use async_trait::async_trait;
use clap::{CommandFactory, Parser};
use crate::interpreter::{ExecResult, OutputData, OutputNode};
use crate::tools::{schema_from_clap, ExecContext, ToolCtx, GlobalFlags, Tool, ToolArgs, ToolSchema};
pub struct Tools;
#[derive(Parser, Debug)]
#[command(name = "kaish-tools", about = "List available tools and their schemas")]
struct ToolsArgs {
#[command(flatten)]
global: GlobalFlags,
tool: Vec<String>,
}
#[async_trait]
impl Tool for Tools {
fn name(&self) -> &str {
"kaish-tools"
}
fn schema(&self) -> ToolSchema {
schema_from_clap(
&ToolsArgs::command(),
"kaish-tools",
"List available tools and their schemas",
[
("List all tools", "kaish-tools"),
("Show tool detail", "kaish-tools cat"),
],
)
}
async fn execute(&self, args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult {
let Some(ctx) = ctx.as_any_mut().downcast_mut::<ExecContext>() else {
return ExecResult::failure(1, "internal error: kernel builtin requires ExecContext");
};
let argv = match args.to_argv() {
Ok(v) => v,
Err(e) => return ExecResult::failure(2, format!("kaish-tools: {e}")),
};
let parsed = match ToolsArgs::try_parse_from(
std::iter::once("kaish-tools".to_string()).chain(argv),
) {
Ok(p) => p,
Err(e) => return ExecResult::failure(2, format!("kaish-tools: {e}")),
};
parsed.global.apply(ctx);
let tool_name = args.get_string("name", 0);
if let Some(name) = tool_name {
format_tool_detail(&ctx.tool_schemas, &name)
} else {
format_tool_list(&ctx.tool_schemas)
}
}
}
fn format_tool_list(schemas: &[ToolSchema]) -> ExecResult {
let headers = vec![
"NAME".to_string(),
"DESCRIPTION".to_string(),
"PARAMS".to_string(),
"OPERATIONS".to_string(),
];
let nodes: Vec<OutputNode> = schemas
.iter()
.map(|s| {
let param_count = s.params.len().to_string();
OutputNode::new(&s.name)
.with_cells(vec![s.description.clone(), param_count, s.operations.join(",")])
})
.collect();
ExecResult::with_output(OutputData::table(headers, nodes))
}
fn format_tool_detail(schemas: &[ToolSchema], name: &str) -> ExecResult {
let schema = schemas.iter().find(|s| s.name == name);
match schema {
Some(s) => {
let mut output = format!("{}\n{}\n", s.name, s.description);
output.push_str(&kaish_help::topic::command_aliases_line(&s.aliases));
output.push('\n');
if !s.params.is_empty() {
output.push_str("Parameters:\n");
output.push_str(&kaish_help::topic::param_lines(&s.params, " "));
}
if !s.subcommands.is_empty() {
output.push_str("Subcommands:\n");
output.push_str(&kaish_help::topic::subcommand_roster(&s.subcommands));
}
if !s.examples.is_empty() {
output.push_str("Examples:\n");
output.push_str(&kaish_help::topic::examples_section(&s.examples));
}
if !s.operations.is_empty() {
output.push_str(&kaish_help::topic::operations_line(&s.operations));
}
ExecResult::with_output(OutputData::text(output))
}
None => ExecResult::failure(1, format!("tool not found: {}", name)),
}
}
pub struct Mounts;
#[derive(Parser, Debug)]
#[command(name = "kaish-mounts", about = "List VFS mount points")]
struct MountsArgs {
#[command(flatten)]
global: GlobalFlags,
}
fn format_resident(bytes: Option<u64>) -> String {
match bytes {
None => "-".to_string(),
Some(b) => {
const UNITS: &[&str] = &["", "K", "M", "G", "T"];
let mut size = b as f64;
let mut idx = 0;
while size >= 1024.0 && idx < UNITS.len() - 1 {
size /= 1024.0;
idx += 1;
}
if idx == 0 {
b.to_string()
} else if size >= 10.0 {
format!("{:.0}{}", size, UNITS[idx])
} else {
format!("{:.1}{}", size, UNITS[idx])
}
}
}
}
#[async_trait]
impl Tool for Mounts {
fn name(&self) -> &str {
"kaish-mounts"
}
fn schema(&self) -> ToolSchema {
schema_from_clap(
&MountsArgs::command(),
"kaish-mounts",
"List VFS mount points",
[("Show mount points", "kaish-mounts")],
)
}
async fn execute(&self, args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult {
let Some(ctx) = ctx.as_any_mut().downcast_mut::<ExecContext>() else {
return ExecResult::failure(1, "internal error: kernel builtin requires ExecContext");
};
let argv = match args.to_argv() {
Ok(v) => v,
Err(e) => return ExecResult::failure(2, format!("kaish-mounts: {e}")),
};
let parsed = match MountsArgs::try_parse_from(
std::iter::once("kaish-mounts".to_string()).chain(argv),
) {
Ok(p) => p,
Err(e) => return ExecResult::failure(2, format!("kaish-mounts: {e}")),
};
parsed.global.apply(ctx);
let mounts = ctx.backend.mounts();
let budget = ctx.vfs_budget.as_ref();
let headers = vec![
"PATH".to_string(),
"MODE".to_string(),
"RESIDENT".to_string(),
];
let nodes: Vec<OutputNode> = mounts
.iter()
.map(|m| {
let mode = if m.read_only { "ro" } else { "rw" };
OutputNode::new(m.path.to_string_lossy())
.with_cells(vec![mode.to_string(), format_resident(m.resident_bytes)])
})
.collect();
let budget_summary = budget.map(|b| {
format!(
"\nvfs-memory budget: {} used / {} limit / {} remaining",
format_resident(Some(b.used())),
format_resident(Some(b.limit())),
format_resident(Some(b.remaining())),
)
});
let rich = {
let mount_array: Vec<serde_json::Value> = mounts
.iter()
.map(|m| {
let mut obj = serde_json::Map::new();
obj.insert("path".to_string(), serde_json::Value::String(m.path.to_string_lossy().into_owned()));
obj.insert("read_only".to_string(), serde_json::Value::Bool(m.read_only));
match m.resident_bytes {
Some(n) => obj.insert("resident_bytes".to_string(), serde_json::Value::Number(n.into())),
None => obj.insert("resident_bytes".to_string(), serde_json::Value::Null),
};
serde_json::Value::Object(obj)
})
.collect();
let mut top = serde_json::Map::new();
top.insert("mounts".to_string(), serde_json::Value::Array(mount_array));
if let Some(b) = budget {
let mut bobj = serde_json::Map::new();
bobj.insert("label".to_string(), serde_json::Value::String(b.label().to_string()));
bobj.insert("used".to_string(), serde_json::Value::Number(b.used().into()));
bobj.insert("limit".to_string(), serde_json::Value::Number(b.limit().into()));
bobj.insert("remaining".to_string(), serde_json::Value::Number(b.remaining().into()));
top.insert("budget".to_string(), serde_json::Value::Object(bobj));
}
serde_json::Value::Object(top)
};
let output = OutputData::table(headers, nodes).with_rich_json(rich);
if let Some(summary) = budget_summary {
let text = format!("{}{}", output.to_canonical_string(), summary);
ExecResult::with_output_and_text(output, text)
} else {
ExecResult::with_output(output)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::Value;
use crate::interpreter::{apply_output_format, OutputFormat};
use crate::tools::{ParamSchema, ToolSchema as TS};
use crate::vfs::{MemoryFs, VfsRouter};
use std::sync::Arc;
fn make_ctx() -> ExecContext {
let mut vfs = VfsRouter::new();
vfs.mount("/", MemoryFs::new());
vfs.mount("/tmp", MemoryFs::new());
let mut ctx = ExecContext::new(Arc::new(vfs));
ctx.set_tool_schemas(vec![
TS::new("echo", "Print arguments"),
TS::new("cat", "Concatenate files"),
]);
ctx
}
#[tokio::test]
async fn test_tools_list_plain() {
let mut ctx = make_ctx();
let args = ToolArgs::new();
let result = Tools.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("echo"));
assert!(result.text_out().contains("cat"));
let output = result.output().expect("should have OutputData");
assert!(output.headers.is_some());
}
#[tokio::test]
async fn test_tools_list_json_via_global_flag() {
let mut ctx = make_ctx();
let args = ToolArgs::new();
let result = Tools.execute(args, &mut ctx).await;
assert!(result.ok());
let result = apply_output_format(result, OutputFormat::Json);
let data: Vec<serde_json::Value> = serde_json::from_str(&result.text_out()).expect("valid JSON");
assert_eq!(data.len(), 2);
assert!(data[0].get("NAME").is_some());
}
#[tokio::test]
async fn test_tools_detail() {
let mut ctx = make_ctx();
let mut args = ToolArgs::new();
args.positional.push(Value::String("echo".into()));
let result = Tools.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("echo"));
assert!(result.text_out().contains("Print arguments"));
}
#[tokio::test]
async fn test_tools_detail_not_found() {
let mut ctx = make_ctx();
let mut args = ToolArgs::new();
args.positional.push(Value::String("nonexistent".into()));
let result = Tools.execute(args, &mut ctx).await;
assert!(!result.ok());
assert!(result.err.contains("tool not found"));
}
#[tokio::test]
async fn test_tools_detail_recurses_into_nested_subcommands() {
let leaf = TS::new("list", "List the repository's working trees").param(
ParamSchema::optional("porcelain", "bool", Value::Bool(false), "Machine-readable output"),
);
let node = TS::new("worktree", "Work with the repository's working trees").subcommand(leaf);
let git = TS::new("git", "Git plumbing and porcelain").subcommand(node);
let mut ctx = make_ctx();
ctx.set_tool_schemas(vec![git]);
let mut args = ToolArgs::new();
args.positional.push(Value::String("git".into()));
let result = Tools.execute(args, &mut ctx).await;
assert!(result.ok());
let text = result.text_out();
assert!(
text.contains("worktree list — List the repository's working trees"),
"expected full-path leaf line, got:\n{text}"
);
assert!(text.contains("porcelain"), "expected leaf parameter to render, got:\n{text}");
assert!(
text.contains("Machine-readable output"),
"expected leaf parameter description to render, got:\n{text}"
);
let roster_start = text.find("Subcommands:\n").expect("Subcommands section") + "Subcommands:\n".len();
for line in text[roster_start..].lines() {
if line.is_empty() || line.starts_with(" ") || line.starts_with("Operations:") {
continue; }
assert!(
line.starts_with(" ") && !line.starts_with(" "),
"roster line must start with exactly two spaces: {line:?}"
);
assert!(line.contains(" — "), "roster line must use the ' — ' separator: {line:?}");
}
}
#[tokio::test]
async fn test_tools_detail_recurses_three_levels() {
let leaf = TS::new("list", "List sessions in this context").param(ParamSchema::optional(
"active",
"bool",
Value::Bool(false),
"Only running sessions",
));
let session = TS::new("session", "Session operations").subcommand(leaf);
let context = TS::new("context", "Context operations").subcommand(session);
let kj = TS::new("kj", "kaijutsu control").subcommand(context);
let mut ctx = make_ctx();
ctx.set_tool_schemas(vec![kj]);
let mut args = ToolArgs::new();
args.positional.push(Value::String("kj".into()));
let result = Tools.execute(args, &mut ctx).await;
assert!(result.ok());
let text = result.text_out();
assert!(
text.contains("context session list — List sessions in this context"),
"expected three-level full-path leaf line, got:\n{text}"
);
assert!(text.contains("active"), "expected leaf parameter to render, got:\n{text}");
}
#[tokio::test]
async fn test_tools_detail_flat_tool_byte_identical() {
let mut cat = TS::new("cat", "Concatenate files")
.param(ParamSchema::required("path", "string", "File path to read"));
cat.operations = vec!["fs.read".to_string()];
let mut ctx = make_ctx();
ctx.set_tool_schemas(vec![cat]);
let mut args = ToolArgs::new();
args.positional.push(Value::String("cat".into()));
let result = Tools.execute(args, &mut ctx).await;
assert!(result.ok());
assert_eq!(
result.text_out(),
"cat\nConcatenate files\n\nParameters:\n path : string (required)\n File path to read\nOperations: fs.read\n"
);
}
#[tokio::test]
async fn test_tools_detail_renders_examples() {
let mut ctx = make_ctx();
let echo = TS::new("echo", "Print arguments")
.example("Print a literal string", "echo hello");
ctx.set_tool_schemas(vec![echo]);
let mut args = ToolArgs::new();
args.positional.push(Value::String("echo".into()));
let result = Tools.execute(args, &mut ctx).await;
assert!(result.ok());
let text = result.text_out();
assert!(
text.contains("Print a literal string") && text.contains("echo hello"),
"expected the example to render, got:\n{text}"
);
}
#[tokio::test]
async fn test_tools_detail_renders_parameter_aliases() {
let mut ctx = make_ctx();
let head = TS::new("head", "Print the first lines")
.param(ParamSchema::optional("lines", "int", Value::Int(10), "Line count").with_aliases(["-n"]));
ctx.set_tool_schemas(vec![head]);
let mut args = ToolArgs::new();
args.positional.push(Value::String("head".into()));
let result = Tools.execute(args, &mut ctx).await;
assert!(result.ok());
let text = result.text_out();
assert!(
text.contains("(also: -n)"),
"expected the parameter's alias to render, got:\n{text}"
);
}
#[tokio::test]
async fn test_tools_detail_renders_command_aliases() {
let mut ctx = make_ctx();
let list = TS::new("list", "List sessions").with_command_aliases(["ls"]);
ctx.set_tool_schemas(vec![list]);
let mut args = ToolArgs::new();
args.positional.push(Value::String("list".into()));
let result = Tools.execute(args, &mut ctx).await;
assert!(result.ok());
let text = result.text_out();
assert!(
text.contains("Aliases: ls"),
"expected the command alias to render, got:\n{text}"
);
}
#[tokio::test]
async fn test_mounts_list_plain() {
let mut ctx = make_ctx();
let args = ToolArgs::new();
let result = Mounts.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("/"));
assert!(result.text_out().contains("/tmp"));
assert!(result.text_out().contains("rw"));
}
#[tokio::test]
async fn test_mounts_list_json_via_global_flag() {
let mut ctx = make_ctx();
let args = ToolArgs::new();
let result = Mounts.execute(args, &mut ctx).await;
assert!(result.ok());
let result = apply_output_format(result, OutputFormat::Json);
let top: serde_json::Value =
serde_json::from_str(&result.text_out()).expect("valid JSON");
let data = top.get("mounts").expect("must have 'mounts' key");
assert!(data.is_array(), "'mounts' must be an array");
let data = data.as_array().unwrap();
assert!(!data.is_empty());
let paths: Vec<&str> = data
.iter()
.filter_map(|v| v.get("path").and_then(|p| p.as_str()))
.collect();
assert!(paths.contains(&"/"));
assert!(paths.contains(&"/tmp"));
assert!(top.get("budget").is_none(), "no budget key for unbudgeted ctx");
}
}