runx-cli 0.6.19

Cargo-installed launcher for the runx governed agent workflow CLI.
Documentation
// rust-style-allow: large-file - command wiring keeps tool build/search/inspect output parity together.
use std::collections::BTreeMap;
use std::env;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use runx_runtime::{
    ToolBuildOptions, ToolCatalogError, ToolInspectOptions, ToolSearchOptions, build_tool_catalogs,
    inspect_tool, search_tools,
};

use crate::router::{ToolAction, ToolPlan};

pub fn run_native_tool(plan: ToolPlan) -> ExitCode {
    match run_tool(plan) {
        Ok(output) => crate::cli_io::write_stdout_code(&output.stdout, output.exit_code),
        Err(error) => {
            let _ignored = crate::cli_io::write_stderr_code(&render_cli_error(&error.to_string()));
            ExitCode::from(error.exit_code())
        }
    }
}

struct ToolCliOutput {
    stdout: String,
    exit_code: u8,
}

fn run_tool(plan: ToolPlan) -> Result<ToolCliOutput, ToolCliError> {
    let env = env_pairs();
    let cwd = env::current_dir().map_err(|error| ToolCliError::Internal(error.to_string()))?;
    match plan.action {
        ToolAction::Build => run_build(plan, &env, &cwd),
        ToolAction::Search => run_search(plan, &env),
        ToolAction::Inspect => run_inspect(plan, &env, &cwd),
    }
}

fn run_build(
    plan: ToolPlan,
    env: &BTreeMap<String, String>,
    cwd: &Path,
) -> Result<ToolCliOutput, ToolCliError> {
    let root = runx_runtime::resolve_runx_workspace_base(env, cwd);
    let tool_path = plan
        .path
        .as_deref()
        .map(|path| resolve_user_path(path, env, cwd));
    let toolkit_version = toolkit_version(env);
    let report = build_tool_catalogs(&ToolBuildOptions {
        root,
        tool_path,
        all: plan.all,
        toolkit_version,
    })?;
    let stdout = if plan.json {
        json_line(&report)?
    } else {
        render_build_report(report.built.len(), &report.errors)
    };
    let exit_code = if report.status == runx_contracts::tools::ToolBuildStatus::Success {
        0
    } else {
        1
    };
    Ok(ToolCliOutput { stdout, exit_code })
}

fn run_search(
    plan: ToolPlan,
    env: &BTreeMap<String, String>,
) -> Result<ToolCliOutput, ToolCliError> {
    let query = plan
        .ref_or_query
        .ok_or_else(|| ToolCliError::Usage("runx tool search requires a query".to_owned()))?;
    let report = search_tools(&ToolSearchOptions {
        query,
        source: plan.source,
        limit: 20,
        fixture_catalog_enabled: env_value(env, "RUNX_ENABLE_FIXTURE_TOOL_CATALOG")
            .is_some_and(|value| value == "1"),
    });
    let stdout = if plan.json {
        json_line(&report)?
    } else {
        render_search_results(&report.results)
    };
    Ok(ToolCliOutput {
        stdout,
        exit_code: 0,
    })
}

fn run_inspect(
    plan: ToolPlan,
    env: &BTreeMap<String, String>,
    cwd: &Path,
) -> Result<ToolCliOutput, ToolCliError> {
    let tool_ref = plan.ref_or_query.ok_or_else(|| {
        ToolCliError::Usage("runx tool inspect requires a tool reference".to_owned())
    })?;
    let root = runx_runtime::resolve_runx_workspace_base(env, cwd);
    let search_from_directory = resolve_user_path(Path::new("."), env, cwd);
    let tool_roots = env_value(env, "RUNX_TOOL_ROOTS")
        .map(|value| split_env_paths(&value))
        .unwrap_or_default();
    let report = inspect_tool(&ToolInspectOptions {
        root,
        tool_ref,
        source: plan.source,
        search_from_directory,
        tool_roots,
        fixture_catalog_enabled: env_value(env, "RUNX_ENABLE_FIXTURE_TOOL_CATALOG")
            .is_some_and(|value| value == "1"),
        allow_explicit_manifest_path: true,
    })?;
    let stdout = if plan.json {
        json_line(&report)?
    } else {
        render_inspect_result(&report.tool)
    };
    Ok(ToolCliOutput {
        stdout,
        exit_code: 0,
    })
}

fn env_pairs() -> BTreeMap<String, String> {
    env::vars().collect()
}

fn env_value(env: &BTreeMap<String, String>, key: &str) -> Option<String> {
    env.get(key).cloned()
}

fn resolve_user_path(user_path: &Path, env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
    runx_runtime::resolve_path_from_user_input(&user_path.to_string_lossy(), env, cwd, true)
}

fn split_env_paths(value: &str) -> Vec<PathBuf> {
    env::split_paths(value).collect()
}

fn toolkit_version(env: &BTreeMap<String, String>) -> String {
    env_value(env, "RUNX_AUTHORING_TOOLKIT_VERSION")
        .or_else(|| env_value(env, "RUNX_AUTHORING_PACKAGE_VERSION"))
        .map(|value| value.trim_start_matches('^').to_owned())
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| "0.1.4".to_owned())
}

