use super::common;
use crate::core::keyring::Keyring;
use crate::core::manifest::ManifestRegistry;
use crate::core::scope::{self, ScopeConfig};
use crate::output;
use crate::{Cli, OutputFormat, ToolCommands};
pub(crate) async fn discover_mcp_tools(
registry: &mut ManifestRegistry,
keyring: &Keyring,
_verbose: bool,
) {
crate::core::mcp_client::discover_all_mcp_tools(registry, keyring).await;
}
pub async fn execute(cli: &Cli, subcmd: &ToolCommands) -> Result<(), Box<dyn std::error::Error>> {
if let Ok(proxy_url) = std::env::var("ATI_PROXY_URL") {
return execute_via_proxy(cli, subcmd, &proxy_url).await;
}
let ati_dir = common::ati_dir();
let manifests_dir = ati_dir.join("manifests");
let mut registry = ManifestRegistry::load(&manifests_dir)?;
let keyring = super::call::load_keyring(&ati_dir);
discover_mcp_tools(&mut registry, &keyring, cli.verbose).await;
let scopes = common::load_local_scopes_from_env()?;
let skills_dir = ati_dir.join("skills");
let skill_registry =
crate::core::skill::SkillRegistry::load(&skills_dir).unwrap_or_else(|_| {
crate::core::skill::SkillRegistry::load(std::path::Path::new("/nonexistent")).unwrap()
});
match subcmd {
ToolCommands::List { provider } => list_tools(cli, ®istry, &scopes, provider.as_deref()),
ToolCommands::Info { name } => tool_info(cli, ®istry, &scopes, &skill_registry, name),
ToolCommands::Search { query } => search_tools(cli, ®istry, &scopes, query),
}
}
async fn execute_via_proxy(
cli: &Cli,
subcmd: &ToolCommands,
proxy_url: &str,
) -> Result<(), Box<dyn std::error::Error>> {
use crate::proxy::client as proxy_client;
tracing::debug!(proxy_url = %proxy_url, "mode: proxy");
match subcmd {
ToolCommands::List { provider } => {
let mut params = Vec::new();
if let Some(p) = provider {
params.push(format!("provider={p}"));
}
let query = params.join("&");
let tools = proxy_client::list_tools(proxy_url, &query).await?;
let empty = vec![];
let tools_arr = tools.as_array().unwrap_or(&empty);
if tools_arr.is_empty() {
tracing::warn!("no tools available from proxy");
return Ok(());
}
match cli.output {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&tools)?),
_ => {
for tool in tools_arr {
let name = tool["name"].as_str().unwrap_or("?");
let desc = tool["description"].as_str().unwrap_or("");
let provider = tool["provider"].as_str().unwrap_or("?");
let desc_short: String = desc.chars().take(80).collect();
println!("{name:<40} {provider:<15} {desc_short}");
}
}
}
}
ToolCommands::Info { name } => {
let info = proxy_client::get_tool_info(proxy_url, name).await?;
if info.get("error").is_some() {
return Err(format!("Tool '{}' not found", name).into());
}
match cli.output {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&info)?),
_ => {
println!("Tool: {}", info["name"].as_str().unwrap_or("?"));
println!("Provider: {}", info["provider"].as_str().unwrap_or("?"));
println!(
"Description: {}",
info["description"].as_str().unwrap_or("")
);
println!("Method: {}", info["method"].as_str().unwrap_or("?"));
if let Some(tags) = info["tags"].as_array() {
let tag_strs: Vec<&str> = tags.iter().filter_map(|t| t.as_str()).collect();
if !tag_strs.is_empty() {
println!("Tags: {}", tag_strs.join(", "));
}
}
if let Some(skills_arr) = info["skills"].as_array() {
let skill_strs: Vec<&str> =
skills_arr.iter().filter_map(|s| s.as_str()).collect();
if !skill_strs.is_empty() {
println!("Skills: {}", skill_strs.join(", "));
}
}
if let Some(schema) = info.get("input_schema") {
if let Some(props) = schema.get("properties") {
println!("\nParameters:");
if let Some(obj) = props.as_object() {
for (key, val) in obj {
let ptype = val["type"].as_str().unwrap_or("any");
let pdesc = val["description"].as_str().unwrap_or("");
println!(" --{key:<20} ({ptype}) {pdesc}");
}
}
}
}
}
}
}
ToolCommands::Search { query } => {
let params = format!("search={query}");
let tools = proxy_client::list_tools(proxy_url, ¶ms).await?;
let empty = vec![];
let tools_arr = tools.as_array().unwrap_or(&empty);
if tools_arr.is_empty() {
tracing::warn!(query = %query, "no tools match search");
return Ok(());
}
match cli.output {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&tools)?),
_ => {
for tool in tools_arr {
let name = tool["name"].as_str().unwrap_or("?");
let desc = tool["description"].as_str().unwrap_or("");
let provider = tool["provider"].as_str().unwrap_or("?");
let desc_short: String = desc.chars().take(80).collect();
println!("{name:<40} {provider:<15} {desc_short}");
}
}
}
}
}
Ok(())
}
fn list_tools(
cli: &Cli,
registry: &ManifestRegistry,
scopes: &ScopeConfig,
provider_filter: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut tools = registry.list_public_tools();
tools = scope::filter_tools_by_scope(tools, scopes);
if let Some(pf) = provider_filter {
tools.retain(|(p, _)| p.name == pf);
}
if tools.is_empty() {
tracing::warn!("no tools available — check your scopes or manifests");
return Ok(());
}
match cli.output {
OutputFormat::Json => {
let json_tools: Vec<serde_json::Value> = tools
.iter()
.map(|(p, t)| {
serde_json::json!({
"provider": p.name,
"tool": t.name,
"description": t.description,
"method": t.method.to_string(),
"scope": t.scope,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&json_tools)?);
}
OutputFormat::Table | OutputFormat::Text => {
let value = serde_json::json!(tools
.iter()
.map(|(p, t)| {
serde_json::json!({
"PROVIDER": p.name,
"TOOL": t.name,
"DESCRIPTION": t.description,
})
})
.collect::<Vec<_>>());
println!("{}", output::table::format(&value));
}
}
Ok(())
}
fn tool_info(
cli: &Cli,
registry: &ManifestRegistry,
scopes: &ScopeConfig,
skill_registry: &crate::core::skill::SkillRegistry,
name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let (provider, tool) = registry
.get_tool(name)
.filter(|(_, tool)| match &tool.scope {
Some(scope) => scopes.is_allowed(scope),
None => true,
})
.ok_or_else(|| {
format!("Unknown tool: '{name}'. Run 'ati tool list' to see available tools.")
})?;
let mut skills: Vec<String> = provider.skills.clone();
for s in skill_registry.skills_for_tool(&tool.name) {
if !skills.contains(&s.name) {
skills.push(s.name.clone());
}
}
for s in skill_registry.skills_for_provider(&provider.name) {
if !skills.contains(&s.name) {
skills.push(s.name.clone());
}
}
let help_text = if tool.input_schema.is_none() && provider.is_cli() {
super::help::capture_cli_help(provider)
} else {
None
};
match cli.output {
OutputFormat::Json => {
let mut info = serde_json::json!({
"name": tool.name,
"description": tool.description,
"provider": provider.name,
"base_url": provider.base_url,
"method": tool.method.to_string(),
"endpoint": tool.endpoint,
"scope": tool.scope,
"skills": skills,
"input_schema": tool.input_schema,
});
if provider.is_cli() {
info["help_text"] = serde_json::json!(help_text);
}
println!("{}", serde_json::to_string_pretty(&info)?);
}
OutputFormat::Table | OutputFormat::Text => {
println!("Tool: {}", tool.name);
println!("Provider: {} ({})", provider.name, provider.description);
println!("Handler: {}", provider.handler);
if provider.is_mcp() {
println!("Transport: MCP ({})", provider.mcp_transport_type());
} else if provider.is_cli() {
let cmd = provider.cli_command.as_deref().unwrap_or(&tool.name);
println!("Command: {cmd}");
} else {
println!(
"Endpoint: {} {}{}",
tool.method, provider.base_url, tool.endpoint
);
}
println!("Description: {}", tool.description);
if let Some(scope) = &tool.scope {
println!("Scope: {scope}");
}
if let Some(category) = &provider.category {
println!("Category: {category}");
}
if !tool.tags.is_empty() {
println!("Tags: {}", tool.tags.join(", "));
}
if let Some(hint) = &tool.hint {
println!("Hint: {hint}");
}
if !skills.is_empty() {
println!("Skills: {}", skills.join(", "));
}
if let Some(schema) = &tool.input_schema {
println!("\nInput Schema:");
println!("{}", serde_json::to_string_pretty(schema)?);
} else if let Some(ref help) = help_text {
println!("\nCLI Usage (from --help):");
println!("{help}");
}
if !tool.examples.is_empty() {
println!("\nExamples:");
for ex in &tool.examples {
println!(" {ex}");
}
}
println!("\nUsage:");
if provider.is_cli() {
let cmd = provider.cli_command.as_deref().unwrap_or(&tool.name);
println!(
" ati run {} -- <args> (passthrough to `{cmd}`)",
tool.name
);
} else {
print!(" ati run {}", tool.name);
if let Some(schema) = &tool.input_schema {
if let Some(props) = schema.get("properties") {
if let Some(obj) = props.as_object() {
for (k, v) in obj {
let example = v
.get("default")
.or_else(|| v.get("example"))
.map(|e| e.to_string())
.unwrap_or_else(|| format!("<{k}>"));
print!(" --{k} {example}");
}
}
}
}
println!();
}
}
}
Ok(())
}
fn search_tools(
cli: &Cli,
registry: &ManifestRegistry,
scopes: &ScopeConfig,
query: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let mut tools = registry.list_public_tools();
tools = scope::filter_tools_by_scope(tools, scopes);
let query_lower = query.to_lowercase();
let query_terms: Vec<&str> = query_lower.split_whitespace().collect();
let mut scored: Vec<(
f64,
&crate::core::manifest::Provider,
&crate::core::manifest::Tool,
)> = tools
.iter()
.filter_map(|(p, t)| {
let score = score_tool_match(p, t, &query_terms);
if score > 0.0 {
Some((score, *p, *t))
} else {
None
}
})
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(20);
if scored.is_empty() {
tracing::warn!("no tools match '{query}' — try a different search term");
return Ok(());
}
match cli.output {
OutputFormat::Json => {
let json_tools: Vec<serde_json::Value> = scored
.iter()
.map(|(_score, p, t)| {
serde_json::json!({
"provider": p.name,
"tool": t.name,
"description": t.description,
"handler": p.handler,
"category": p.category,
"tags": t.tags,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&json_tools)?);
}
OutputFormat::Table | OutputFormat::Text => {
let value = serde_json::json!(scored
.iter()
.map(|(_score, p, t)| {
serde_json::json!({
"PROVIDER": p.name,
"TOOL": t.name,
"DESCRIPTION": t.description,
})
})
.collect::<Vec<_>>());
println!("{}", output::table::format(&value));
}
}
Ok(())
}
const STOP_WORDS: &[&str] = &[
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
"do", "does", "did", "will", "would", "could", "should", "may", "might", "shall", "can",
"need", "must", "i", "me", "my", "we", "our", "you", "your", "he", "she", "it", "they", "how",
"what", "when", "where", "which", "who", "whom", "why", "to", "of", "in", "for", "on", "with",
"at", "by", "from", "about", "into", "through", "during", "before", "after", "above", "below",
"and", "but", "or", "nor", "not", "so", "if", "than", "that", "this", "there", "here", "all",
"each", "every", "both", "few", "more", "most", "some", "any", "no", "only", "very", "just",
"also", "then", "use", "using", "want", "like", "way",
];
const FUZZY_THRESHOLD: f64 = 0.85;
fn term_matches_word(term: &str, word: &str) -> bool {
if word.contains(term) {
return true;
}
if term.len() >= 4 {
return strsim::jaro_winkler(term, word) >= FUZZY_THRESHOLD;
}
false
}
fn score_term_against_field(term: &str, field: &str, weight: f64) -> f64 {
if field.contains(term) {
return weight;
}
if term.len() >= 4 {
for word in field.split(|c: char| !c.is_alphanumeric() && c != '_') {
if word.len() >= 3 && strsim::jaro_winkler(term, word) >= FUZZY_THRESHOLD {
return weight * 0.8;
}
}
}
0.0
}
pub(crate) fn score_tool_match(
provider: &crate::core::manifest::Provider,
tool: &crate::core::manifest::Tool,
query_terms: &[&str],
) -> f64 {
let mut score = 0.0;
let name_lower = tool.name.to_lowercase();
let desc_lower = tool.description.to_lowercase();
let provider_lower = provider.name.to_lowercase();
let category_lower = provider.category.as_deref().unwrap_or("").to_lowercase();
let tags_lower: Vec<String> = tool.tags.iter().map(|t| t.to_lowercase()).collect();
let content_terms: Vec<&str> = query_terms
.iter()
.filter(|t| t.len() >= 2 && !STOP_WORDS.contains(&t.to_lowercase().as_str()))
.copied()
.collect();
if content_terms.is_empty() {
return 0.0;
}
let mut matched_terms = 0;
for term in &content_terms {
let mut term_score = 0.0;
if name_lower == *term {
term_score += 10.0;
} else {
term_score += score_term_against_field(term, &name_lower, 5.0);
}
term_score += score_term_against_field(term, &provider_lower, 3.0);
if !category_lower.is_empty() {
term_score += score_term_against_field(term, &category_lower, 3.0);
}
for tag in &tags_lower {
if term_matches_word(term, tag) {
term_score += 4.0;
break;
}
}
term_score += score_term_against_field(term, &desc_lower, 2.0);
if let Some(hint) = &tool.hint {
term_score += score_term_against_field(term, &hint.to_lowercase(), 1.5);
}
if term_score > 0.0 {
matched_terms += 1;
}
score += term_score;
}
let min_required = content_terms.len().div_ceil(2);
if matched_terms < min_required {
return 0.0;
}
let match_ratio = matched_terms as f64 / content_terms.len() as f64;
score * match_ratio
}