runx-cli 0.6.19

Cargo-installed launcher for the runx governed agent workflow CLI.
Documentation
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use runx_runtime::export::{RunxExportLoadError, RunxExportLoadOptions};
use serde::Serialize;

mod managed;
mod parser;
mod report;
mod shim;

pub use parser::parse_export_plan;

const CLAUDE_MARKER: &str = "runx-export:claude";
const CODEX_MARKER: &str = "runx-export:codex";
const CODEX_RULE_START: &str = "# >>> runx-export start (managed) >>>";
const CODEX_RULE_END: &str = "# <<< runx-export end <<<";
const CODEX_RULE_RUNX_ON_PATH: &str = "prefix_rule(pattern = [\"runx\", \"skill\"], decision = \"allow\", justification = \"runx skill invocations are trusted\")";
const CODEX_RULE_RUNX_RESUME_ON_PATH: &str = "prefix_rule(pattern = [\"runx\", \"resume\"], decision = \"allow\", justification = \"runx resume invocations are trusted\")";

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ExportPlan {
    pub target: Target,
    pub refs: Vec<String>,
    pub project: bool,
    pub json: bool,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Target {
    Claude,
    Codex,
}

impl Target {
    fn as_str(self) -> &'static str {
        match self {
            Self::Claude => "claude",
            Self::Codex => "codex",
        }
    }

    fn marker(self) -> &'static str {
        match self {
            Self::Claude => CLAUDE_MARKER,
            Self::Codex => CODEX_MARKER,
        }
    }
}

#[derive(Clone, Debug)]
struct GeneratedFile {
    path: PathBuf,
    contents: String,
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct ExportReport {
    pub target: String,
    pub scope: String,
    pub exported: Vec<ExportedFile>,
    pub pruned: Vec<String>,
    pub rules_file: Option<String>,
    pub warnings: Vec<String>,
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct ExportedFile {
    pub skill: String,
    pub path: String,
}

#[derive(Debug)]
pub enum ExportError {
    InvalidArgs(String),
    Io {
        context: String,
        source: std::io::Error,
    },
    Parse(String),
    Unsupported(String),
}

impl std::fmt::Display for ExportError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::InvalidArgs(message) | Self::Parse(message) | Self::Unsupported(message) => {
                formatter.write_str(message)
            }
            Self::Io { context, source } => write!(formatter, "{context}: {source}"),
        }
    }
}

impl std::error::Error for ExportError {}

impl From<RunxExportLoadError> for ExportError {
    fn from(error: RunxExportLoadError) -> Self {
        match error {
            RunxExportLoadError::InvalidArgs(message) => Self::InvalidArgs(message),
            RunxExportLoadError::Io { context, source } => Self::Io { context, source },
            RunxExportLoadError::Parse(message) => Self::Parse(message),
        }
    }
}

pub fn run_native_export(plan: ExportPlan) -> ExitCode {
    let env = std::env::vars().collect::<BTreeMap<_, _>>();
    let cwd = match std::env::current_dir() {
        Ok(cwd) => cwd,
        Err(error) => {
            let _ignored = writeln!(std::io::stderr(), "runx: failed to resolve cwd: {error}");
            return ExitCode::from(1);
        }
    };

    match run_export_command(&plan, &cwd, &env) {
        Ok(report) => report::write_report(&report, plan.json),
        Err(ExportError::InvalidArgs(message)) => {
            let _ignored = writeln!(std::io::stderr(), "runx: {message}");
            ExitCode::from(64)
        }
        Err(error) => {
            let _ignored = writeln!(std::io::stderr(), "runx: {error}");
            ExitCode::from(1)
        }
    }
}

pub fn run_export_command(
    plan: &ExportPlan,
    cwd: &Path,
    env: &BTreeMap<String, String>,
) -> Result<ExportReport, ExportError> {
    validate_export_plan(plan)?;
    let root = canonicalize(cwd, "canonicalizing export root")?;
    let runx_bin = exported_runx_binary(env)?;
    let skills = runx_runtime::export::load_export_skills_with_options(RunxExportLoadOptions {
        root: &root,
        refs: &plan.refs,
        official_roots: official_skill_roots(env, cwd, &runx_bin),
    })?;
    let skill_dir = target_skill_dir(plan.target, plan.project, cwd, env);
    let files = shim::plan_files(
        plan.target,
        plan.project,
        &root,
        &skills,
        &skill_dir,
        &runx_bin,
    );
    let pruned = managed::prune_managed_files(plan.target, &skill_dir, &files)?;
    managed::write_files(&files)?;
    let rules_file = if plan.target == Target::Codex && !plan.project {
        Some(managed::merge_codex_rules(
            &codex_rules_file(env, cwd),
            &runx_bin,
        )?)
    } else {
        None
    };

    Ok(export_report(plan, &files, pruned, rules_file))
}

