Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions src/mcp/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -77,6 +78,92 @@ impl ToolResult {
}
}

/// Render the CLI help shown by `tracedecay tool <name> --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 <path>]"
);
return out;
};

let mut usage_params = String::new();
for req in &required {
let _ = write!(usage_params, " --{} <value>", 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 <path>, --args <json|@file>, -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::*;
Expand Down
63 changes: 2 additions & 61 deletions src/tool_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <path>]");
return;
};

let usage_params: String = required
.iter()
.map(|req| format!(" --{} <value>", 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 <path>, --args <json|@file>, -h/--help");
println!("Any value starting with @ is read from that file (multi-line payloads).");
print!("{}", render_tool_cli_help(def));
}

#[cfg(test)]
Expand Down
46 changes: 32 additions & 14 deletions tests/agent_suite/tool_skill_coverage_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -47,28 +47,46 @@ 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}"
);
}
}

/// One real `tracedecay tool <name> --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`).
Expand Down
Loading