diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs index 3e015546c..f0235c39e 100644 --- a/src/mcp/tools/mod.rs +++ b/src/mcp/tools/mod.rs @@ -11,6 +11,7 @@ pub(crate) mod render; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::fmt::Write as _; pub use definitions::{ ast_grep_available, ast_grep_diagnostics_json, ast_grep_outline_available, context_description, @@ -77,6 +78,92 @@ impl ToolResult { } } +/// Render the CLI help shown by `tracedecay tool --help`. +/// +/// Kept in the library so tests and generated integration surfaces can +/// validate the dynamic tool help without spawning the binary once per tool. +pub fn render_tool_cli_help(def: &ToolDefinition) -> String { + let short = short_tool_name(&def.name); + let mut out = String::new(); + let _ = writeln!(out, "tracedecay tool {short}"); + let _ = writeln!(out); + let _ = writeln!(out, "{}", def.description); + let _ = writeln!(out); + + let props = def + .input_schema + .get("properties") + .and_then(Value::as_object) + .filter(|props| !props.is_empty()); + let required: Vec<&str> = def + .input_schema + .get("required") + .and_then(Value::as_array) + .map(|arr| arr.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + + let Some(props) = props else { + let _ = writeln!(out, "(no parameters)"); + let _ = writeln!(out); + let _ = writeln!( + out, + "Usage: tracedecay tool {short} [--json] [--project ]" + ); + return out; + }; + + let mut usage_params = String::new(); + for req in &required { + let _ = write!(usage_params, " --{} ", req.replace('_', "-")); + } + let _ = writeln!( + out, + "Usage: tracedecay tool {short}{usage_params} [--key value]... [--json]" + ); + let _ = writeln!(out); + + let _ = writeln!(out, "Parameters:"); + let mut entries: Vec<(&String, &Value)> = props.iter().collect(); + entries.sort_by_key(|(k, _)| (*k).clone()); + for (key, schema) in entries { + let ty = schema + .get("type") + .and_then(Value::as_str) + .unwrap_or("string"); + let req = if required.contains(&key.as_str()) { + "required" + } else { + "optional" + }; + let desc = schema + .get("description") + .and_then(Value::as_str) + .unwrap_or(""); + let _ = writeln!( + out, + " --{:<26} {:<8} {:<8} {}", + key.replace('_', "-"), + ty, + req, + desc + ); + } + let _ = writeln!(out); + let _ = writeln!( + out, + "Reserved flags: --json, --project , --args , -h/--help" + ); + let _ = writeln!( + out, + "Any value starting with @ is read from that file (multi-line payloads)." + ); + out +} + +fn short_tool_name(full: &str) -> &str { + full.strip_prefix("tracedecay_").unwrap_or(full) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/tool_command.rs b/src/tool_command.rs index 23d78af60..7cf703da6 100644 --- a/src/tool_command.rs +++ b/src/tool_command.rs @@ -33,7 +33,7 @@ use tracedecay::daemon::call_default_tool; use tracedecay::daemon::DaemonHandshake; use tracedecay::errors::{Result, TraceDecayError}; use tracedecay::mcp::tools::{ - get_tool_definitions, handle_profile_scoped_lcm_tool_call, ToolDefinition, + get_tool_definitions, handle_profile_scoped_lcm_tool_call, render_tool_cli_help, ToolDefinition, }; /// Old CLI command names that don't match the MCP tool name. Keeps muscle @@ -719,66 +719,7 @@ fn group_for(def: &ToolDefinition) -> &'static str { /// Print one tool's description, usage line, and parameter table. fn print_tool_help(def: &ToolDefinition) { - let short = short_name(&def.name); - println!("tracedecay tool {short}"); - println!(); - println!("{}", def.description); - println!(); - - let props = def - .input_schema - .get("properties") - .and_then(Value::as_object) - .filter(|props| !props.is_empty()); - let required: Vec<&str> = def - .input_schema - .get("required") - .and_then(Value::as_array) - .map(|arr| arr.iter().filter_map(Value::as_str).collect()) - .unwrap_or_default(); - - let Some(props) = props else { - println!("(no parameters)"); - println!(); - println!("Usage: tracedecay tool {short} [--json] [--project ]"); - return; - }; - - let usage_params: String = required - .iter() - .map(|req| format!(" --{} ", req.replace('_', "-"))) - .collect(); - println!("Usage: tracedecay tool {short}{usage_params} [--key value]... [--json]"); - println!(); - - println!("Parameters:"); - let mut entries: Vec<(&String, &Value)> = props.iter().collect(); - entries.sort_by_key(|(k, _)| (*k).clone()); - for (key, schema) in entries { - let ty = schema - .get("type") - .and_then(Value::as_str) - .unwrap_or("string"); - let req = if required.contains(&key.as_str()) { - "required" - } else { - "optional" - }; - let desc = schema - .get("description") - .and_then(Value::as_str) - .unwrap_or(""); - println!( - " --{:<26} {:<8} {:<8} {}", - key.replace('_', "-"), - ty, - req, - desc - ); - } - println!(); - println!("Reserved flags: --json, --project , --args , -h/--help"); - println!("Any value starting with @ is read from that file (multi-line payloads)."); + print!("{}", render_tool_cli_help(def)); } #[cfg(test)] diff --git a/tests/agent_suite/tool_skill_coverage_test.rs b/tests/agent_suite/tool_skill_coverage_test.rs index 0881e1d48..f3d96a4cd 100644 --- a/tests/agent_suite/tool_skill_coverage_test.rs +++ b/tests/agent_suite/tool_skill_coverage_test.rs @@ -7,7 +7,7 @@ use std::process::Command; use crate::common::tracedecay_command_with_home; use tempfile::TempDir; -use tracedecay::mcp::tools::get_tool_definitions; +use tracedecay::mcp::tools::{get_tool_definitions, render_tool_cli_help}; /// MCP tools intentionally exempt from bundled-skill coverage. /// Keep empty unless a tool is truly internal. @@ -47,21 +47,10 @@ fn every_mcp_tool_is_listed_by_the_cli_discovery_command() { } #[test] -fn every_mcp_tool_is_invocable_via_the_cli() { - let home = TempDir::new().expect("create isolated TraceDecay home"); +fn every_mcp_tool_renders_its_own_cli_help() { for def in get_tool_definitions() { let short = short_name(&def.name); - let output = isolated_tracedecay_command(&home) - .args(["tool", short, "--help"]) - .output() - .unwrap_or_else(|e| panic!("run `tracedecay tool {short} --help`: {e}")); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - output.status.success(), - "`tracedecay tool {short} --help` must succeed so the tool stays \ - invocable without an MCP client:\n{}", - String::from_utf8_lossy(&output.stderr) - ); + let stdout = render_tool_cli_help(&def); assert!( stdout.contains(&format!("tracedecay tool {short}")), "`tracedecay tool {short} --help` should print the tool's own help, got:\n{stdout}" @@ -69,6 +58,35 @@ fn every_mcp_tool_is_invocable_via_the_cli() { } } +/// One real `tracedecay tool --help` invocation, asserting the binary +/// prints exactly what `render_tool_cli_help` renders. Tool-name resolution +/// and help dispatch are shared across tools, so a single spawn keeps the CLI +/// wiring covered end-to-end without paying one process per tool. +#[test] +fn tool_cli_help_matches_rendered_help_end_to_end() { + let home = TempDir::new().expect("create isolated TraceDecay home"); + let def = get_tool_definitions() + .into_iter() + .next() + .expect("at least one MCP tool definition"); + let short = short_name(&def.name); + let output = isolated_tracedecay_command(&home) + .args(["tool", short, "--help"]) + .output() + .unwrap_or_else(|e| panic!("run `tracedecay tool {short} --help`: {e}")); + assert!( + output.status.success(), + "`tracedecay tool {short} --help` must succeed so tools stay invocable \ + without an MCP client:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + render_tool_cli_help(&def), + "CLI help output should be exactly the rendered help" + ); +} + /// True when `haystack` mentions `tool_name` as a standalone identifier /// (not as a prefix of a longer tool name such as `tracedecay_lcm_expand` /// inside `tracedecay_lcm_expand_query`).