runx-cli 0.6.19

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

use runx_runtime::scaffold::{
    InitAction, InitGeneratedValues, RunxInitOptions, RunxInitResult, RunxNewOptions,
    RunxNewResult, runx_init, sanitize_runx_package_name, scaffold_runx_package,
};
use serde::Serialize;

use crate::router::{InitPlan, NewPlan};

pub fn run_native_new(plan: NewPlan) -> ExitCode {
    let cwd = match env::current_dir() {
        Ok(cwd) => cwd,
        Err(error) => {
            let _ignored = write_stderr_line(&format!("runx: failed to resolve cwd: {error}"));
            return ExitCode::from(1);
        }
    };
    let env = crate::history::env_map();
    let directory =
        resolve_new_package_directory(&plan.name, plan.directory.as_deref(), &env, &cwd);
    let options = RunxNewOptions {
        name: plan.name,
        directory,
    };

    match scaffold_runx_package(&options) {
        Ok(result) => render_new_result(plan.json, &result),
        Err(error) => {
            let _ignored = write_stderr_line(&format!("runx: {error}"));
            ExitCode::from(1)
        }
    }
}

pub fn run_native_init(plan: InitPlan) -> ExitCode {
    let cwd = match env::current_dir() {
        Ok(cwd) => cwd,
        Err(error) => {
            let _ignored = write_stderr_line(&format!("runx: failed to resolve cwd: {error}"));
            return ExitCode::from(1);
        }
    };
    let env = crate::history::env_map();
    let global_home_dir = resolve_global_home_dir(&env, &cwd);
    let official_cache_dir = resolve_official_skills_dir(&env, &cwd, &global_home_dir);
    let options = RunxInitOptions {
        action: if plan.global {
            InitAction::Global
        } else {
            InitAction::Project
        },
        project_dir: resolve_project_dir(&env, &cwd),
        global_home_dir,
        official_cache_dir,
        prefetch_official: plan.prefetch_official,
        generated: InitGeneratedValues::generate(),
    };

    match runx_init(&options) {
        Ok(result) => render_init_result(plan.json, &result),
        Err(error) => {
            let _ignored = write_stderr_line(&format!("runx: {error}"));
            ExitCode::from(1)
        }
    }
}

fn write_json<T: serde::Serialize>(command: &str, result: &T) -> ExitCode {
    match serde_json::to_string_pretty(result) {
        Ok(output) => write_stdout_line(&output),
        Err(error) => {
            let _ignored = write_stderr_line(&format!(
                "runx: failed to serialize {command} result: {error}"
            ));
            ExitCode::from(1)
        }
    }
}

fn render_new_result(json: bool, result: &RunxNewResult) -> ExitCode {
    if json {
        return write_json(
            "new",
            &NewJsonResult {
                status: "success",
                new: NewCommandResult {
                    action: "skill",
                    name: &result.name,
                    directory: &result.directory,
                    files: &result.files,
                    next_steps: &result.next_steps,
                },
            },
        );
    }
    write_stdout(&render_key_values(
        "runx new",
        &[
            ("skill", Some(result.name.clone())),
            ("directory", Some(result.directory.display().to_string())),
            ("files", Some(result.files.len().to_string())),
            ("next", Some(result.next_steps.join(" && "))),
        ],
    ))
}

fn render_init_result(json: bool, result: &RunxInitResult) -> ExitCode {
    if json {
        return write_json(
            "init",
            &InitJsonResult {
                status: "success",
                init: result,
            },
        );
    }
    let title = match &result.action {
        InitAction::Global => "runx global init",
        InitAction::Project => "runx project init",
    };
    write_stdout(&render_key_values(
        title,
        &[
            (
                "created",
                Some(if result.created { "yes" } else { "no" }.to_owned()),
            ),
            (
                "project",
                result
                    .project_dir
                    .as_ref()
                    .map(|path| path.display().to_string()),
            ),
            ("project_id", result.project_id.clone()),
            (
                "home",
                result
                    .global_home_dir
                    .as_ref()
                    .map(|path| path.display().to_string()),
            ),
            ("installation_id", result.installation_id.clone()),
            (
                "official_cache",
                result
                    .official_cache_dir
                    .as_ref()
                    .map(|path| path.display().to_string()),
            ),
        ],
    ))
}

fn render_key_values(title: &str, rows: &[(&str, Option<String>)]) -> String {
    let mut output = format!("\n  {title}  success\n\n");
    for (key, value) in rows {
        output.push_str(&format!("  {key}  {}\n", value.as_deref().unwrap_or("-")));
    }
    output.push('\n');
    output
}

fn resolve_new_package_directory(
    name: &str,
    directory: Option<&Path>,
    env: &std::collections::BTreeMap<String, String>,
    cwd: &Path,
) -> PathBuf {
    let root = new_package_base(env, cwd);
    match directory {
        Some(directory) if directory.is_absolute() => directory.to_path_buf(),
        Some(directory) => root.join(directory),
        None => root.join(sanitize_runx_package_name(name)),
    }
}

fn new_package_base(env: &std::collections::BTreeMap<String, String>, cwd: &Path) -> PathBuf {
    runx_runtime::resolve_runx_workspace_base(env, cwd)
}

fn resolve_project_dir(env: &std::collections::BTreeMap<String, String>, cwd: &Path) -> PathBuf {
    if let Some(project_dir) = env.get("RUNX_PROJECT_DIR") {
        return resolve_user_path(project_dir, env, cwd);
    }
    find_nearest_project_runx_dir(cwd)
        .unwrap_or_else(|| runx_runtime::resolve_runx_workspace_base(env, cwd).join(".runx"))
}

fn resolve_global_home_dir(
    env: &std::collections::BTreeMap<String, String>,
    cwd: &Path,
) -> PathBuf {
    env.get("RUNX_HOME")
        .map(|value| resolve_user_path(value, env, cwd))
        .unwrap_or_else(default_home_runx_dir)
}

fn resolve_official_skills_dir(
    env: &std::collections::BTreeMap<String, String>,
    cwd: &Path,
    global_home_dir: &Path,
) -> PathBuf {
    env.get("RUNX_OFFICIAL_SKILLS_DIR")
        .map(|value| resolve_user_path(value, env, cwd))
        .unwrap_or_else(|| global_home_dir.join("official-skills"))
}

fn resolve_user_path(
    value: &str,
    env: &std::collections::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 find_nearest_project_runx_dir(start: &Path) -> Option<PathBuf> {
    for current in start.ancestors() {
        let candidate = current.join(".runx");
        if candidate.join("project.json").exists() {
            return Some(candidate);
        }
    }
    None
}

fn default_home_runx_dir() -> PathBuf {
    env::var_os("HOME")
        .or_else(|| env::var_os("USERPROFILE"))
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".runx")
}

#[derive(Serialize)]
struct NewJsonResult<'a> {
    status: &'static str,
    new: NewCommandResult<'a>,
}

#[derive(Serialize)]
struct NewCommandResult<'a> {
    action: &'static str,
    name: &'a str,
    directory: &'a Path,
    files: &'a [String],
    next_steps: &'a [String],
}

#[derive(Serialize)]
struct InitJsonResult<'a> {
    status: &'static str,
    init: &'a RunxInitResult,
}

fn write_stdout(message: &str) -> ExitCode {
    crate::cli_io::write_stdout_code(message, 0)
}

fn write_stdout_line(message: &str) -> ExitCode {
    crate::cli_io::write_stdout_code(&format!("{message}\n"), 0)
}

fn write_stderr_line(message: &str) -> ExitCode {
    crate::cli_io::write_stderr_code(&format!("{message}\n"))
}