use clap::{Arg, ArgAction, Command};
use kaish_types::{ParamSchema, ToolSchema, Value};
pub fn schema_from_clap(
cmd: &Command,
name: &str,
description: &str,
examples: impl IntoIterator<Item = (&'static str, &'static str)>,
) -> ToolSchema {
let mut schema = ToolSchema::new(name, description);
for param in params_from_clap(cmd) {
schema = schema.param(param);
}
for (desc, code) in examples {
schema = schema.example(desc, code);
}
schema
}
pub fn schema_tree_from_clap(
cmd: &Command,
name: &str,
description: &str,
examples: impl IntoIterator<Item = (&'static str, &'static str)>,
) -> ToolSchema {
let mut schema = ToolSchema::new(name, description);
for param in params_from_clap(cmd) {
schema = schema.param(param);
}
for (desc, code) in examples {
schema = schema.example(desc, code);
}
for sub in cmd.get_subcommands() {
schema = schema.subcommand(child_schema_from_clap(sub));
}
schema
}
fn child_schema_from_clap(cmd: &Command) -> ToolSchema {
let name = cmd.get_name().to_string();
let description = cmd
.get_about()
.map(|s| s.to_string())
.unwrap_or_default();
let mut schema = ToolSchema::new(name, description);
for param in params_from_clap(cmd) {
schema = schema.param(param);
}
let aliases: Vec<String> = cmd.get_all_aliases().map(|s| s.to_string()).collect();
if !aliases.is_empty() {
schema = schema.with_command_aliases(aliases);
}
for sub in cmd.get_subcommands() {
schema = schema.subcommand(child_schema_from_clap(sub));
}
schema
}
pub fn params_from_clap(cmd: &Command) -> Vec<ParamSchema> {
cmd.get_arguments()
.filter(|arg| !is_skipped(arg))
.map(arg_to_param)
.collect()
}
fn is_skipped(arg: &Arg) -> bool {
let id = arg.get_id().as_str();
if matches!(id, "help" | "version" | "json") {
return true;
}
arg.is_hide_set() && !arg.is_positional()
}
fn arg_to_param(arg: &Arg) -> ParamSchema {
let id = arg.get_id().as_str();
let name = arg.get_long().unwrap_or(id).to_string();
let action = arg.get_action();
let is_bool = matches!(action, ArgAction::SetTrue | ArgAction::SetFalse);
let param_type = if is_bool {
"bool"
} else if matches!(action, ArgAction::Count) {
"int"
} else {
"string"
};
let description = arg
.get_help()
.map(|s| s.to_string())
.or_else(|| arg.get_long_help().map(|s| s.to_string()))
.unwrap_or_default();
let required = arg.is_required_set();
let mut aliases: Vec<String> = Vec::new();
if let Some(short) = arg.get_short() {
aliases.push(short.to_string());
}
if id != name {
aliases.push(id.to_string());
}
if let Some(visible) = arg.get_visible_aliases() {
for alias in visible {
aliases.push(alias.to_string());
}
}
let consumes = match arg.get_num_args() {
Some(range) => {
let lo = range.min_values();
if lo == 0 { 1 } else { lo }
}
None => 1,
};
let default = if is_bool {
Some(Value::Bool(false))
} else {
None
};
let positional = arg.is_positional();
ParamSchema::new(name, param_type.to_string())
.with_required(required)
.with_default(default)
.with_description(description)
.with_aliases(aliases)
.consumes(consumes)
.with_positional(positional)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
#[derive(Parser, Debug)]
#[command(name = "demo", about = "demo tool")]
struct DemoArgs {
#[arg(short = 'n', long = "number")]
number: bool,
#[arg(short = 'l', long = "lines", default_value_t = 10)]
lines: i64,
#[arg(hide = true)]
paths: Vec<String>,
}
#[derive(Parser, Debug)]
#[command(name = "demo-internal", about = "demo with internal flag")]
struct DemoInternalArgs {
#[arg(hide = true, long = "internal-only")]
internal: bool,
paths: Vec<String>,
}
#[test]
fn bool_flag_becomes_bool_param() {
let cmd = DemoArgs::command();
let params = params_from_clap(&cmd);
let p = params.iter().find(|p| p.name == "number").expect("number param");
assert_eq!(p.param_type, "bool");
assert!(!p.required);
assert_eq!(p.aliases, vec!["n".to_string()]);
assert!(p.description.contains("Number output lines"));
}
#[test]
fn value_flag_reports_short_alias_and_string_type() {
let cmd = DemoArgs::command();
let params = params_from_clap(&cmd);
let p = params.iter().find(|p| p.name == "lines").expect("lines param");
assert_eq!(p.param_type, "string");
assert!(!p.required);
assert_eq!(p.aliases, vec!["l".to_string()]);
}
#[test]
fn hidden_positional_is_kept_and_marked_positional() {
let cmd = DemoArgs::command();
let params = params_from_clap(&cmd);
let p = params.iter().find(|p| p.name == "paths").expect("paths param");
assert!(p.positional, "hidden positional sink should be exposed as positional");
assert_eq!(p.param_type, "string");
assert!(p.description.contains("Files to read"));
}
#[test]
fn hidden_flag_is_dropped() {
let cmd = DemoInternalArgs::command();
let params = params_from_clap(&cmd);
assert!(
params.iter().all(|p| p.name != "internal"),
"hidden non-positional flag should be skipped: {:?}",
params.iter().map(|p| &p.name).collect::<Vec<_>>()
);
assert!(params.iter().any(|p| p.name == "paths" && p.positional));
}
#[derive(Parser, Debug)]
#[command(name = "demo-id-override")]
struct DemoIdOverrideArgs {
#[arg(short = 'b', long = "bare")]
_bare: Option<String>,
#[arg(id = "clean", short = 'c', long = "clean")]
_clean: Option<String>,
}
#[test]
fn id_override_strips_leading_underscore_from_schema_name() {
let cmd = DemoIdOverrideArgs::command();
let params = params_from_clap(&cmd);
let bare = params.iter().find(|p| p.name == "bare")
.expect("name should be the long flag `bare`, not the field id `_bare`");
assert_eq!(bare.aliases, vec!["b".to_string(), "_bare".to_string()]);
assert!(
!params.iter().any(|p| p.name == "_bare"),
"the snake field id must not be the canonical name"
);
let clean = params.iter().find(|p| p.name == "clean")
.expect("name should be the long flag `clean`");
assert_eq!(clean.aliases, vec!["c".to_string()]);
}
#[test]
fn flag_params_are_not_marked_positional() {
let cmd = DemoArgs::command();
let params = params_from_clap(&cmd);
let p = params.iter().find(|p| p.name == "number").unwrap();
assert!(!p.positional);
let p = params.iter().find(|p| p.name == "lines").unwrap();
assert!(!p.positional);
}
#[test]
fn help_version_json_filtered() {
let cmd = DemoArgs::command();
let params = params_from_clap(&cmd);
assert!(params.iter().all(|p| !matches!(p.name.as_str(), "help" | "version" | "json")));
}
#[test]
fn schema_tree_reflects_subcommands_and_aliases() {
let list = Command::new("list").about("list contexts").visible_alias("ls");
let context = Command::new("context")
.about("context ops")
.visible_alias("ctx")
.arg(Arg::new("type").long("type").short('t').action(ArgAction::Set))
.subcommand(list);
let kj = Command::new("kj").about("kaijutsu").subcommand(context);
let schema = schema_tree_from_clap(&kj, "kj", "kaijutsu", []);
assert_eq!(schema.subcommands.len(), 1, "kj should have one child");
let context = &schema.subcommands[0];
assert!(context.matches_command("context"));
assert!(context.matches_command("ctx"), "command alias should route");
let type_param = context.params.iter().find(|p| p.name == "type").expect("type on context");
assert_eq!(type_param.param_type, "string");
assert_eq!(type_param.aliases, vec!["t".to_string()]);
assert!(schema.params.iter().all(|p| p.name != "type"), "leaf flag must not leak to root");
assert_eq!(context.subcommands.len(), 1);
let list = &context.subcommands[0];
assert!(list.matches_command("list"));
assert!(list.matches_command("ls"));
}
#[test]
fn schema_tree_of_flat_command_has_no_subcommands() {
let cmd = DemoArgs::command();
let schema = schema_tree_from_clap(&cmd, "demo", "demo tool", []);
assert!(schema.subcommands.is_empty());
assert!(schema.aliases.is_empty());
assert_eq!(schema.params.len(), params_from_clap(&cmd).len());
}
}