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 parsed = match ToolsArgs::try_parse_from(
std::iter::once("kaish-tools".to_string()).chain(args.to_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(),
];
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])
})
.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\n", s.name, s.description);
if !s.params.is_empty() {
output.push_str("Parameters:\n");
for p in &s.params {
let required = if p.required { "(required)" } else { "(optional)" };
output.push_str(&format!(
" {} : {} {}\n {}\n",
p.name, p.param_type, required, p.description
));
}
}
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 parsed = match MountsArgs::try_parse_from(
std::iter::once("kaish-mounts".to_string()).chain(args.to_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::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_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");
}
}