use anyhow::{anyhow, Context as _, Result};
use serde::Deserialize;
use serde_json::{json, Map, Value};
use std::path::PathBuf;
use super::mcp::InvalidParams;
const TOOL_GROUPS: [&str; 4] = ["Read", "Reshape", "Export", "System"];
pub(crate) const SHOW_DEFAULT_LAST: u64 = 50;
pub(crate) const SHOW_DEFAULT_MAX_BYTES: u64 = 200_000;
const WINDOW_SELECTORS: [&str; 6] = ["first", "last", "range", "around", "max_bytes", "pre_compaction"];
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct ArgSpec {
pub name: String,
pub kind: ArgKind,
#[serde(default)]
pub value_type: String,
#[serde(default)]
pub possible_values: Vec<String>,
#[serde(default)]
pub default: Option<String>,
#[serde(default)]
pub help: String,
#[serde(default)]
pub required: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum ArgKind {
Flag,
Option,
Positional,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct CommandSpec {
pub name: String,
#[serde(default)]
pub group: String,
#[serde(default)]
pub about: String,
#[serde(default)]
pub args: Vec<ArgSpec>,
}
impl ArgSpec {
pub fn long_flag(&self) -> String {
format!("--{}", self.name.replace('_', "-"))
}
}
impl CommandSpec {
pub fn tool_name(&self) -> String {
self.name.replace(' ', "_")
}
fn arg(&self, name: &str) -> Option<&ArgSpec> {
self.args.iter().find(|a| a.name == name)
}
fn has_json(&self) -> bool {
self.arg("json").is_some_and(|a| a.kind == ArgKind::Flag)
}
}
pub(crate) struct CliTools {
bin: PathBuf,
commands: Vec<CommandSpec>,
}
impl CliTools {
pub fn from_tree(cli: &clap::Command) -> Result<CliTools> {
let all: Vec<CommandSpec> =
serde_json::from_value(super::schema::commands_json(cli)).context("reading the clap tree")?;
let commands = select_tool_commands(all);
if commands.is_empty() {
anyhow::bail!("the clap tree has no Read/Reshape/Export/System commands");
}
let bin = std::env::current_exe().context("locating this executable")?;
Ok(CliTools { bin, commands })
}
pub fn commands(&self) -> &[CommandSpec] {
&self.commands
}
pub fn find(&self, tool: &str) -> Option<&CommandSpec> {
self.commands.iter().find(|c| c.tool_name() == tool)
}
pub fn tool_list(&self) -> Vec<Value> {
self.commands.iter().map(tool_descriptor).collect()
}
pub fn call(&self, tool: &str, args: &Value) -> Result<String> {
let cmd = self
.find(tool)
.ok_or_else(|| anyhow!("unknown generated tool: {tool}"))?;
let argv = build_argv(cmd, args)?;
let out = std::process::Command::new(&self.bin)
.args(&argv)
.stdin(std::process::Stdio::null())
.output()
.with_context(|| format!("running {} {}", self.bin.display(), argv.join(" ")))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let detail = if stderr.is_empty() { stdout } else { stderr };
let code = out
.status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".into());
anyhow::bail!("cv {} exited {code}: {detail}", argv.join(" "));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
}
fn select_tool_commands(all: Vec<CommandSpec>) -> Vec<CommandSpec> {
let names: Vec<String> = all.iter().map(|c| c.name.clone()).collect();
all.into_iter()
.filter(|c| TOOL_GROUPS.contains(&c.group.as_str()) || c.name.starts_with("task "))
.filter(|c| {
let has_children = names.iter().any(|n| n.starts_with(&format!("{} ", c.name)));
!(has_children && c.args.is_empty())
})
.collect()
}
pub(crate) fn tool_descriptor(cmd: &CommandSpec) -> Value {
let mut props = Map::new();
let mut required = Vec::new();
for a in &cmd.args {
if a.name == "json" && a.kind == ArgKind::Flag {
continue;
}
props.insert(a.name.clone(), property_schema(cmd, a));
if a.required {
required.push(Value::String(a.name.clone()));
}
}
let mut schema = json!({ "type": "object", "properties": Value::Object(props) });
if !required.is_empty() {
schema["required"] = Value::Array(required);
}
json!({
"name": cmd.tool_name(),
"description": tool_description(cmd),
"inputSchema": schema,
})
}
fn tool_description(cmd: &CommandSpec) -> String {
let about = if cmd.about.trim().is_empty() {
format!("`cv {}`.", cmd.name)
} else {
cmd.about.trim().to_string()
};
let json = if cmd.has_json() { " --json" } else { "" };
let mut out = format!("{about} (runs `cv {}{json}`).", cmd.name);
if cmd.tool_name() == "show" {
out.push_str(&format!(
" Over MCP this windows by default: with none of first/last/range/around/max_bytes \
(nor `kind`) given it reads the LAST {SHOW_DEFAULT_LAST} messages capped at \
{SHOW_DEFAULT_MAX_BYTES} bytes, so a whole transcript is never pulled in by accident. \
Ask for a window explicitly to override."
));
}
out
}
fn property_schema(cmd: &CommandSpec, a: &ArgSpec) -> Value {
let mut p = Map::new();
p.insert("type".into(), json!(json_type(a)));
p.insert("description".into(), json!(description_of(cmd, a)));
if !a.possible_values.is_empty() {
p.insert("enum".into(), json!(a.possible_values));
}
if let Some(d) = &a.default {
p.insert("default".into(), default_value(a, d));
}
Value::Object(p)
}
fn json_type(a: &ArgSpec) -> &'static str {
if a.kind == ArgKind::Flag {
return "boolean";
}
match a.value_type.as_str() {
"integer" => "integer",
"number" => "number",
"boolean" => "boolean",
_ => "string", }
}
fn default_value(a: &ArgSpec, raw: &str) -> Value {
match json_type(a) {
"integer" => raw.parse::<i64>().map(Value::from).unwrap_or_else(|_| json!(raw)),
"number" => raw.parse::<f64>().map(Value::from).unwrap_or_else(|_| json!(raw)),
"boolean" => raw.parse::<bool>().map(Value::from).unwrap_or_else(|_| json!(raw)),
_ => json!(raw),
}
}
fn description_of(cmd: &CommandSpec, a: &ArgSpec) -> String {
let help = a.help.trim();
if !help.is_empty() {
return help.to_string();
}
match a.kind {
ArgKind::Positional => format!("The `<{}>` argument of `cv {}`.", a.name, cmd.name),
_ => format!("The `{}` option of `cv {}`.", a.long_flag(), cmd.name),
}
}
pub(crate) fn build_argv(cmd: &CommandSpec, args: &Value) -> Result<Vec<String>> {
let obj = match args {
Value::Null => Map::new(),
Value::Object(o) => o.clone(),
other => return Err(invalid(format!("arguments must be an object, got {other}"))),
};
for key in obj.keys() {
if key == "json" {
continue; }
if cmd.arg(key).is_none() {
return Err(invalid(format!(
"unknown argument `{key}` for `{}` (see the tool's inputSchema)",
cmd.tool_name()
)));
}
}
if let Some(a) = cmd.args.iter().find(|a| a.required && present(&obj, &a.name).is_none()) {
return Err(invalid(format!(
"missing required argument `{}` for `{}`",
a.name,
cmd.tool_name()
)));
}
let mut argv: Vec<String> = cmd.name.split(' ').map(str::to_string).collect();
for a in cmd.args.iter().filter(|a| a.kind == ArgKind::Positional) {
match present(&obj, &a.name) {
Some(Value::Array(items)) => {
for v in items {
argv.push(scalar(&a.name, v)?);
}
}
Some(v) => argv.push(scalar(&a.name, v)?),
None => break,
}
}
for a in cmd.args.iter().filter(|a| a.kind == ArgKind::Option) {
let Some(v) = present(&obj, &a.name) else { continue };
let values = match v {
Value::Array(items) => items.iter().collect(),
v => vec![v],
};
for v in values {
argv.push(a.long_flag());
argv.push(scalar(&a.name, v)?);
}
}
for a in cmd.args.iter().filter(|a| a.kind == ArgKind::Flag) {
if a.name == "json" {
continue;
}
match present(&obj, &a.name) {
Some(Value::Bool(true)) => argv.push(a.long_flag()),
Some(Value::Bool(false)) | None => {}
Some(other) => {
return Err(invalid(format!(
"`{}` is a flag: pass true or false, got {other}",
a.name
)))
}
}
}
apply_mcp_defaults(cmd, &obj, &mut argv);
if cmd.has_json() {
argv.push("--json".into());
}
Ok(argv)
}
fn apply_mcp_defaults(cmd: &CommandSpec, obj: &Map<String, Value>, argv: &mut Vec<String>) {
if cmd.tool_name() != "show" {
return;
}
let windowed = WINDOW_SELECTORS
.iter()
.any(|k| cmd.arg(k).is_some() && present(obj, k).is_some());
if windowed || present(obj, "kind").is_some() {
return;
}
if cmd.arg("last").is_some() {
argv.push("--last".into());
argv.push(SHOW_DEFAULT_LAST.to_string());
}
if cmd.arg("max_bytes").is_some() {
argv.push("--max-bytes".into());
argv.push(SHOW_DEFAULT_MAX_BYTES.to_string());
}
}
fn present<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a Value> {
match obj.get(key) {
None | Some(Value::Null) => None,
Some(v) => Some(v),
}
}
fn scalar(key: &str, v: &Value) -> Result<String> {
Ok(match v {
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => match n.as_i64() {
Some(i) => i.to_string(),
None => match n.as_f64() {
Some(f) if f.fract() == 0.0 => format!("{}", f as i64),
_ => n.to_string(),
},
},
other => return Err(invalid(format!("`{key}` must be a scalar, got {other}"))),
})
}
fn invalid(msg: String) -> anyhow::Error {
InvalidParams(msg).into()
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(json_src: &str) -> CommandSpec {
serde_json::from_str(json_src).expect("command spec")
}
fn show() -> CommandSpec {
spec(
r#"{
"name": "show", "group": "Read", "about": "Print a single session",
"args": [
{"name":"id","kind":"positional","value_type":"string","help":"Session id","required":true},
{"name":"harness","kind":"option","value_type":"string","help":"","required":false},
{"name":"json","kind":"flag","value_type":"boolean","help":"Emit IR JSON","required":false},
{"name":"first","kind":"option","value_type":"integer","help":"The first N messages","required":false},
{"name":"last","kind":"option","value_type":"integer","help":"The last N messages","required":false},
{"name":"range","kind":"option","value_type":"string","help":"A..B","required":false},
{"name":"around","kind":"option","value_type":"integer","help":"Message N","required":false},
{"name":"context","kind":"option","value_type":"integer","help":"K either side","required":false,"default":"5"},
{"name":"max_bytes","kind":"option","value_type":"integer","help":"Stop after N bytes","required":false},
{"name":"subagents","kind":"flag","value_type":"boolean","help":"List sub-agents","required":false},
{"name":"pre_compaction","kind":"option","value_type":"integer","help":"Pre-compaction span","required":false},
{"name":"kind","kind":"option","value_type":"string","help":"Only these kinds","required":false}
]}"#,
)
}
fn cat() -> CommandSpec {
spec(
r#"{
"name": "cat", "group": "Read", "about": "Print one tool call's full output",
"args": [
{"name":"session","kind":"positional","value_type":"string","help":"Session","required":true},
{"name":"tool_use_id","kind":"positional","value_type":"string","help":"Tool use id","required":true},
{"name":"input","kind":"flag","value_type":"boolean","help":"Print the input","required":false}
]}"#,
)
}
#[test]
fn positionals_and_options_become_typed_properties() {
let d = tool_descriptor(&show());
assert_eq!(d["name"], "show");
let props = &d["inputSchema"]["properties"];
assert_eq!(d["inputSchema"]["type"], "object");
assert_eq!(props["id"]["type"], "string");
assert_eq!(d["inputSchema"]["required"], json!(["id"]));
assert_eq!(props["last"]["type"], "integer");
assert_eq!(props["last"]["description"], "The last N messages");
assert_eq!(props["subagents"]["type"], "boolean");
assert_eq!(props["context"]["default"], json!(5));
assert!(props.get("json").is_none(), "json must not be a property: {props}");
}
#[test]
fn empty_clap_help_still_yields_a_description() {
let d = tool_descriptor(&show());
let desc = d["inputSchema"]["properties"]["harness"]["description"]
.as_str()
.unwrap();
assert!(desc.contains("--harness"), "{desc}");
assert!(!desc.is_empty());
}
#[test]
fn possible_values_become_an_enum_and_paths_become_strings() {
let c = spec(
r#"{"name":"ls","group":"Read","about":"List","args":[
{"name":"sort_by","kind":"option","value_type":"string","help":"Sort key","required":false,
"possible_values":["updated","created","messages"],"default":"updated"},
{"name":"out","kind":"option","value_type":"path","help":"Where to write","required":false}]}"#,
);
let props = tool_descriptor(&c)["inputSchema"]["properties"].clone();
assert_eq!(props["sort_by"]["enum"], json!(["updated", "created", "messages"]));
assert_eq!(props["sort_by"]["default"], json!("updated"));
assert_eq!(props["out"]["type"], "string");
}
#[test]
fn subcommand_names_join_with_underscores_and_dispatchers_are_dropped() {
let all: Vec<CommandSpec> = serde_json::from_str(
r#"[{"name":"formats","group":"System","about":"Formats","args":[]},
{"name":"formats census","group":"System","about":"Census","args":[
{"name":"harness","kind":"option","value_type":"string","help":"h","required":false}]},
{"name":"task","group":"Fleet & live","about":"Tasks","args":[]}]"#,
)
.unwrap();
let kept = select_tool_commands(all);
let names: Vec<String> = kept.iter().map(|c| c.tool_name()).collect();
assert_eq!(
names,
vec!["formats_census"],
"dispatchers and the rest of Fleet & live are dropped"
);
let all: Vec<CommandSpec> = serde_json::from_str(
r#"[{"name":"task","group":"Fleet & live","about":"Tasks","args":[]},
{"name":"task claim","group":"Fleet & live","about":"Claim","args":[
{"name":"id","kind":"positional","value_type":"string","help":"Task","required":true}]},
{"name":"serve","group":"Fleet & live","about":"Serve","args":[
{"name":"port","kind":"option","value_type":"integer","help":"p","required":false}]}]"#,
)
.unwrap();
let names: Vec<String> = select_tool_commands(all).iter().map(|c| c.tool_name()).collect();
assert_eq!(names, vec!["task_claim"], "task subcommands are tools; a server is not");
}
#[test]
fn show_without_a_selector_gets_the_mcp_window_defaults() {
let argv = build_argv(&show(), &json!({"id": "abc"})).unwrap();
assert_eq!(
argv,
vec!["show", "abc", "--last", "50", "--max-bytes", "200000", "--json"]
);
}
#[test]
fn an_explicit_selector_suppresses_the_defaults() {
for sel in [
json!({"id": "abc", "first": 10}),
json!({"id": "abc", "range": "10..20"}),
json!({"id": "abc", "around": 7, "context": 2}),
json!({"id": "abc", "max_bytes": 4096}),
json!({"id": "abc", "pre_compaction": 1}),
] {
let argv = build_argv(&show(), &sel).unwrap();
assert!(!argv.contains(&"--last".to_string()), "{sel} → {argv:?}");
}
let argv = build_argv(&show(), &json!({"id": "abc", "last": 3})).unwrap();
assert_eq!(argv, vec!["show", "abc", "--last", "3", "--json"]);
}
#[test]
fn a_kind_view_gets_no_window_default() {
let argv = build_argv(&show(), &json!({"id": "abc", "kind": "prompt,answer"})).unwrap();
assert_eq!(argv, vec!["show", "abc", "--kind", "prompt,answer", "--json"]);
}
#[test]
fn a_boolean_flag_is_present_only_when_true() {
let on = build_argv(&show(), &json!({"id": "a", "last": 1, "subagents": true})).unwrap();
assert!(on.contains(&"--subagents".to_string()), "{on:?}");
for off in [
json!({"id": "a", "last": 1, "subagents": false}),
json!({"id": "a", "last": 1}),
] {
let argv = build_argv(&show(), &off).unwrap();
assert!(!argv.contains(&"--subagents".to_string()), "{argv:?}");
}
}
#[test]
fn positionals_lead_the_argv_in_declaration_order() {
let argv = build_argv(
&cat(),
&json!({"tool_use_id": "toolu_9", "session": "abc", "input": true}),
)
.unwrap();
assert_eq!(argv, vec!["cat", "abc", "toolu_9", "--input"]);
}
#[test]
fn a_list_repeats_an_option_and_fills_a_positional() {
let c = spec(
r#"{"name":"task open","group":"Fleet & live","about":"Open","args":[
{"name":"title","kind":"positional","value_type":"string","help":"t","required":true},
{"name":"blocked_by","kind":"option","value_type":"string","help":"b","required":false}]}"#,
);
let argv = build_argv(&c, &json!({"title": "x", "blocked_by": ["a", "b"]})).unwrap();
assert_eq!(
argv,
vec!["task", "open", "x", "--blocked-by", "a", "--blocked-by", "b"]
);
let argv = build_argv(&cat(), &json!({"session": ["s"], "tool_use_id": "t"})).unwrap();
assert_eq!(argv, vec!["cat", "s", "t"]);
}
#[test]
fn option_ids_become_kebab_case_long_flags() {
let argv = build_argv(&show(), &json!({"id": "a", "max_bytes": 1024})).unwrap();
assert_eq!(argv, vec!["show", "a", "--max-bytes", "1024", "--json"]);
}
#[test]
fn integral_floats_are_accepted_because_clients_serialise_them() {
let argv = build_argv(&show(), &json!({"id": "a", "last": 20.0})).unwrap();
assert_eq!(argv, vec!["show", "a", "--last", "20", "--json"]);
}
#[test]
fn a_missing_required_positional_is_an_invalid_param() {
let e = build_argv(&cat(), &json!({"session": "abc"})).unwrap_err();
assert!(e.downcast_ref::<InvalidParams>().is_some(), "{e}");
assert!(format!("{e}").contains("tool_use_id"), "{e}");
}
#[test]
fn an_unknown_argument_is_rejected_rather_than_silently_dropped() {
let e = build_argv(&show(), &json!({"id": "a", "limit": 5})).unwrap_err();
assert!(e.downcast_ref::<InvalidParams>().is_some(), "{e}");
assert!(format!("{e}").contains("limit"), "{e}");
assert!(build_argv(&show(), &json!({"id": "a", "last": 1, "json": true})).is_ok());
}
#[test]
fn a_flag_given_a_non_boolean_is_an_invalid_param() {
let e = build_argv(&show(), &json!({"id": "a", "last": 1, "subagents": "yes"})).unwrap_err();
assert!(e.downcast_ref::<InvalidParams>().is_some(), "{e}");
}
}