use anyhow::Result;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq)]
pub enum ToolScope {
Global,
Team(String),
Project,
}
impl ToolScope {
pub fn label(&self) -> &str {
match self {
ToolScope::Global => "global",
ToolScope::Team(_) => "team",
ToolScope::Project => "project",
}
}
}
#[derive(Debug, Clone)]
pub struct ToolMeta {
pub name: String,
pub display_name: String,
pub description: String,
pub command: String,
pub args: String,
pub output_format: String,
pub tags: Vec<String>,
pub scope: ToolScope,
}
#[derive(Debug, Clone, Deserialize)]
struct ToolFile {
tool: ToolConfig,
}
#[derive(Debug, Clone, Deserialize)]
struct ToolConfig {
id: String,
display_name: Option<String>,
#[serde(default)]
description: String,
command: String,
#[serde(default)]
args: String,
#[serde(default = "default_output_format")]
output_format: String,
#[serde(default)]
tags: Vec<String>,
}
fn default_output_format() -> String {
"text".to_string()
}
pub fn tools_dir() -> PathBuf {
crate::paths::aid_dir().join("tools")
}
pub fn team_tools_dir(team_id: &str) -> PathBuf {
crate::team::teams_dir().join(team_id).join("tools")
}
fn load_tool_from_toml(path: &Path, scope: ToolScope) -> Option<ToolMeta> {
let content = std::fs::read_to_string(path).ok()?;
let file: ToolFile = toml::from_str(&content).ok()?;
let ToolConfig { id, display_name, description, command, args, output_format, tags } = file.tool;
let display_name = display_name.unwrap_or_else(|| id.clone());
Some(ToolMeta { name: id, display_name, description, command, args, output_format, tags, scope })
}
fn parse_tool_script(path: &Path, scope: ToolScope) -> Option<ToolMeta> {
let content = std::fs::read_to_string(path).ok()?;
let name = path.file_stem()?.to_str()?.to_string();
let mut description = String::new();
let mut args = String::new();
let mut output_hint = String::new();
for line in content.lines().take(10) {
let trimmed = line.trim_start_matches('#').trim();
if let Some(desc) = trimmed.strip_prefix("@description:") {
description = desc.trim().to_string();
} else if let Some(a) = trimmed.strip_prefix("@args:") {
args = a.trim().to_string();
} else if let Some(o) = trimmed.strip_prefix("@output:") {
output_hint = o.trim().to_string();
}
}
if description.is_empty() {
description = format!("Run {name}");
}
let output_format = if output_hint.to_lowercase().contains("json") {
"json".to_string()
} else {
"text".to_string()
};
Some(ToolMeta {
display_name: name.clone(),
name,
description,
command: path.to_string_lossy().to_string(),
args,
output_format,
tags: Vec::new(),
scope,
})
}
fn list_tools_in_dir(dir: &Path, scope: ToolScope) -> Vec<ToolMeta> {
let mut tools = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
if let Some(tool) = load_tool_from_toml(&path, scope.clone()) {
tools.push(tool);
}
}
}
}
let scripts_dir = dir.join("scripts");
if let Ok(entries) = std::fs::read_dir(&scripts_dir) {
for entry in entries.flatten() {
let is_file = entry.file_type().ok().map(|ft| ft.is_file()).unwrap_or(false);
let visible = entry.file_name().to_str().map(|n| !n.starts_with('.')).unwrap_or(false);
if is_file && visible {
if let Some(tool) = parse_tool_script(&entry.path(), scope.clone()) {
tools.push(tool);
}
}
}
}
tools.sort_by(|a, b| a.name.cmp(&b.name));
tools
}
pub fn list_tools() -> Vec<ToolMeta> {
list_tools_in_dir(&tools_dir(), ToolScope::Global)
}
pub fn list_team_tools(team_id: &str) -> Vec<ToolMeta> {
list_tools_in_dir(&team_tools_dir(team_id), ToolScope::Team(team_id.to_string()))
}
pub fn resolve_toolbox(team_id: Option<&str>, project_dir: Option<&Path>) -> Vec<ToolMeta> {
let mut seen: BTreeMap<String, ToolMeta> = BTreeMap::new();
for tool in list_tools_in_dir(&tools_dir(), ToolScope::Global) {
seen.insert(tool.name.clone(), tool);
}
if let Some(id) = team_id {
for tool in list_tools_in_dir(&team_tools_dir(id), ToolScope::Team(id.to_string())) {
seen.insert(tool.name.clone(), tool);
}
}
if let Some(dir) = project_dir {
for tool in list_tools_in_dir(&dir.join(".aid").join("tools"), ToolScope::Project) {
seen.insert(tool.name.clone(), tool);
}
}
seen.into_values().collect()
}
pub fn filter_by_auto_inject(tools: Vec<ToolMeta>, auto_inject: &[String]) -> Vec<ToolMeta> {
if auto_inject.is_empty() {
return tools;
}
let allow: std::collections::HashSet<&str> = auto_inject.iter().map(|s| s.as_str()).collect();
tools.into_iter().filter(|t| allow.contains(t.name.as_str())).collect()
}
pub fn filter_by_task_category(tools: Vec<ToolMeta>, category: &str) -> Vec<ToolMeta> {
let category = category.trim();
if category.is_empty() {
return tools;
}
let relevant_tags: &[&str] = match category {
"research" => &["research", "search", "docs", "web"],
"simple-edit" | "complex-impl" | "refactoring" => {
&["build", "lint", "test", "verify", "deploy"]
}
"frontend" => &["build", "lint", "test", "ui", "design"],
"debugging" => &["debug", "trace", "logs", "rpc", "chain"],
"testing" => &["test", "verify", "build"],
"documentation" => &["docs", "search", "web"],
_ => return tools,
};
tools
.into_iter()
.filter(|tool| {
tool.tags.is_empty()
|| tool
.tags
.iter()
.any(|tag| relevant_tags.contains(&tag.as_str()))
})
.collect()
}
pub fn find_tool(name: &str, team_id: Option<&str>, project_dir: Option<&Path>) -> Result<ToolMeta> {
crate::sanitize::validate_name(name, "tool")?;
resolve_toolbox(team_id, project_dir)
.into_iter()
.find(|t| t.name == name)
.ok_or_else(|| anyhow::anyhow!("Tool '{name}' not found"))
}
pub fn format_toolbox_instructions(tools: &[ToolMeta]) -> String {
if tools.is_empty() {
return String::new();
}
let mut lines = vec!["--- Team Toolbox ---".to_string()];
lines.push("The following tools are available via bash. Use `aid tool show <name>` for full usage.".to_string());
lines.push(String::new());
for tool in tools {
lines.push(format!(" {}: {}", tool.name, tool.description));
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths;
use std::fs;
use tempfile::TempDir;
#[test]
fn loads_tool_from_toml_file() {
let temp = TempDir::new().unwrap();
let _guard = paths::AidHomeGuard::set(temp.path());
let dir = tools_dir();
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("lint.toml"), "[tool]\nid = \"lint\"\ndisplay_name = \"Linter\"\ndescription = \"Run linting\"\ncommand = \"eslint\"\nargs = \"--format json\"\noutput_format = \"json\"\ntags = [\"quality\"]\n").unwrap();
let tools = list_tools();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "lint");
assert_eq!(tools[0].display_name, "Linter");
assert_eq!(tools[0].command, "eslint");
assert_eq!(tools[0].output_format, "json");
assert_eq!(tools[0].tags, vec!["quality"]);
assert_eq!(tools[0].scope, ToolScope::Global);
}
#[test]
fn parses_tool_script_metadata() {
let temp = TempDir::new().unwrap();
let _guard = paths::AidHomeGuard::set(temp.path());
let dir = tools_dir().join("scripts");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("check.sh"), "#!/bin/bash\n# @description: Run checks\n# @args: <files...>\n# @output: JSON results\n").unwrap();
let tools = list_tools();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "check");
assert_eq!(tools[0].description, "Run checks");
assert_eq!(tools[0].args, "<files...>");
assert_eq!(tools[0].output_format, "json");
}
#[test]
fn list_finds_toml_and_scripts() {
let temp = TempDir::new().unwrap();
let _guard = paths::AidHomeGuard::set(temp.path());
let dir = tools_dir();
fs::create_dir_all(dir.join("scripts")).unwrap();
fs::write(dir.join("alpha.toml"), "[tool]\nid = \"alpha\"\ncommand = \"alpha-cmd\"\n").unwrap();
fs::write(dir.join("scripts").join("beta.sh"), "#!/bin/bash\n").unwrap();
let names: Vec<_> = list_tools().iter().map(|t| t.name.clone()).collect();
assert_eq!(names, vec!["alpha", "beta"]);
}
#[test]
fn resolve_deduplicates_by_scope_priority() {
let temp = TempDir::new().unwrap();
let _guard = paths::AidHomeGuard::set(temp.path());
let global = tools_dir();
fs::create_dir_all(&global).unwrap();
fs::write(global.join("shared.toml"), "[tool]\nid = \"shared\"\ncommand = \"global-cmd\"\ndescription = \"global\"\n").unwrap();
let team = team_tools_dir("dev");
fs::create_dir_all(&team).unwrap();
fs::write(team.join("shared.toml"), "[tool]\nid = \"shared\"\ncommand = \"team-cmd\"\ndescription = \"team\"\n").unwrap();
let tools = resolve_toolbox(Some("dev"), None);
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].command, "team-cmd");
assert_eq!(tools[0].scope, ToolScope::Team("dev".to_string()));
}
#[test]
fn format_instructions_renders_tools() {
let tools = vec![ToolMeta {
name: "lint".to_string(),
display_name: "Linter".to_string(),
description: "Run linting".to_string(),
command: "eslint".to_string(),
args: "<files>".to_string(),
output_format: "json".to_string(),
tags: vec![],
scope: ToolScope::Global,
}];
let output = format_toolbox_instructions(&tools);
assert!(output.contains("--- Team Toolbox ---"));
assert!(output.contains("lint: Run linting"));
assert!(!output.contains("eslint"), "command path should not appear in summary");
assert!(!output.contains("Output: json"), "output format should not appear in summary");
}
#[test]
fn format_empty_returns_empty() {
assert!(format_toolbox_instructions(&[]).is_empty());
}
#[test]
fn find_tool_rejects_invalid_name() {
let err = find_tool("../escape", None, None).unwrap_err();
assert!(err.to_string().contains("Invalid tool name"));
}
#[test]
fn tool_scope_labels() {
assert_eq!(ToolScope::Global.label(), "global");
assert_eq!(ToolScope::Team("dev".to_string()).label(), "team");
assert_eq!(ToolScope::Project.label(), "project");
}
#[test]
fn empty_dir_returns_no_tools() {
let temp = TempDir::new().unwrap();
let _guard = paths::AidHomeGuard::set(temp.path());
assert!(list_tools().is_empty());
}
#[test]
fn toml_defaults_when_optional_fields_omitted() {
let temp = TempDir::new().unwrap();
let _guard = paths::AidHomeGuard::set(temp.path());
let dir = tools_dir();
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("minimal.toml"), "[tool]\nid = \"minimal\"\ncommand = \"run-it\"\n").unwrap();
let tools = list_tools();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].display_name, "minimal");
assert_eq!(tools[0].output_format, "text");
assert!(tools[0].tags.is_empty());
}
#[test]
fn resolve_toolbox_returns_all_without_filter() {
let temp = TempDir::new().unwrap();
let _guard = paths::AidHomeGuard::set(temp.path());
let dir = tools_dir();
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("a.toml"), "[tool]\nid = \"a\"\ncommand = \"a-cmd\"\n").unwrap();
fs::write(dir.join("b.toml"), "[tool]\nid = \"b\"\ncommand = \"b-cmd\"\n").unwrap();
let tools = resolve_toolbox(None, None);
assert_eq!(tools.len(), 2);
}
fn make_tool(name: &str) -> ToolMeta {
ToolMeta {
name: name.to_string(),
display_name: name.to_string(),
description: String::new(),
command: name.to_string(),
args: String::new(),
output_format: "text".to_string(),
tags: vec![],
scope: ToolScope::Global,
}
}
#[test]
fn filter_by_auto_inject_filters_correctly() {
let tools = vec![make_tool("lint"), make_tool("test"), make_tool("build")];
let filtered = filter_by_auto_inject(tools, &["lint".to_string(), "build".to_string()]);
assert_eq!(filtered.len(), 2);
let names: Vec<_> = filtered.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"lint"));
assert!(names.contains(&"build"));
assert!(!names.contains(&"test"));
}
#[test]
fn filter_by_auto_inject_empty_returns_all() {
let tools = vec![make_tool("lint"), make_tool("test")];
let result = filter_by_auto_inject(tools, &[]);
assert_eq!(result.len(), 2);
}
#[test]
fn filter_by_task_category_keeps_untagged_tools() {
let tools = vec![make_tool("universal")];
let filtered = filter_by_task_category(tools, "research");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "universal");
}
#[test]
fn filter_by_task_category_filters_irrelevant() {
let mut tool = make_tool("rpc");
tool.tags = vec!["rpc".to_string()];
let filtered = filter_by_task_category(vec![tool], "research");
assert!(filtered.is_empty());
}
#[test]
fn filter_by_task_category_keeps_relevant() {
let mut tool = make_tool("search");
tool.tags = vec!["search".to_string()];
let filtered = filter_by_task_category(vec![tool], "research");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "search");
}
#[test]
fn filter_by_task_category_empty_category_keeps_all() {
let mut tagged = make_tool("search");
tagged.tags = vec!["search".to_string()];
let tools = vec![make_tool("universal"), tagged];
let filtered = filter_by_task_category(tools, "");
assert_eq!(filtered.len(), 2);
}
#[test]
fn project_scope_overrides_global() {
let temp = TempDir::new().unwrap();
let _guard = paths::AidHomeGuard::set(temp.path());
let global = tools_dir();
fs::create_dir_all(&global).unwrap();
fs::write(global.join("tool.toml"), "[tool]\nid = \"tool\"\ncommand = \"global\"\n").unwrap();
let project = temp.path().join("project");
let project_tools = project.join(".aid").join("tools");
fs::create_dir_all(&project_tools).unwrap();
fs::write(project_tools.join("tool.toml"), "[tool]\nid = \"tool\"\ncommand = \"project\"\n").unwrap();
let tools = resolve_toolbox(None, Some(&project));
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].command, "project");
assert_eq!(tools[0].scope, ToolScope::Project);
}
}