1use std::collections::BTreeMap;
2use std::fs;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::process::ExitCode;
6
7use runx_runtime::export::{RunxExportLoadError, RunxExportLoadOptions};
8use serde::Serialize;
9
10mod managed;
11mod parser;
12mod report;
13mod shim;
14
15pub use parser::parse_export_plan;
16
17const CLAUDE_MARKER: &str = "runx-export:claude";
18const CODEX_MARKER: &str = "runx-export:codex";
19const CODEX_RULE_START: &str = "# >>> runx-export start (managed) >>>";
20const CODEX_RULE_END: &str = "# <<< runx-export end <<<";
21const CODEX_RULE_RUNX_ON_PATH: &str = "prefix_rule(pattern = [\"runx\", \"skill\"], decision = \"allow\", justification = \"runx skill invocations are trusted\")";
22const CODEX_RULE_RUNX_RESUME_ON_PATH: &str = "prefix_rule(pattern = [\"runx\", \"resume\"], decision = \"allow\", justification = \"runx resume invocations are trusted\")";
23
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct ExportPlan {
26 pub target: Target,
27 pub refs: Vec<String>,
28 pub project: bool,
29 pub json: bool,
30}
31
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub enum Target {
34 Claude,
35 Codex,
36}
37
38impl Target {
39 fn as_str(self) -> &'static str {
40 match self {
41 Self::Claude => "claude",
42 Self::Codex => "codex",
43 }
44 }
45
46 fn marker(self) -> &'static str {
47 match self {
48 Self::Claude => CLAUDE_MARKER,
49 Self::Codex => CODEX_MARKER,
50 }
51 }
52}
53
54#[derive(Clone, Debug)]
55struct GeneratedFile {
56 path: PathBuf,
57 contents: String,
58}
59
60#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
61pub struct ExportReport {
62 pub target: String,
63 pub scope: String,
64 pub exported: Vec<ExportedFile>,
65 pub pruned: Vec<String>,
66 pub rules_file: Option<String>,
67 pub warnings: Vec<String>,
68}
69
70#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
71pub struct ExportedFile {
72 pub skill: String,
73 pub path: String,
74}
75
76#[derive(Debug)]
77pub enum ExportError {
78 InvalidArgs(String),
79 Io {
80 context: String,
81 source: std::io::Error,
82 },
83 Parse(String),
84 Unsupported(String),
85}
86
87impl std::fmt::Display for ExportError {
88 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 match self {
90 Self::InvalidArgs(message) | Self::Parse(message) | Self::Unsupported(message) => {
91 formatter.write_str(message)
92 }
93 Self::Io { context, source } => write!(formatter, "{context}: {source}"),
94 }
95 }
96}
97
98impl std::error::Error for ExportError {}
99
100impl From<RunxExportLoadError> for ExportError {
101 fn from(error: RunxExportLoadError) -> Self {
102 match error {
103 RunxExportLoadError::InvalidArgs(message) => Self::InvalidArgs(message),
104 RunxExportLoadError::Io { context, source } => Self::Io { context, source },
105 RunxExportLoadError::Parse(message) => Self::Parse(message),
106 }
107 }
108}
109
110pub fn run_native_export(plan: ExportPlan) -> ExitCode {
111 let env = std::env::vars().collect::<BTreeMap<_, _>>();
112 let cwd = match std::env::current_dir() {
113 Ok(cwd) => cwd,
114 Err(error) => {
115 let _ignored = writeln!(std::io::stderr(), "runx: failed to resolve cwd: {error}");
116 return ExitCode::from(1);
117 }
118 };
119
120 match run_export_command(&plan, &cwd, &env) {
121 Ok(report) => report::write_report(&report, plan.json),
122 Err(ExportError::InvalidArgs(message)) => {
123 let _ignored = writeln!(std::io::stderr(), "runx: {message}");
124 ExitCode::from(64)
125 }
126 Err(error) => {
127 let _ignored = writeln!(std::io::stderr(), "runx: {error}");
128 ExitCode::from(1)
129 }
130 }
131}
132
133pub fn run_export_command(
134 plan: &ExportPlan,
135 cwd: &Path,
136 env: &BTreeMap<String, String>,
137) -> Result<ExportReport, ExportError> {
138 validate_export_plan(plan)?;
139 let root = canonicalize(cwd, "canonicalizing export root")?;
140 let runx_bin = exported_runx_binary(env)?;
141 let skills = runx_runtime::export::load_export_skills_with_options(RunxExportLoadOptions {
142 root: &root,
143 refs: &plan.refs,
144 official_roots: official_skill_roots(env, cwd, &runx_bin),
145 })?;
146 let skill_dir = target_skill_dir(plan.target, plan.project, cwd, env);
147 let files = shim::plan_files(
148 plan.target,
149 plan.project,
150 &root,
151 &skills,
152 &skill_dir,
153 &runx_bin,
154 );
155 let pruned = managed::prune_managed_files(plan.target, &skill_dir, &files)?;
156 managed::write_files(&files)?;
157 let rules_file = if plan.target == Target::Codex && !plan.project {
158 Some(managed::merge_codex_rules(
159 &codex_rules_file(env, cwd),
160 &runx_bin,
161 )?)
162 } else {
163 None
164 };
165
166 Ok(export_report(plan, &files, pruned, rules_file))
167}
168
169fn exported_runx_binary(env: &BTreeMap<String, String>) -> Result<PathBuf, ExportError> {
170 if let Some(value) = env
171 .get("RUNX_EXPORT_BIN")
172 .filter(|value| !value.trim().is_empty())
173 {
174 return Ok(PathBuf::from(value));
175 }
176 std::env::current_exe().map_err(|source| ExportError::Io {
177 context: "resolving current runx binary for export shim".to_owned(),
178 source,
179 })
180}
181
182fn official_skill_roots(
183 env: &BTreeMap<String, String>,
184 cwd: &Path,
185 runx_bin: &Path,
186) -> Vec<PathBuf> {
187 let mut roots = Vec::new();
188 if let Some(value) = env
189 .get("RUNX_OFFICIAL_SKILLS_SOURCE_DIR")
190 .filter(|value| !value.trim().is_empty())
191 {
192 roots.push(resolve_user_path(value, env, cwd));
193 }
194 if let Some(value) = env
195 .get("RUNX_OFFICIAL_SKILLS_DIR")
196 .filter(|value| !value.trim().is_empty())
197 {
198 roots.push(resolve_user_path(value, env, cwd));
199 }
200 if let Some(root) = discover_checkout_official_skills_root(runx_bin) {
201 roots.push(root);
202 }
203 dedupe_paths(roots)
204}
205
206fn discover_checkout_official_skills_root(runx_bin: &Path) -> Option<PathBuf> {
207 for ancestor in runx_bin.ancestors() {
208 let candidate = ancestor.join("skills");
209 if candidate.join("send-as").join("SKILL.md").exists() {
210 return Some(candidate);
211 }
212 }
213 None
214}
215
216fn dedupe_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
217 let mut deduped = Vec::new();
218 for path in paths {
219 if !deduped.iter().any(|existing| existing == &path) {
220 deduped.push(path);
221 }
222 }
223 deduped
224}
225
226fn validate_export_plan(plan: &ExportPlan) -> Result<(), ExportError> {
227 if plan.target == Target::Codex && plan.project {
228 return Err(ExportError::Unsupported(
229 "runx export codex --project is not supported until Codex project skill and rules paths are verified".to_owned(),
230 ));
231 }
232 Ok(())
233}
234
235fn export_report(
236 plan: &ExportPlan,
237 files: &[GeneratedFile],
238 pruned: Vec<String>,
239 rules_file: Option<PathBuf>,
240) -> ExportReport {
241 ExportReport {
242 target: plan.target.as_str().to_owned(),
243 scope: scope_name(plan.project).to_owned(),
244 exported: files
245 .iter()
246 .map(|file| ExportedFile {
247 skill: file
248 .path
249 .parent()
250 .and_then(Path::file_name)
251 .and_then(|name| name.to_str())
252 .unwrap_or("unknown")
253 .to_owned(),
254 path: display_path(&file.path),
255 })
256 .collect(),
257 pruned,
258 rules_file: rules_file.map(|path| display_path(&path)),
259 warnings: Vec::new(),
260 }
261}
262
263fn target_skill_dir(
264 target: Target,
265 project: bool,
266 cwd: &Path,
267 env: &BTreeMap<String, String>,
268) -> PathBuf {
269 if project {
270 return match target {
271 Target::Claude => cwd.join(".claude").join("skills"),
272 Target::Codex => cwd.join(".codex").join("skills"),
273 };
274 }
275 let home = home_dir(env, cwd);
276 match target {
277 Target::Claude => home.join(".claude").join("skills"),
278 Target::Codex => home.join(".codex").join("skills"),
279 }
280}
281
282fn codex_rules_file(env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
283 home_dir(env, cwd)
284 .join(".codex")
285 .join("rules")
286 .join("default.rules")
287}
288
289fn canonicalize(path: &Path, context: &str) -> Result<PathBuf, ExportError> {
290 fs::canonicalize(path).map_err(|source| ExportError::Io {
291 context: format!("{context} {}", display_path(path)),
292 source,
293 })
294}
295
296fn home_dir(env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
297 env.get("HOME")
298 .map(PathBuf::from)
299 .unwrap_or_else(|| cwd.to_path_buf())
300}
301
302fn resolve_user_path(value: &str, env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
303 let path = PathBuf::from(value);
304 if path.is_absolute() {
305 path
306 } else {
307 runx_runtime::resolve_runx_workspace_base(env, cwd).join(path)
308 }
309}
310
311fn scope_name(project: bool) -> &'static str {
312 if project { "project" } else { "global" }
313}
314
315fn display_path(path: &Path) -> String {
316 path.to_string_lossy().into_owned()
317}