Skip to main content

runx_cli/
export.rs

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}