fn json_line<T: serde::Serialize>(value: &T) -> Result<String, ToolCliError> {
    serde_json::to_string_pretty(value)
        .map(|json| format!("{json}\n"))
        .map_err(|error| ToolCliError::Internal(error.to_string()))
}

fn render_build_report(count: usize, errors: &[String]) -> String {
    let mut lines = vec!["".to_owned(), format!("  tool build  {count} tool(s)")];
    for error in errors {
        lines.push(format!("  {error}"));
    }
    lines.push(String::new());
    lines.join("\n")
}

fn render_search_results(results: &[runx_contracts::tools::ToolCatalogSearchResult]) -> String {
    if results.is_empty() {
        return "\n  No imported tools found.\n\n".to_owned();
    }
    let mut lines = vec!["".to_owned(), "  Imported Tools".to_owned()];
    for result in results {
        lines.push(format!("  {}  {}", result.name, result.source_label));
        lines.push(format!("  type      {}", result.source_type));
        lines.push(format!("  namespace {}", result.namespace));
        lines.push(format!("  external  {}", result.external_name));
        lines.push(format!("  catalog   {}", result.catalog_ref));
        if !result.required_scopes.is_empty() {
            lines.push(format!("  scopes    {}", result.required_scopes.join(", ")));
        }
        if let Some(summary) = &result.summary {
            lines.push(format!("  summary   {summary}"));
        }
        lines.push(String::new());
    }
    format!("{}\n", lines.join("\n"))
}

fn render_inspect_result(result: &runx_contracts::tools::ToolInspectResult) -> String {
    let mut lines = inspect_header_lines(result);
    if matches!(
        result.provenance.origin,
        runx_contracts::tools::ToolInspectOrigin::Imported
    ) {
        lines.extend(imported_tool_lines(result));
    }
    if !result.scopes.is_empty() {
        lines.push(format!("  scopes    {}", result.scopes.join(", ")));
    }
    if let Some(description) = &result.description {
        lines.push(format!("  summary   {description}"));
    }
    if !result.inputs.is_empty() {
        lines.push("  inputs".to_owned());
        lines.extend(input_lines(result));
    }
    lines.push(String::new());
    format!("{}\n", lines.join("\n"))
}

fn inspect_header_lines(result: &runx_contracts::tools::ToolInspectResult) -> Vec<String> {
    let origin = match result.provenance.origin {
        runx_contracts::tools::ToolInspectOrigin::Local => "local",
        runx_contracts::tools::ToolInspectOrigin::Imported => "imported",
    };
    vec![
        String::new(),
        format!("  {}  {origin}", result.name),
        format!("  exec      {}", result.execution_source_type),
        format!("  path      {}", result.reference_path),
        format!("  root      {}", result.skill_directory),
    ]
}

fn imported_tool_lines(result: &runx_contracts::tools::ToolInspectResult) -> Vec<String> {
    vec![
        format!(
            "  catalog   {}",
            result
                .provenance
                .catalog_ref
                .as_deref()
                .unwrap_or("unknown")
        ),
        format!("  source    {}", inspect_source_label(result)),
        format!(
            "  kind      {}",
            result
                .provenance
                .source_type
                .as_deref()
                .unwrap_or("unknown")
        ),
        format!(
            "  external  {}",
            result
                .provenance
                .external_name
                .as_deref()
                .unwrap_or("unknown")
        ),
    ]
}

fn inspect_source_label(result: &runx_contracts::tools::ToolInspectResult) -> &str {
    result
        .provenance
        .source_label
        .as_deref()
        .or(result.provenance.source.as_deref())
        .unwrap_or("unknown")
}

fn input_lines(result: &runx_contracts::tools::ToolInspectResult) -> Vec<String> {
    result
        .inputs
        .iter()
        .map(|(name, input)| {
            let required = if input.required {
                "required"
            } else {
                "optional"
            };
            let description = input
                .description
                .as_ref()
                .map(|value| format!(" · {value}"))
                .unwrap_or_default();
            format!("    {name}: {} · {required}{description}", input.input_type)
        })
        .collect()
}

fn render_cli_error(message: &str) -> String {
    format!("\n{message}\n")
}

#[derive(Debug)]
enum ToolCliError {
    Usage(String),
    Runtime(ToolCatalogError),
    Internal(String),
}

impl ToolCliError {
    fn exit_code(&self) -> u8 {
        match self {
            Self::Usage(_) => 2,
            Self::Runtime(_) | Self::Internal(_) => 1,
        }
    }
}

impl std::fmt::Display for ToolCliError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Usage(message) | Self::Internal(message) => formatter.write_str(message),
            Self::Runtime(error) => write!(formatter, "{error}"),
        }
    }
}

impl From<ToolCatalogError> for ToolCliError {
    fn from(value: ToolCatalogError) -> Self {
        Self::Runtime(value)
    }
}