crabmate 0.4.0

Rust AI agent: OpenAI-compatible chat/completions, function calling, HTTP serve, ops CLI
Documentation
//! Node.js / npm / npx 生态工具

use std::path::Path;
use std::process::Command;

use super::ToolContext;
use super::output_util;
use super::test_result_cache::{
    TestCacheKey, TestCacheKind, fingerprint_npm_package_dir, npm_test_args_fingerprint,
    store_cached, try_get_cached, wrap_cache_hit,
};
use super::tool_param_types::{NpmInstallArgs, NpmRunArgs, NpxRunArgs, TscCheckArgs};

const MAX_OUTPUT_LINES: usize = 800;

pub fn npm_install(args_json: &str, workspace_root: &Path, max_output_len: usize) -> String {
    let v = match crate::cm_tools::tools::parse_args_json(args_json) {
        Ok(v) => v,
        Err(e) => return e,
    };
    let args: NpmInstallArgs = match serde_json::from_value(v) {
        Ok(a) => a,
        Err(e) => return format!("参数 JSON 与 npm_install 形状不一致: {e}"),
    };
    let subdir = normalize_npm_subdir(args.subdir.as_deref());
    let ci = args.ci;
    let production = args.production;

    let dir = match resolve_npm_package_dir(workspace_root, subdir) {
        Ok(d) => d,
        Err(e) => return format!("npm install: {e}"),
    };

    let mut cmd = Command::new("npm");
    if ci {
        cmd.arg("ci");
    } else {
        cmd.arg("install");
    }
    if production {
        cmd.arg("--production");
    }
    cmd.current_dir(&dir);
    let title = if ci { "npm ci" } else { "npm install" };
    run_and_format(cmd, max_output_len, title)
}

pub fn npm_run(
    args_json: &str,
    workspace_root: &Path,
    max_output_len: usize,
    ctx: &ToolContext<'_>,
) -> String {
    let v = match crate::cm_tools::tools::parse_args_json(args_json) {
        Ok(v) => v,
        Err(e) => return e,
    };
    let args: NpmRunArgs = match serde_json::from_value(v) {
        Ok(a) => a,
        Err(e) => return format!("参数 JSON 与 npm_run 形状不一致: {e}"),
    };
    let subdir = normalize_npm_subdir(args.subdir.as_deref());
    if let Err(e) = validate_npm_script(&args.script) {
        return e;
    }
    let script = args.script.trim();

    let dir = match resolve_npm_package_dir(workspace_root, subdir) {
        Ok(d) => d,
        Err(_) => {
            return format!("npm run {}: 跳过({}/package.json 不存在)", script, subdir);
        }
    };

    let extra_args = args.args;

    let run = || {
        let mut cmd = Command::new("npm");
        cmd.arg("run").arg(script);
        if !extra_args.is_empty() {
            cmd.arg("--");
            for a in &extra_args {
                cmd.arg(a);
            }
        }
        cmd.current_dir(&dir);
        run_and_format(cmd, max_output_len, &format!("npm run {}", script))
    };

    if ctx.test_result_cache_enabled
        && script == "test"
        && let Some(inputs_fp) = fingerprint_npm_package_dir(workspace_root, subdir)
    {
        let args_fp = npm_test_args_fingerprint(subdir, script, &extra_args);
        let key = TestCacheKey {
            workspace_root: workspace_root.to_path_buf(),
            kind: TestCacheKind::NpmTest {
                package_subdir: subdir.to_string(),
            },
            args_fingerprint: args_fp,
            inputs_fingerprint: inputs_fp.clone(),
        };
        if let Some(hit) = try_get_cached(
            ctx.test_result_cache_enabled,
            ctx.test_result_cache_max_entries,
            &key,
        ) {
            return wrap_cache_hit(&inputs_fp, &hit);
        }
        let out = run();
        store_cached(
            ctx.test_result_cache_enabled,
            ctx.test_result_cache_max_entries,
            key,
            out.clone(),
        );
        return out;
    }

    run()
}