fn exported_runx_binary(env: &BTreeMap<String, String>) -> Result<PathBuf, ExportError> {
    if let Some(value) = env
        .get("RUNX_EXPORT_BIN")
        .filter(|value| !value.trim().is_empty())
    {
        return Ok(PathBuf::from(value));
    }
    std::env::current_exe().map_err(|source| ExportError::Io {
        context: "resolving current runx binary for export shim".to_owned(),
        source,
    })
}

fn official_skill_roots(
    env: &BTreeMap<String, String>,
    cwd: &Path,
    runx_bin: &Path,
) -> Vec<PathBuf> {
    let mut roots = Vec::new();
    if let Some(value) = env
        .get("RUNX_OFFICIAL_SKILLS_SOURCE_DIR")
        .filter(|value| !value.trim().is_empty())
    {
        roots.push(resolve_user_path(value, env, cwd));
    }
    if let Some(value) = env
        .get("RUNX_OFFICIAL_SKILLS_DIR")
        .filter(|value| !value.trim().is_empty())
    {
        roots.push(resolve_user_path(value, env, cwd));
    }
    if let Some(root) = discover_checkout_official_skills_root(runx_bin) {
        roots.push(root);
    }
    dedupe_paths(roots)
}

fn discover_checkout_official_skills_root(runx_bin: &Path) -> Option<PathBuf> {
    for ancestor in runx_bin.ancestors() {
        let candidate = ancestor.join("skills");
        if candidate.join("send-as").join("SKILL.md").exists() {
            return Some(candidate);
        }
    }
    None
}

fn dedupe_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
    let mut deduped = Vec::new();
    for path in paths {
        if !deduped.iter().any(|existing| existing == &path) {
            deduped.push(path);
        }
    }
    deduped
}

fn validate_export_plan(plan: &ExportPlan) -> Result<(), ExportError> {
    if plan.target == Target::Codex && plan.project {
        return Err(ExportError::Unsupported(
            "runx export codex --project is not supported until Codex project skill and rules paths are verified".to_owned(),
        ));
    }
    Ok(())
}

fn export_report(
    plan: &ExportPlan,
    files: &[GeneratedFile],
    pruned: Vec<String>,
    rules_file: Option<PathBuf>,
) -> ExportReport {
    ExportReport {
        target: plan.target.as_str().to_owned(),
        scope: scope_name(plan.project).to_owned(),
        exported: files
            .iter()
            .map(|file| ExportedFile {
                skill: file
                    .path
                    .parent()
                    .and_then(Path::file_name)
                    .and_then(|name| name.to_str())
                    .unwrap_or("unknown")
                    .to_owned(),
                path: display_path(&file.path),
            })
            .collect(),
        pruned,
        rules_file: rules_file.map(|path| display_path(&path)),
        warnings: Vec::new(),
    }
}

fn target_skill_dir(
    target: Target,
    project: bool,
    cwd: &Path,
    env: &BTreeMap<String, String>,
) -> PathBuf {
    if project {
        return match target {
            Target::Claude => cwd.join(".claude").join("skills"),
            Target::Codex => cwd.join(".codex").join("skills"),
        };
    }
    let home = home_dir(env, cwd);
    match target {
        Target::Claude => home.join(".claude").join("skills"),
        Target::Codex => home.join(".codex").join("skills"),
    }
}

fn codex_rules_file(env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
    home_dir(env, cwd)
        .join(".codex")
        .join("rules")
        .join("default.rules")
}

fn canonicalize(path: &Path, context: &str) -> Result<PathBuf, ExportError> {
    fs::canonicalize(path).map_err(|source| ExportError::Io {
        context: format!("{context} {}", display_path(path)),
        source,
    })
}

fn home_dir(env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
    env.get("HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|| cwd.to_path_buf())
}

fn resolve_user_path(value: &str, env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
    let path = PathBuf::from(value);
    if path.is_absolute() {
        path
    } else {
        runx_runtime::resolve_runx_workspace_base(env, cwd).join(path)
    }
}

fn scope_name(project: bool) -> &'static str {
    if project { "project" } else { "global" }
}

fn display_path(path: &Path) -> String {
    path.to_string_lossy().into_owned()
}