pub fn npx_run(args_json: &str, workspace_root: &Path, max_output_len: usize) -> String {
    let v = match crate::cm_tools::tools::parse_args_json(args_json) {
        Ok(v) => v,
        Err(e) => return e,
    };
    let args: NpxRunArgs = match serde_json::from_value(v) {
        Ok(a) => a,
        Err(e) => return format!("参数 JSON 与 npx_run 形状不一致: {e}"),
    };
    let subdir = args
        .subdir
        .as_deref()
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .unwrap_or(".");
    let package = args.package.trim();
    if package.is_empty() {
        return "错误:缺少 package 参数".to_string();
    }
    if package.contains("..") || package.starts_with('/') {
        return "错误:package 参数不安全".to_string();
    }

    if let Some(e) = check_subdir(subdir) {
        return e;
    }
    let dir = workspace_root.join(subdir);
    if !dir.join("package.json").is_file() {
        return format!("npx {}: 跳过({}/package.json 不存在)", package, subdir);
    }

    let extra_args: Vec<&str> = args.args.iter().map(|s| s.as_str()).collect();

    let mut cmd = Command::new("npx");
    cmd.arg("--yes").arg(package);
    for a in &extra_args {
        cmd.arg(a);
    }
    cmd.current_dir(&dir);
    run_and_format(cmd, max_output_len, &format!("npx {}", package))
}

pub fn tsc_check(args_json: &str, workspace_root: &Path, max_output_len: usize) -> String {
    let v = match crate::cm_tools::tools::parse_args_json(args_json) {
        Ok(v) => v,
        Err(e) => return e,
    };
    let args: TscCheckArgs = match serde_json::from_value(v) {
        Ok(a) => a,
        Err(e) => return format!("参数 JSON 与 tsc_check 形状不一致: {e}"),
    };
    let subdir = args
        .subdir
        .as_deref()
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .unwrap_or(".");
    let project = args
        .project
        .as_deref()
        .map(str::trim)
        .filter(|s| !s.is_empty());
    let strict = args.strict;

    if let Some(e) = check_subdir(subdir) {
        return e;
    }
    let dir = workspace_root.join(subdir);
    if !dir.join("package.json").is_file() && !dir.join("tsconfig.json").is_file() {
        return format!(
            "tsc: 跳过({} 下未找到 package.json 或 tsconfig.json)",
            subdir
        );
    }

    let mut cmd = Command::new("npx");
    cmd.arg("tsc");
    if let Some(p) = project {
        if p.contains("..") || p.starts_with('/') {
            return "错误:project 参数不安全".to_string();
        }
        cmd.arg("-p").arg(p);
    } else {
        cmd.arg("-b");
    }
    cmd.arg("--noEmit");
    if strict {
        cmd.arg("--strict");
    }
    cmd.current_dir(&dir);
    run_and_format(cmd, max_output_len, "npx tsc --noEmit")
}

fn normalize_npm_subdir(raw: Option<&str>) -> &str {
    raw.map(str::trim).filter(|s| !s.is_empty()).unwrap_or(".")
}

fn resolve_npm_package_dir(
    workspace_root: &Path,
    subdir: &str,
) -> Result<std::path::PathBuf, String> {
    if let Some(e) = check_subdir(subdir) {
        return Err(e);
    }
    let dir = workspace_root.join(subdir);
    if !dir.join("package.json").is_file() {
        return Err(format!("npm: 跳过({}/package.json 不存在)", subdir));
    }
    Ok(dir)
}

fn validate_npm_script(script: &str) -> Result<(), String> {
    let script = script.trim();
    if script.is_empty() {
        return Err("错误:缺少 script 参数".to_string());
    }
    if script.contains(char::is_whitespace) {
        return Err("错误:script 参数无效(不能包含空白字符)".to_string());
    }
    Ok(())
}

fn check_subdir(subdir: &str) -> Option<String> {
    if subdir.starts_with('/') || subdir.contains("..") {
        Some("错误:subdir 必须是工作区内相对路径,且不能包含 ..".to_string())
    } else {
        None
    }
}

fn run_and_format(cmd: Command, max_output_len: usize, title: &str) -> String {
    output_util::run_command_output_formatted(
        cmd,
        title,
        max_output_len,
        MAX_OUTPUT_LINES,
        output_util::ProcessOutputMerge::ConcatStdoutStderr,
        output_util::CommandSpawnErrorStyle::CannotStartCommand,
    )
}