diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 4c3358c..5aa63a3 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -201,14 +201,28 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ resume_supported: false, }, SlashCommandSpec { - name: "plugins", - aliases: &[], - summary: "List or manage plugins", + name: "plugin", + aliases: &["plugins", "marketplace"], + summary: "Manage Claw Code plugins", argument_hint: Some( "[list|install |enable |disable |uninstall |update ]", ), resume_supported: false, }, + SlashCommandSpec { + name: "agents", + aliases: &[], + summary: "List configured agents", + argument_hint: None, + resume_supported: true, + }, + SlashCommandSpec { + name: "skills", + aliases: &[], + summary: "List available skills", + argument_hint: None, + resume_supported: true, + }, ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -264,6 +278,12 @@ pub enum SlashCommand { action: Option, target: Option, }, + Agents { + args: Option, + }, + Skills { + args: Option, + }, Unknown(String), } @@ -325,13 +345,19 @@ impl SlashCommand { action: parts.next().map(ToOwned::to_owned), target: parts.next().map(ToOwned::to_owned), }, - "plugins" => Self::Plugins { + "plugin" | "plugins" | "marketplace" => Self::Plugins { action: parts.next().map(ToOwned::to_owned), target: { let remainder = parts.collect::>().join(" "); (!remainder.is_empty()).then_some(remainder) }, }, + "agents" => Self::Agents { + args: remainder_after_command(trimmed, command), + }, + "skills" => Self::Skills { + args: remainder_after_command(trimmed, command), + }, other => Self::Unknown(other.to_string()), }) } @@ -370,12 +396,27 @@ pub fn render_slash_command_help() -> String { Some(argument_hint) => format!("/{} {}", spec.name, argument_hint), None => format!("/{}", spec.name), }; + let alias_suffix = if spec.aliases.is_empty() { + String::new() + } else { + format!( + " (aliases: {})", + spec.aliases + .iter() + .map(|alias| format!("/{alias}")) + .collect::>() + .join(", ") + ) + }; let resume = if spec.resume_supported { " [resume]" } else { "" }; - lines.push(format!(" {name:<20} {}{}", spec.summary, resume)); + lines.push(format!( + " {name:<20} {}{alias_suffix}{resume}", + spec.summary + )); } lines.join("\n") } @@ -392,6 +433,68 @@ pub struct PluginsCommandResult { pub reload_runtime: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum DefinitionSource { + ProjectCodex, + ProjectClaude, + UserCodexHome, + UserCodex, + UserClaude, +} + +impl DefinitionSource { + fn label(self) -> &'static str { + match self { + Self::ProjectCodex => "Project (.codex)", + Self::ProjectClaude => "Project (.claude)", + Self::UserCodexHome => "User ($CODEX_HOME)", + Self::UserCodex => "User (~/.codex)", + Self::UserClaude => "User (~/.claude)", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AgentSummary { + name: String, + description: Option, + model: Option, + reasoning_effort: Option, + source: DefinitionSource, + shadowed_by: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SkillSummary { + name: String, + description: Option, + source: DefinitionSource, + shadowed_by: Option, + origin: SkillOrigin, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SkillOrigin { + SkillsDir, + LegacyCommandsDir, +} + +impl SkillOrigin { + fn detail_label(self) -> Option<&'static str> { + match self { + Self::SkillsDir => None, + Self::LegacyCommandsDir => Some("legacy /commands"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SkillRoot { + source: DefinitionSource, + path: PathBuf, + origin: SkillOrigin, +} + #[allow(clippy::too_many_lines)] pub fn handle_plugins_slash_command( action: Option<&str>, @@ -504,6 +607,30 @@ pub fn handle_plugins_slash_command( } } +pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result { + match normalize_optional_args(args) { + None | Some("list") => { + let roots = discover_definition_roots(cwd, "agents"); + let agents = load_agents_from_roots(&roots)?; + Ok(render_agents_report(&agents)) + } + Some("-h" | "--help" | "help") => Ok(render_agents_usage(None)), + Some(args) => Ok(render_agents_usage(Some(args))), + } +} + +pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result { + match normalize_optional_args(args) { + None | Some("list") => { + let roots = discover_skill_roots(cwd); + let skills = load_skills_from_roots(&roots)?; + Ok(render_skills_report(&skills)) + } + Some("-h" | "--help" | "help") => Ok(render_skills_usage(None)), + Some(args) => Ok(render_skills_usage(Some(args))), + } +} + #[must_use] pub fn render_plugins_report(plugins: &[PluginSummary]) -> String { let mut lines = vec!["Plugins".to_string()]; @@ -556,6 +683,477 @@ fn resolve_plugin_target( } } +fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, PathBuf)> { + let mut roots = Vec::new(); + + for ancestor in cwd.ancestors() { + push_unique_root( + &mut roots, + DefinitionSource::ProjectCodex, + ancestor.join(".codex").join(leaf), + ); + push_unique_root( + &mut roots, + DefinitionSource::ProjectClaude, + ancestor.join(".claude").join(leaf), + ); + } + + if let Ok(codex_home) = env::var("CODEX_HOME") { + push_unique_root( + &mut roots, + DefinitionSource::UserCodexHome, + PathBuf::from(codex_home).join(leaf), + ); + } + + if let Some(home) = env::var_os("HOME") { + let home = PathBuf::from(home); + push_unique_root( + &mut roots, + DefinitionSource::UserCodex, + home.join(".codex").join(leaf), + ); + push_unique_root( + &mut roots, + DefinitionSource::UserClaude, + home.join(".claude").join(leaf), + ); + } + + roots +} + +fn discover_skill_roots(cwd: &Path) -> Vec { + let mut roots = Vec::new(); + + for ancestor in cwd.ancestors() { + push_unique_skill_root( + &mut roots, + DefinitionSource::ProjectCodex, + ancestor.join(".codex").join("skills"), + SkillOrigin::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + DefinitionSource::ProjectClaude, + ancestor.join(".claude").join("skills"), + SkillOrigin::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + DefinitionSource::ProjectCodex, + ancestor.join(".codex").join("commands"), + SkillOrigin::LegacyCommandsDir, + ); + push_unique_skill_root( + &mut roots, + DefinitionSource::ProjectClaude, + ancestor.join(".claude").join("commands"), + SkillOrigin::LegacyCommandsDir, + ); + } + + if let Ok(codex_home) = env::var("CODEX_HOME") { + let codex_home = PathBuf::from(codex_home); + push_unique_skill_root( + &mut roots, + DefinitionSource::UserCodexHome, + codex_home.join("skills"), + SkillOrigin::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + DefinitionSource::UserCodexHome, + codex_home.join("commands"), + SkillOrigin::LegacyCommandsDir, + ); + } + + if let Some(home) = env::var_os("HOME") { + let home = PathBuf::from(home); + push_unique_skill_root( + &mut roots, + DefinitionSource::UserCodex, + home.join(".codex").join("skills"), + SkillOrigin::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + DefinitionSource::UserCodex, + home.join(".codex").join("commands"), + SkillOrigin::LegacyCommandsDir, + ); + push_unique_skill_root( + &mut roots, + DefinitionSource::UserClaude, + home.join(".claude").join("skills"), + SkillOrigin::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + DefinitionSource::UserClaude, + home.join(".claude").join("commands"), + SkillOrigin::LegacyCommandsDir, + ); + } + + roots +} + +fn push_unique_root( + roots: &mut Vec<(DefinitionSource, PathBuf)>, + source: DefinitionSource, + path: PathBuf, +) { + if path.is_dir() && !roots.iter().any(|(_, existing)| existing == &path) { + roots.push((source, path)); + } +} + +fn push_unique_skill_root( + roots: &mut Vec, + source: DefinitionSource, + path: PathBuf, + origin: SkillOrigin, +) { + if path.is_dir() && !roots.iter().any(|existing| existing.path == path) { + roots.push(SkillRoot { + source, + path, + origin, + }); + } +} + +fn load_agents_from_roots( + roots: &[(DefinitionSource, PathBuf)], +) -> std::io::Result> { + let mut agents = Vec::new(); + let mut active_sources = BTreeMap::::new(); + + for (source, root) in roots { + let mut root_agents = Vec::new(); + for entry in fs::read_dir(root)? { + let entry = entry?; + if entry.path().extension().is_none_or(|ext| ext != "toml") { + continue; + } + let contents = fs::read_to_string(entry.path())?; + let fallback_name = entry.path().file_stem().map_or_else( + || entry.file_name().to_string_lossy().to_string(), + |stem| stem.to_string_lossy().to_string(), + ); + root_agents.push(AgentSummary { + name: parse_toml_string(&contents, "name").unwrap_or(fallback_name), + description: parse_toml_string(&contents, "description"), + model: parse_toml_string(&contents, "model"), + reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"), + source: *source, + shadowed_by: None, + }); + } + root_agents.sort_by(|left, right| left.name.cmp(&right.name)); + + for mut agent in root_agents { + let key = agent.name.to_ascii_lowercase(); + if let Some(existing) = active_sources.get(&key) { + agent.shadowed_by = Some(*existing); + } else { + active_sources.insert(key, agent.source); + } + agents.push(agent); + } + } + + Ok(agents) +} + +fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result> { + let mut skills = Vec::new(); + let mut active_sources = BTreeMap::::new(); + + for root in roots { + let mut root_skills = Vec::new(); + for entry in fs::read_dir(&root.path)? { + let entry = entry?; + match root.origin { + SkillOrigin::SkillsDir => { + if !entry.path().is_dir() { + continue; + } + let skill_path = entry.path().join("SKILL.md"); + if !skill_path.is_file() { + continue; + } + let contents = fs::read_to_string(skill_path)?; + let (name, description) = parse_skill_frontmatter(&contents); + root_skills.push(SkillSummary { + name: name + .unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()), + description, + source: root.source, + shadowed_by: None, + origin: root.origin, + }); + } + SkillOrigin::LegacyCommandsDir => { + let path = entry.path(); + let markdown_path = if path.is_dir() { + let skill_path = path.join("SKILL.md"); + if !skill_path.is_file() { + continue; + } + skill_path + } else if path + .extension() + .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md")) + { + path + } else { + continue; + }; + + let contents = fs::read_to_string(&markdown_path)?; + let fallback_name = markdown_path.file_stem().map_or_else( + || entry.file_name().to_string_lossy().to_string(), + |stem| stem.to_string_lossy().to_string(), + ); + let (name, description) = parse_skill_frontmatter(&contents); + root_skills.push(SkillSummary { + name: name.unwrap_or(fallback_name), + description, + source: root.source, + shadowed_by: None, + origin: root.origin, + }); + } + } + } + root_skills.sort_by(|left, right| left.name.cmp(&right.name)); + + for mut skill in root_skills { + let key = skill.name.to_ascii_lowercase(); + if let Some(existing) = active_sources.get(&key) { + skill.shadowed_by = Some(*existing); + } else { + active_sources.insert(key, skill.source); + } + skills.push(skill); + } + } + + Ok(skills) +} + +fn parse_toml_string(contents: &str, key: &str) -> Option { + let prefix = format!("{key} ="); + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('#') { + continue; + } + let Some(value) = trimmed.strip_prefix(&prefix) else { + continue; + }; + let value = value.trim(); + let Some(value) = value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + else { + continue; + }; + if !value.is_empty() { + return Some(value.to_string()); + } + } + None +} + +fn parse_skill_frontmatter(contents: &str) -> (Option, Option) { + let mut lines = contents.lines(); + if lines.next().map(str::trim) != Some("---") { + return (None, None); + } + + let mut name = None; + let mut description = None; + for line in lines { + let trimmed = line.trim(); + if trimmed == "---" { + break; + } + if let Some(value) = trimmed.strip_prefix("name:") { + let value = unquote_frontmatter_value(value.trim()); + if !value.is_empty() { + name = Some(value); + } + continue; + } + if let Some(value) = trimmed.strip_prefix("description:") { + let value = unquote_frontmatter_value(value.trim()); + if !value.is_empty() { + description = Some(value); + } + } + } + + (name, description) +} + +fn unquote_frontmatter_value(value: &str) -> String { + value + .strip_prefix('"') + .and_then(|trimmed| trimmed.strip_suffix('"')) + .or_else(|| { + value + .strip_prefix('\'') + .and_then(|trimmed| trimmed.strip_suffix('\'')) + }) + .unwrap_or(value) + .trim() + .to_string() +} + +fn render_agents_report(agents: &[AgentSummary]) -> String { + if agents.is_empty() { + return "No agents found.".to_string(); + } + + let total_active = agents + .iter() + .filter(|agent| agent.shadowed_by.is_none()) + .count(); + let mut lines = vec![ + "Agents".to_string(), + format!(" {total_active} active agents"), + String::new(), + ]; + + for source in [ + DefinitionSource::ProjectCodex, + DefinitionSource::ProjectClaude, + DefinitionSource::UserCodexHome, + DefinitionSource::UserCodex, + DefinitionSource::UserClaude, + ] { + let group = agents + .iter() + .filter(|agent| agent.source == source) + .collect::>(); + if group.is_empty() { + continue; + } + + lines.push(format!("{}:", source.label())); + for agent in group { + let detail = agent_detail(agent); + match agent.shadowed_by { + Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())), + None => lines.push(format!(" {detail}")), + } + } + lines.push(String::new()); + } + + lines.join("\n").trim_end().to_string() +} + +fn agent_detail(agent: &AgentSummary) -> String { + let mut parts = vec![agent.name.clone()]; + if let Some(description) = &agent.description { + parts.push(description.clone()); + } + if let Some(model) = &agent.model { + parts.push(model.clone()); + } + if let Some(reasoning) = &agent.reasoning_effort { + parts.push(reasoning.clone()); + } + parts.join(" · ") +} + +fn render_skills_report(skills: &[SkillSummary]) -> String { + if skills.is_empty() { + return "No skills found.".to_string(); + } + + let total_active = skills + .iter() + .filter(|skill| skill.shadowed_by.is_none()) + .count(); + let mut lines = vec![ + "Skills".to_string(), + format!(" {total_active} available skills"), + String::new(), + ]; + + for source in [ + DefinitionSource::ProjectCodex, + DefinitionSource::ProjectClaude, + DefinitionSource::UserCodexHome, + DefinitionSource::UserCodex, + DefinitionSource::UserClaude, + ] { + let group = skills + .iter() + .filter(|skill| skill.source == source) + .collect::>(); + if group.is_empty() { + continue; + } + + lines.push(format!("{}:", source.label())); + for skill in group { + let mut parts = vec![skill.name.clone()]; + if let Some(description) = &skill.description { + parts.push(description.clone()); + } + if let Some(detail) = skill.origin.detail_label() { + parts.push(detail.to_string()); + } + let detail = parts.join(" · "); + match skill.shadowed_by { + Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())), + None => lines.push(format!(" {detail}")), + } + } + lines.push(String::new()); + } + + lines.join("\n").trim_end().to_string() +} + +fn normalize_optional_args(args: Option<&str>) -> Option<&str> { + args.map(str::trim).filter(|value| !value.is_empty()) +} + +fn render_agents_usage(unexpected: Option<&str>) -> String { + let mut lines = vec![ + "Agents".to_string(), + " Usage /agents".to_string(), + " Direct CLI claw agents".to_string(), + " Sources .codex/agents, .claude/agents, $CODEX_HOME/agents".to_string(), + ]; + if let Some(args) = unexpected { + lines.push(format!(" Unexpected {args}")); + } + lines.join("\n") +} + +fn render_skills_usage(unexpected: Option<&str>) -> String { + let mut lines = vec![ + "Skills".to_string(), + " Usage /skills".to_string(), + " Direct CLI claw skills".to_string(), + " Sources .codex/skills, .claude/skills, legacy /commands".to_string(), + ]; + if let Some(args) = unexpected { + lines.push(format!(" Unexpected {args}")); + } + lines.join("\n") +} + #[must_use] pub fn handle_slash_command( input: &str, @@ -603,221 +1201,19 @@ pub fn handle_slash_command( | SlashCommand::Export { .. } | SlashCommand::Session { .. } | SlashCommand::Plugins { .. } + | SlashCommand::Agents { .. } + | SlashCommand::Skills { .. } | SlashCommand::Unknown(_) => None, } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DefinitionSource { - ProjectCodex, - UserCodex, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AgentDef { - pub name: String, - pub description: String, - pub model: String, - pub temperature: String, - pub source: DefinitionSource, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SkillDef { - pub name: String, - pub description: String, - pub source: DefinitionSource, -} - -pub fn load_agents_from_roots( - roots: &[(DefinitionSource, std::path::PathBuf)], -) -> Result, String> { - let mut agents = Vec::new(); - let mut seen: std::collections::HashMap = - std::collections::HashMap::new(); - - for (source, path) in roots { - if !path.exists() { - continue; - } - for entry in std::fs::read_dir(path).map_err(|e| e.to_string())? { - let entry = entry.map_err(|e| e.to_string())?; - let file_path = entry.path(); - if file_path.extension().map_or(false, |ext| ext == "json") { - let name = file_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("unknown") - .to_string(); - let content = std::fs::read_to_string(&file_path).map_err(|e| e.to_string())?; - let json: serde_json::Value = - serde_json::from_str(&content).map_err(|e| e.to_string())?; - let description = json - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let model = json - .get("model") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - let temperature = json - .get("temperature") - .and_then(|v| v.as_str()) - .unwrap_or("medium") - .to_string(); - - let shadowed_by = seen.get(&name).copied(); - seen.insert(name.clone(), *source); - let final_name = if let Some(shadow_source) = shadowed_by { - let source_name = match shadow_source { - DefinitionSource::ProjectCodex => "Project (.codex)", - DefinitionSource::UserCodex => "User (~/.codex)", - }; - format!("(shadowed by {}) {}", source_name, name) - } else { - name - }; - agents.push(AgentDef { - name: final_name, - description, - model, - temperature, - source: *source, - }); - } - } - } - Ok(agents) -} - -pub fn load_skills_from_roots( - roots: &[(DefinitionSource, std::path::PathBuf)], -) -> Result, String> { - let mut skills = Vec::new(); - let mut seen: std::collections::HashMap = - std::collections::HashMap::new(); - - for (source, path) in roots { - if !path.exists() { - continue; - } - for entry in std::fs::read_dir(path).map_err(|e| e.to_string())? { - let entry = entry.map_err(|e| e.to_string())?; - let file_path = entry.path(); - if file_path.extension().map_or(false, |ext| ext == "md") { - let name = file_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("unknown") - .to_string(); - let content = std::fs::read_to_string(&file_path).map_err(|e| e.to_string())?; - let description = content.lines().next().unwrap_or("").to_string(); - - let shadowed_by = seen.get(&name).copied(); - seen.insert(name.clone(), *source); - let final_name = if let Some(shadow_source) = shadowed_by { - let source_name = match shadow_source { - DefinitionSource::ProjectCodex => "Project (.codex)", - DefinitionSource::UserCodex => "User (~/.codex)", - }; - format!("(shadowed by {}) {}", source_name, name) - } else { - name - }; - skills.push(SkillDef { - name: final_name, - description, - source: *source, - }); - } - } - } - Ok(skills) -} - -pub fn render_agents_report(agents: &[AgentDef]) -> String { - let mut lines = vec!["Agents".to_string()]; - let project_agents: Vec<_> = agents - .iter() - .filter(|a| matches!(a.source, DefinitionSource::ProjectCodex)) - .collect(); - let user_agents: Vec<_> = agents - .iter() - .filter(|a| matches!(a.source, DefinitionSource::UserCodex)) - .collect(); - - let unique_count = agents - .iter() - .filter(|a| !a.name.starts_with("(shadowed")) - .count(); - lines.push(format!("{} active agents", unique_count)); - - if !project_agents.is_empty() { - lines.push("Project (.codex):".to_string()); - for agent in project_agents { - lines.push(format!( - "{} · {} · {} · {}", - agent.name, agent.description, agent.model, agent.temperature - )); - } - } - - if !user_agents.is_empty() { - lines.push("User (~/.codex):".to_string()); - for agent in user_agents { - lines.push(format!( - "{} · {} · {} · {}", - agent.name, agent.description, agent.model, agent.temperature - )); - } - } - - lines.join("\n") -} - -pub fn render_skills_report(skills: &[SkillDef]) -> String { - let mut lines = vec!["Skills".to_string()]; - let project_skills: Vec<_> = skills - .iter() - .filter(|s| matches!(s.source, DefinitionSource::ProjectCodex)) - .collect(); - let user_skills: Vec<_> = skills - .iter() - .filter(|s| matches!(s.source, DefinitionSource::UserCodex)) - .collect(); - - let unique_count = skills - .iter() - .filter(|s| !s.name.starts_with("(shadowed")) - .count(); - lines.push(format!("{} available skills", unique_count)); - - if !project_skills.is_empty() { - lines.push("Project (.codex):".to_string()); - for skill in project_skills { - lines.push(format!("{} · {}", skill.name, skill.description)); - } - } - - if !user_skills.is_empty() { - lines.push("User (~/.codex):".to_string()); - for skill in user_skills { - lines.push(format!("{} · {}", skill.name, skill.description)); - } - } - - lines.join("\n") -} - #[cfg(test)] mod tests { use super::{ handle_plugins_slash_command, handle_slash_command, load_agents_from_roots, load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report, render_slash_command_help, resume_supported_slash_commands, slash_command_specs, - DefinitionSource, SlashCommand, + DefinitionSource, SkillOrigin, SkillRoot, SlashCommand, }; use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary}; use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session}; @@ -849,25 +1245,41 @@ mod tests { fs::write( root.join(".claude-plugin").join("plugin.json"), format!( - "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"bundled commands plugin\",\n \"defaultEnabled\": {}}}", + "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"bundled commands plugin\",\n \"defaultEnabled\": {}\n}}", if default_enabled { "true" } else { "false" } ), ) .expect("write bundled manifest"); } - fn write_agent(dir: &Path, name: &str, description: &str, model: &str, temperature: &str) { - fs::create_dir_all(dir).expect("agent dir"); - let json = format!( - "{{\n \"name\": \"{name}\",\n \"description\": \"{description}\",\n \"model\": \"{model}\",\n \"temperature\": \"{temperature}\"\n}}" - ); - fs::write(dir.join(format!("{name}.json")), json).expect("write agent"); + fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) { + fs::create_dir_all(root).expect("agent root"); + fs::write( + root.join(format!("{name}.toml")), + format!( + "name = \"{name}\"\ndescription = \"{description}\"\nmodel = \"{model}\"\nmodel_reasoning_effort = \"{reasoning}\"\n" + ), + ) + .expect("write agent"); } - fn write_skill(dir: &Path, name: &str, description: &str) { - fs::create_dir_all(dir).expect("skill dir"); - let content = format!("{description}\n\nSkill content here.\n"); - fs::write(dir.join(format!("{name}.md")), content).expect("write skill"); + fn write_skill(root: &Path, name: &str, description: &str) { + let skill_root = root.join(name); + fs::create_dir_all(&skill_root).expect("skill root"); + fs::write( + skill_root.join("SKILL.md"), + format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"), + ) + .expect("write skill"); + } + + fn write_legacy_command(root: &Path, name: &str, description: &str) { + fs::create_dir_all(root).expect("commands root"); + fs::write( + root.join(format!("{name}.md")), + format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"), + ) + .expect("write command"); } #[allow(clippy::too_many_lines)] @@ -1025,10 +1437,13 @@ mod tests { assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch ]")); assert!(help.contains( - "/plugins [list|install |enable |disable |uninstall |update ]" + "/plugin [list|install |enable |disable |uninstall |update ]" )); - assert_eq!(slash_command_specs().len(), 23); - assert_eq!(resume_supported_slash_commands().len(), 11); + assert!(help.contains("aliases: /plugins, /marketplace")); + assert!(help.contains("/agents")); + assert!(help.contains("/skills")); + assert_eq!(slash_command_specs().len(), 25); + assert_eq!(resume_supported_slash_commands().len(), 13); } #[test] @@ -1221,24 +1636,41 @@ mod tests { fn lists_skills_from_project_and_user_roots() { let workspace = temp_dir("skills-workspace"); let project_skills = workspace.join(".codex").join("skills"); + let project_commands = workspace.join(".claude").join("commands"); let user_home = temp_dir("skills-home"); let user_skills = user_home.join(".codex").join("skills"); write_skill(&project_skills, "plan", "Project planning guidance"); + write_legacy_command(&project_commands, "deploy", "Legacy deployment guidance"); write_skill(&user_skills, "plan", "User planning guidance"); write_skill(&user_skills, "help", "Help guidance"); let roots = vec![ - (DefinitionSource::ProjectCodex, project_skills), - (DefinitionSource::UserCodex, user_skills), + SkillRoot { + source: DefinitionSource::ProjectCodex, + path: project_skills, + origin: SkillOrigin::SkillsDir, + }, + SkillRoot { + source: DefinitionSource::ProjectClaude, + path: project_commands, + origin: SkillOrigin::LegacyCommandsDir, + }, + SkillRoot { + source: DefinitionSource::UserCodex, + path: user_skills, + origin: SkillOrigin::SkillsDir, + }, ]; let report = render_skills_report(&load_skills_from_roots(&roots).expect("skill roots should load")); assert!(report.contains("Skills")); - assert!(report.contains("2 available skills")); + assert!(report.contains("3 available skills")); assert!(report.contains("Project (.codex):")); assert!(report.contains("plan · Project planning guidance")); + assert!(report.contains("Project (.claude):")); + assert!(report.contains("deploy · Legacy deployment guidance · legacy /commands")); assert!(report.contains("User (~/.codex):")); assert!(report.contains("(shadowed by Project (.codex)) plan · User planning guidance")); assert!(report.contains("help · Help guidance")); @@ -1247,6 +1679,39 @@ mod tests { let _ = fs::remove_dir_all(user_home); } + #[test] + fn agents_and_skills_usage_support_help_and_unexpected_args() { + let cwd = temp_dir("slash-usage"); + + let agents_help = + super::handle_agents_slash_command(Some("help"), &cwd).expect("agents help"); + assert!(agents_help.contains("Usage /agents")); + assert!(agents_help.contains("Direct CLI claw agents")); + + let agents_unexpected = + super::handle_agents_slash_command(Some("show planner"), &cwd).expect("agents usage"); + assert!(agents_unexpected.contains("Unexpected show planner")); + + let skills_help = + super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help"); + assert!(skills_help.contains("Usage /skills")); + assert!(skills_help.contains("legacy /commands")); + + let skills_unexpected = + super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage"); + assert!(skills_unexpected.contains("Unexpected show help")); + + let _ = fs::remove_dir_all(cwd); + } + + #[test] + fn parses_quoted_skill_frontmatter_values() { + let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n"; + let (name, description) = super::parse_skill_frontmatter(contents); + assert_eq!(name.as_deref(), Some("hud")); + assert_eq!(description.as_deref(), Some("Quoted description")); + } + #[test] fn installs_plugin_from_path_and_lists_it() { let config_home = temp_dir("home"); diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs index 0a54d76..8ab819c 100644 --- a/rust/crates/plugins/src/lib.rs +++ b/rust/crates/plugins/src/lib.rs @@ -2578,6 +2578,9 @@ mod tests { }, ); manager.store_registry(®istry).expect("store registry"); + manager + .write_enabled_state("stale@bundled", Some(true)) + .expect("seed bundled enabled state"); let installed = manager .list_installed_plugins() @@ -2635,6 +2638,9 @@ mod tests { }, ); manager.store_registry(®istry).expect("store registry"); + manager + .write_enabled_state("stale-external@external", Some(true)) + .expect("seed stale external enabled state"); let installed = manager .list_installed_plugins() diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 9452cba..65aff8d 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -2,27 +2,32 @@ mod init; mod input; mod render; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use std::env; +use std::fmt::Write as _; use std::fs; use std::io::{self, Read, Write}; use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use api::{ - detect_provider_kind, max_tokens_for_model, resolve_model_alias, resolve_startup_auth_source, - ApiClient as ApiHttpClient, AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, - MessageRequest, MessageResponse, OutputContentBlock, ProviderClient, ProviderKind, + resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock, + InputMessage, MessageRequest, MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, }; use commands::{ + handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command, render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand, }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; +use plugins::{PluginManager, PluginManagerConfig}; use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use runtime::{ clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, @@ -33,14 +38,22 @@ use runtime::{ Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, }; use serde_json::json; -use tools::{execute_tool, mvp_tool_specs, ToolSpec}; +use tools::GlobalToolRegistry; const DEFAULT_MODEL: &str = "claude-opus-4-6"; +fn max_tokens_for_model(model: &str) -> u32 { + if model.contains("opus") { + 32_000 + } else { + 64_000 + } +} const DEFAULT_DATE: &str = "2026-03-31"; const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545; const VERSION: &str = env!("CARGO_PKG_VERSION"); const BUILD_TARGET: Option<&str> = option_env!("TARGET"); const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); +const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3); type AllowedToolSet = BTreeSet; @@ -60,6 +73,8 @@ fn run() -> Result<(), Box> { match parse_args(&args)? { CliAction::DumpManifests => dump_manifests(), CliAction::BootstrapPlan => print_bootstrap_plan(), + CliAction::Agents { args } => LiveCli::print_agents(args.as_deref())?, + CliAction::Skills { args } => LiveCli::print_skills(args.as_deref())?, CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date), CliAction::Version => print_version(), CliAction::ResumeSession { @@ -91,6 +106,12 @@ fn run() -> Result<(), Box> { enum CliAction { DumpManifests, BootstrapPlan, + Agents { + args: Option, + }, + Skills { + args: Option, + }, PrintSystemPrompt { cwd: PathBuf, date: String, @@ -254,6 +275,12 @@ fn parse_args(args: &[String]) -> Result { match rest[0].as_str() { "dump-manifests" => Ok(CliAction::DumpManifests), "bootstrap-plan" => Ok(CliAction::BootstrapPlan), + "agents" => Ok(CliAction::Agents { + args: join_optional_args(&rest[1..]), + }), + "skills" => Ok(CliAction::Skills { + args: join_optional_args(&rest[1..]), + }), "system-prompt" => parse_system_prompt_args(&rest[1..]), "login" => Ok(CliAction::Login), "logout" => Ok(CliAction::Logout), @@ -271,63 +298,62 @@ fn parse_args(args: &[String]) -> Result { permission_mode, }) } - other if !other.starts_with('/') => Ok(CliAction::Prompt { + other if other.starts_with('/') => parse_direct_slash_cli_action(&rest), + _other => Ok(CliAction::Prompt { prompt: rest.join(" "), model, output_format, allowed_tools, permission_mode, }), - other => Err(format!("unknown subcommand: {other}")), + } +} + +fn join_optional_args(args: &[String]) -> Option { + let joined = args.join(" "); + let trimmed = joined.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +fn parse_direct_slash_cli_action(rest: &[String]) -> Result { + let raw = rest.join(" "); + match SlashCommand::parse(&raw) { + Some(SlashCommand::Help) => Ok(CliAction::Help), + Some(SlashCommand::Agents { args }) => Ok(CliAction::Agents { args }), + Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }), + Some(command) => Err(format!( + "unsupported direct slash command outside the REPL: {command_name}", + command_name = match command { + SlashCommand::Unknown(name) => format!("/{name}"), + _ => rest[0].clone(), + } + )), + None => Err(format!("unknown subcommand: {}", rest[0])), + } +} + +fn resolve_model_alias(model: &str) -> &str { + match model { + "opus" => "claude-opus-4-6", + "sonnet" => "claude-sonnet-4-6", + "haiku" => "claude-haiku-4-5-20251213", + _ => model, } } fn normalize_allowed_tools(values: &[String]) -> Result, String> { - if values.is_empty() { - return Ok(None); - } - - let canonical_names = mvp_tool_specs() - .into_iter() - .map(|spec| spec.name.to_string()) - .collect::>(); - let mut name_map = canonical_names - .iter() - .map(|name| (normalize_tool_name(name), name.clone())) - .collect::>(); - - for (alias, canonical) in [ - ("read", "read_file"), - ("write", "write_file"), - ("edit", "edit_file"), - ("glob", "glob_search"), - ("grep", "grep_search"), - ] { - name_map.insert(alias.to_string(), canonical.to_string()); - } - - let mut allowed = AllowedToolSet::new(); - for value in values { - for token in value - .split(|ch: char| ch == ',' || ch.is_whitespace()) - .filter(|token| !token.is_empty()) - { - let normalized = normalize_tool_name(token); - let canonical = name_map.get(&normalized).ok_or_else(|| { - format!( - "unsupported tool in --allowedTools: {token} (expected one of: {})", - canonical_names.join(", ") - ) - })?; - allowed.insert(canonical.clone()); - } - } - - Ok(Some(allowed)) + current_tool_registry()?.normalize_allowed_tools(values) } -fn normalize_tool_name(value: &str) -> String { - value.trim().replace('-', "_").to_ascii_lowercase() +fn current_tool_registry() -> Result { + let cwd = env::current_dir().map_err(|error| error.to_string())?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load().map_err(|error| error.to_string())?; + let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let plugin_tools = plugin_manager + .aggregated_tools() + .map_err(|error| error.to_string())?; + GlobalToolRegistry::with_plugin_tools(plugin_tools) } fn parse_permission_mode_arg(value: &str) -> Result { @@ -357,11 +383,11 @@ fn default_permission_mode() -> PermissionMode { .map_or(PermissionMode::DangerFullAccess, permission_mode_from_label) } -fn filter_tool_specs(allowed_tools: Option<&AllowedToolSet>) -> Vec { - mvp_tool_specs() - .into_iter() - .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name))) - .collect() +fn filter_tool_specs( + tool_registry: &GlobalToolRegistry, + allowed_tools: Option<&AllowedToolSet>, +) -> Vec { + tool_registry.definitions(allowed_tools) } fn parse_system_prompt_args(args: &[String]) -> Result { @@ -484,7 +510,7 @@ fn run_login() -> Result<(), Box> { return Err(io::Error::new(io::ErrorKind::InvalidData, "oauth state mismatch").into()); } - let client = ApiHttpClient::from_auth(AuthSource::None).with_base_url(api::read_base_url()); + let client = AnthropicClient::from_auth(AuthSource::None).with_base_url(api::read_base_url()); let exchange_request = OAuthTokenExchangeRequest::from_config(oauth, code, state, pkce.verifier, redirect_uri); let runtime = tokio::runtime::Runtime::new()?; @@ -898,18 +924,32 @@ fn run_resume_command( )), }) } - SlashCommand::Resume { .. } - | SlashCommand::Bughunter { .. } + SlashCommand::Agents { args } => { + let cwd = env::current_dir()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?), + }) + } + SlashCommand::Skills { args } => { + let cwd = env::current_dir()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?), + }) + } + SlashCommand::Bughunter { .. } | SlashCommand::Commit | SlashCommand::Pr { .. } | SlashCommand::Issue { .. } | SlashCommand::Ultraplan { .. } | SlashCommand::Teleport { .. } | SlashCommand::DebugToolCall - | SlashCommand::Plugins { .. } + | SlashCommand::Resume { .. } | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Session { .. } + | SlashCommand::Plugins { .. } | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), } } @@ -973,7 +1013,7 @@ struct LiveCli { allowed_tools: Option, permission_mode: PermissionMode, system_prompt: Vec, - runtime: ConversationRuntime, + runtime: ConversationRuntime, session: SessionHandle, } @@ -994,6 +1034,7 @@ impl LiveCli { true, allowed_tools.clone(), permission_mode, + None, )?; let cli = Self { model, @@ -1085,6 +1126,7 @@ impl LiveCli { false, self.allowed_tools.clone(), self.permission_mode, + None, )?; let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let summary = runtime.run_turn(input, Some(&mut permission_prompter))?; @@ -1122,6 +1164,34 @@ impl LiveCli { self.print_status(); false } + SlashCommand::Bughunter { scope } => { + self.run_bughunter(scope.as_deref())?; + false + } + SlashCommand::Commit => { + self.run_commit()?; + true + } + SlashCommand::Pr { context } => { + self.run_pr(context.as_deref())?; + false + } + SlashCommand::Issue { context } => { + self.run_issue(context.as_deref())?; + false + } + SlashCommand::Ultraplan { task } => { + self.run_ultraplan(task.as_deref())?; + false + } + SlashCommand::Teleport { target } => { + self.run_teleport(target.as_deref())?; + false + } + SlashCommand::DebugToolCall => { + self.run_debug_tool_call()?; + false + } SlashCommand::Compact => { self.compact()?; false @@ -1161,15 +1231,15 @@ impl LiveCli { SlashCommand::Session { action, target } => { self.handle_session_command(action.as_deref(), target.as_deref())? } - SlashCommand::Bughunter { .. } - | SlashCommand::Commit - | SlashCommand::Pr { .. } - | SlashCommand::Issue { .. } - | SlashCommand::Ultraplan { .. } - | SlashCommand::Teleport { .. } - | SlashCommand::DebugToolCall - | SlashCommand::Plugins { .. } => { - eprintln!("slash command not supported in this REPL yet"); + SlashCommand::Plugins { action, target } => { + self.handle_plugins_command(action.as_deref(), target.as_deref())? + } + SlashCommand::Agents { args } => { + Self::print_agents(args.as_deref())?; + false + } + SlashCommand::Skills { args } => { + Self::print_skills(args.as_deref())?; false } SlashCommand::Unknown(name) => { @@ -1242,6 +1312,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; self.model.clone_from(&model); println!( @@ -1285,6 +1356,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; println!( "{}", @@ -1310,6 +1382,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; println!( "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}", @@ -1345,6 +1418,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; self.session = handle; println!( @@ -1368,6 +1442,18 @@ impl LiveCli { Ok(()) } + fn print_agents(args: Option<&str>) -> Result<(), Box> { + let cwd = env::current_dir()?; + println!("{}", handle_agents_slash_command(args, &cwd)?); + Ok(()) + } + + fn print_skills(args: Option<&str>) -> Result<(), Box> { + let cwd = env::current_dir()?; + println!("{}", handle_skills_slash_command(args, &cwd)?); + Ok(()) + } + fn print_diff() -> Result<(), Box> { println!("{}", render_diff_report()?); Ok(()) @@ -1417,6 +1503,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; self.session = handle; println!( @@ -1434,6 +1521,37 @@ impl LiveCli { } } + fn handle_plugins_command( + &mut self, + action: Option<&str>, + target: Option<&str>, + ) -> Result> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let result = handle_plugins_slash_command(action, target, &mut manager)?; + println!("{}", result.message); + if result.reload_runtime { + self.reload_runtime_features()?; + } + Ok(false) + } + + fn reload_runtime_features(&mut self) -> Result<(), Box> { + self.runtime = build_runtime( + self.runtime.session().clone(), + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.persist_session() + } + fn compact(&mut self) -> Result<(), Box> { let result = self.runtime.compact(CompactionConfig::default()); let removed = result.removed_message_count; @@ -1447,11 +1565,188 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; self.persist_session()?; println!("{}", format_compact_report(removed, kept, skipped)); Ok(()) } + + fn run_internal_prompt_text_with_progress( + &self, + prompt: &str, + enable_tools: bool, + progress: Option, + ) -> Result> { + let session = self.runtime.session().clone(); + let mut runtime = build_runtime( + session, + self.model.clone(), + self.system_prompt.clone(), + enable_tools, + false, + self.allowed_tools.clone(), + self.permission_mode, + progress, + )?; + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?; + Ok(final_assistant_text(&summary).trim().to_string()) + } + + fn run_internal_prompt_text( + &self, + prompt: &str, + enable_tools: bool, + ) -> Result> { + self.run_internal_prompt_text_with_progress(prompt, enable_tools, None) + } + + fn run_bughunter(&self, scope: Option<&str>) -> Result<(), Box> { + let scope = scope.unwrap_or("the current repository"); + let prompt = format!( + "You are /bughunter. Inspect {scope} and identify the most likely bugs or correctness issues. Prioritize concrete findings with file paths, severity, and suggested fixes. Use tools if needed." + ); + println!("{}", self.run_internal_prompt_text(&prompt, true)?); + Ok(()) + } + + fn run_ultraplan(&self, task: Option<&str>) -> Result<(), Box> { + let task = task.unwrap_or("the current repo work"); + let prompt = format!( + "You are /ultraplan. Produce a deep multi-step execution plan for {task}. Include goals, risks, implementation sequence, verification steps, and rollback considerations. Use tools if needed." + ); + let mut progress = InternalPromptProgressRun::start_ultraplan(task); + match self.run_internal_prompt_text_with_progress(&prompt, true, Some(progress.reporter())) + { + Ok(plan) => { + progress.finish_success(); + println!("{plan}"); + Ok(()) + } + Err(error) => { + progress.finish_failure(&error.to_string()); + Err(error) + } + } + } + + #[allow(clippy::unused_self)] + fn run_teleport(&self, target: Option<&str>) -> Result<(), Box> { + let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else { + println!("Usage: /teleport "); + return Ok(()); + }; + + println!("{}", render_teleport_report(target)?); + Ok(()) + } + + fn run_debug_tool_call(&self) -> Result<(), Box> { + println!("{}", render_last_tool_debug_report(self.runtime.session())?); + Ok(()) + } + + fn run_commit(&mut self) -> Result<(), Box> { + let status = git_output(&["status", "--short"])?; + if status.trim().is_empty() { + println!("Commit\n Result skipped\n Reason no workspace changes"); + return Ok(()); + } + + git_status_ok(&["add", "-A"])?; + let staged_stat = git_output(&["diff", "--cached", "--stat"])?; + let prompt = format!( + "Generate a git commit message in plain text Lore format only. Base it on this staged diff summary:\n\n{}\n\nRecent conversation context:\n{}", + truncate_for_prompt(&staged_stat, 8_000), + recent_user_context(self.runtime.session(), 6) + ); + let message = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?); + if message.trim().is_empty() { + return Err("generated commit message was empty".into()); + } + + let path = write_temp_text_file("claw-commit-message.txt", &message)?; + let output = Command::new("git") + .args(["commit", "--file"]) + .arg(&path) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git commit failed: {stderr}").into()); + } + + println!( + "Commit\n Result created\n Message file {}\n\n{}", + path.display(), + message.trim() + ); + Ok(()) + } + + fn run_pr(&self, context: Option<&str>) -> Result<(), Box> { + let staged = git_output(&["diff", "--stat"])?; + let prompt = format!( + "Generate a pull request title and body from this conversation and diff summary. Output plain text in this format exactly:\nTITLE: \nBODY:\n<body markdown>\n\nContext hint: {}\n\nDiff summary:\n{}", + context.unwrap_or("none"), + truncate_for_prompt(&staged, 10_000) + ); + let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?); + let (title, body) = parse_titled_body(&draft) + .ok_or_else(|| "failed to parse generated PR title/body".to_string())?; + + if command_exists("gh") { + let body_path = write_temp_text_file("claw-pr-body.md", &body)?; + let output = Command::new("gh") + .args(["pr", "create", "--title", &title, "--body-file"]) + .arg(&body_path) + .current_dir(env::current_dir()?) + .output()?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + println!( + "PR\n Result created\n Title {title}\n URL {}", + if stdout.is_empty() { "<unknown>" } else { &stdout } + ); + return Ok(()); + } + } + + println!("PR draft\n Title {title}\n\n{body}"); + Ok(()) + } + + fn run_issue(&self, context: Option<&str>) -> Result<(), Box<dyn std::error::Error>> { + let prompt = format!( + "Generate a GitHub issue title and body from this conversation. Output plain text in this format exactly:\nTITLE: <title>\nBODY:\n<body markdown>\n\nContext hint: {}\n\nConversation context:\n{}", + context.unwrap_or("none"), + truncate_for_prompt(&recent_user_context(self.runtime.session(), 10), 10_000) + ); + let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?); + let (title, body) = parse_titled_body(&draft) + .ok_or_else(|| "failed to parse generated issue title/body".to_string())?; + + if command_exists("gh") { + let body_path = write_temp_text_file("claw-issue-body.md", &body)?; + let output = Command::new("gh") + .args(["issue", "create", "--title", &title, "--body-file"]) + .arg(&body_path) + .current_dir(env::current_dir()?) + .output()?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + println!( + "Issue\n Result created\n Title {title}\n URL {}", + if stdout.is_empty() { "<unknown>" } else { &stdout } + ); + return Ok(()); + } + } + + println!("Issue draft\n Title {title}\n\n{body}"); + Ok(()) + } } fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> { @@ -1695,9 +1990,12 @@ fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::er "env" => runtime_config.get("env"), "hooks" => runtime_config.get("hooks"), "model" => runtime_config.get("model"), + "plugins" => runtime_config + .get("plugins") + .or_else(|| runtime_config.get("enabledPlugins")), other => { lines.push(format!( - " Unsupported config section '{other}'. Use env, hooks, or model." + " Unsupported config section '{other}'. Use env, hooks, model, or plugins." )); return Ok(lines.join( " @@ -1803,6 +2101,206 @@ fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> { Ok(format!("Diff\n\n{}", diff.trim_end())) } +fn render_teleport_report(target: &str) -> Result<String, Box<dyn std::error::Error>> { + let cwd = env::current_dir()?; + + let file_list = Command::new("rg") + .args(["--files"]) + .current_dir(&cwd) + .output()?; + let file_matches = if file_list.status.success() { + String::from_utf8(file_list.stdout)? + .lines() + .filter(|line| line.contains(target)) + .take(10) + .map(ToOwned::to_owned) + .collect::<Vec<_>>() + } else { + Vec::new() + }; + + let content_output = Command::new("rg") + .args(["-n", "-S", "--color", "never", target, "."]) + .current_dir(&cwd) + .output()?; + + let mut lines = vec![format!("Teleport\n Target {target}")]; + if !file_matches.is_empty() { + lines.push(String::new()); + lines.push("File matches".to_string()); + lines.extend(file_matches.into_iter().map(|path| format!(" {path}"))); + } + + if content_output.status.success() { + let matches = String::from_utf8(content_output.stdout)?; + if !matches.trim().is_empty() { + lines.push(String::new()); + lines.push("Content matches".to_string()); + lines.push(truncate_for_prompt(&matches, 4_000)); + } + } + + if lines.len() == 1 { + lines.push(" Result no matches found".to_string()); + } + + Ok(lines.join("\n")) +} + +fn render_last_tool_debug_report(session: &Session) -> Result<String, Box<dyn std::error::Error>> { + let last_tool_use = session + .messages + .iter() + .rev() + .find_map(|message| { + message.blocks.iter().rev().find_map(|block| match block { + ContentBlock::ToolUse { id, name, input } => { + Some((id.clone(), name.clone(), input.clone())) + } + _ => None, + }) + }) + .ok_or_else(|| "no prior tool call found in session".to_string())?; + + let tool_result = session.messages.iter().rev().find_map(|message| { + message.blocks.iter().rev().find_map(|block| match block { + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } if tool_use_id == &last_tool_use.0 => { + Some((tool_name.clone(), output.clone(), *is_error)) + } + _ => None, + }) + }); + + let mut lines = vec![ + "Debug tool call".to_string(), + format!(" Tool id {}", last_tool_use.0), + format!(" Tool name {}", last_tool_use.1), + " Input".to_string(), + indent_block(&last_tool_use.2, 4), + ]; + + match tool_result { + Some((tool_name, output, is_error)) => { + lines.push(" Result".to_string()); + lines.push(format!(" name {tool_name}")); + lines.push(format!( + " status {}", + if is_error { "error" } else { "ok" } + )); + lines.push(indent_block(&output, 4)); + } + None => lines.push(" Result missing tool result".to_string()), + } + + Ok(lines.join("\n")) +} + +fn indent_block(value: &str, spaces: usize) -> String { + let indent = " ".repeat(spaces); + value + .lines() + .map(|line| format!("{indent}{line}")) + .collect::<Vec<_>>() + .join("\n") +} + +fn git_output(args: &[&str]) -> Result<String, Box<dyn std::error::Error>> { + let output = Command::new("git") + .args(args) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); + } + Ok(String::from_utf8(output.stdout)?) +} + +fn git_status_ok(args: &[&str]) -> Result<(), Box<dyn std::error::Error>> { + let output = Command::new("git") + .args(args) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); + } + Ok(()) +} + +fn command_exists(name: &str) -> bool { + Command::new("which") + .arg(name) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +fn write_temp_text_file( + filename: &str, + contents: &str, +) -> Result<PathBuf, Box<dyn std::error::Error>> { + let path = env::temp_dir().join(filename); + fs::write(&path, contents)?; + Ok(path) +} + +fn recent_user_context(session: &Session, limit: usize) -> String { + let requests = session + .messages + .iter() + .filter(|message| message.role == MessageRole::User) + .filter_map(|message| { + message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(text.trim().to_string()), + _ => None, + }) + }) + .rev() + .take(limit) + .collect::<Vec<_>>(); + + if requests.is_empty() { + "<no prior user messages>".to_string() + } else { + requests + .into_iter() + .rev() + .enumerate() + .map(|(index, text)| format!("{}. {}", index + 1, text)) + .collect::<Vec<_>>() + .join("\n") + } +} + +fn truncate_for_prompt(value: &str, limit: usize) -> String { + if value.chars().count() <= limit { + value.trim().to_string() + } else { + let truncated = value.chars().take(limit).collect::<String>(); + format!("{}\n…[truncated]", truncated.trim_end()) + } +} + +fn sanitize_generated_message(value: &str) -> String { + value.trim().trim_matches('`').trim().replace("\r\n", "\n") +} + +fn parse_titled_body(value: &str) -> Option<(String, String)> { + let normalized = sanitize_generated_message(value); + let title = normalized + .lines() + .find_map(|line| line.strip_prefix("TITLE:").map(str::trim))?; + let body_start = normalized.find("BODY:")?; + let body = normalized[body_start + "BODY:".len()..].trim(); + Some((title.to_string(), body.to_string())) +} + fn render_version_report() -> String { let git_sha = GIT_SHA.unwrap_or("unknown"); let target = BUILD_TARGET.unwrap_or("unknown"); @@ -1907,15 +2405,382 @@ fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> { )?) } -fn build_runtime_feature_config( -) -> Result<runtime::RuntimeFeatureConfig, Box<dyn std::error::Error>> { +fn build_runtime_plugin_state( +) -> Result<(runtime::RuntimeFeatureConfig, GlobalToolRegistry), Box<dyn std::error::Error>> { let cwd = env::current_dir()?; - Ok(ConfigLoader::default_for(cwd) - .load()? - .feature_config() - .clone()) + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_manager.aggregated_tools()?)?; + Ok((runtime_config.feature_config().clone(), tool_registry)) } +fn build_plugin_manager( + cwd: &Path, + loader: &ConfigLoader, + runtime_config: &runtime::RuntimeConfig, +) -> PluginManager { + let plugin_settings = runtime_config.plugins(); + let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf()); + plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone(); + plugin_config.external_dirs = plugin_settings + .external_directories() + .iter() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)) + .collect(); + plugin_config.install_root = plugin_settings + .install_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.registry_path = plugin_settings + .registry_path() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.bundled_root = plugin_settings + .bundled_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + PluginManager::new(plugin_config) +} + +fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { + let path = PathBuf::from(value); + if path.is_absolute() { + path + } else if value.starts_with('.') { + cwd.join(path) + } else { + config_home.join(path) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InternalPromptProgressState { + command_label: &'static str, + task_label: String, + step: usize, + phase: String, + detail: Option<String>, + saw_final_text: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InternalPromptProgressEvent { + Started, + Update, + Heartbeat, + Complete, + Failed, +} + +#[derive(Debug)] +struct InternalPromptProgressShared { + state: Mutex<InternalPromptProgressState>, + output_lock: Mutex<()>, + started_at: Instant, +} + +#[derive(Debug, Clone)] +struct InternalPromptProgressReporter { + shared: Arc<InternalPromptProgressShared>, +} + +#[derive(Debug)] +struct InternalPromptProgressRun { + reporter: InternalPromptProgressReporter, + heartbeat_stop: Option<mpsc::Sender<()>>, + heartbeat_handle: Option<thread::JoinHandle<()>>, +} + +impl InternalPromptProgressReporter { + fn ultraplan(task: &str) -> Self { + Self { + shared: Arc::new(InternalPromptProgressShared { + state: Mutex::new(InternalPromptProgressState { + command_label: "Ultraplan", + task_label: task.to_string(), + step: 0, + phase: "planning started".to_string(), + detail: Some(format!("task: {task}")), + saw_final_text: false, + }), + output_lock: Mutex::new(()), + started_at: Instant::now(), + }), + } + } + + fn emit(&self, event: InternalPromptProgressEvent, error: Option<&str>) { + let snapshot = self.snapshot(); + let line = format_internal_prompt_progress_line(event, &snapshot, self.elapsed(), error); + self.write_line(&line); + } + + fn mark_model_phase(&self) { + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + state.step += 1; + state.phase = if state.step == 1 { + "analyzing request".to_string() + } else { + "reviewing findings".to_string() + }; + state.detail = Some(format!("task: {}", state.task_label)); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + fn mark_tool_phase(&self, name: &str, input: &str) { + let detail = describe_tool_progress(name, input); + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + state.step += 1; + state.phase = format!("running {name}"); + state.detail = Some(detail); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + fn mark_text_phase(&self, text: &str) { + let trimmed = text.trim(); + if trimmed.is_empty() { + return; + } + let detail = truncate_for_summary(first_visible_line(trimmed), 120); + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + if state.saw_final_text { + return; + } + state.saw_final_text = true; + state.step += 1; + state.phase = "drafting final plan".to_string(); + state.detail = (!detail.is_empty()).then_some(detail); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + fn emit_heartbeat(&self) { + let snapshot = self.snapshot(); + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Heartbeat, + &snapshot, + self.elapsed(), + None, + )); + } + + fn snapshot(&self) -> InternalPromptProgressState { + self.shared + .state + .lock() + .expect("internal prompt progress state poisoned") + .clone() + } + + fn elapsed(&self) -> Duration { + self.shared.started_at.elapsed() + } + + fn write_line(&self, line: &str) { + let _guard = self + .shared + .output_lock + .lock() + .expect("internal prompt progress output lock poisoned"); + let mut stdout = io::stdout(); + let _ = writeln!(stdout, "{line}"); + let _ = stdout.flush(); + } +} + +impl InternalPromptProgressRun { + fn start_ultraplan(task: &str) -> Self { + let reporter = InternalPromptProgressReporter::ultraplan(task); + reporter.emit(InternalPromptProgressEvent::Started, None); + + let (heartbeat_stop, heartbeat_rx) = mpsc::channel(); + let heartbeat_reporter = reporter.clone(); + let heartbeat_handle = thread::spawn(move || loop { + match heartbeat_rx.recv_timeout(INTERNAL_PROGRESS_HEARTBEAT_INTERVAL) { + Ok(()) | Err(RecvTimeoutError::Disconnected) => break, + Err(RecvTimeoutError::Timeout) => heartbeat_reporter.emit_heartbeat(), + } + }); + + Self { + reporter, + heartbeat_stop: Some(heartbeat_stop), + heartbeat_handle: Some(heartbeat_handle), + } + } + + fn reporter(&self) -> InternalPromptProgressReporter { + self.reporter.clone() + } + + fn finish_success(&mut self) { + self.stop_heartbeat(); + self.reporter + .emit(InternalPromptProgressEvent::Complete, None); + } + + fn finish_failure(&mut self, error: &str) { + self.stop_heartbeat(); + self.reporter + .emit(InternalPromptProgressEvent::Failed, Some(error)); + } + + fn stop_heartbeat(&mut self) { + if let Some(sender) = self.heartbeat_stop.take() { + let _ = sender.send(()); + } + if let Some(handle) = self.heartbeat_handle.take() { + let _ = handle.join(); + } + } +} + +impl Drop for InternalPromptProgressRun { + fn drop(&mut self) { + self.stop_heartbeat(); + } +} + +fn format_internal_prompt_progress_line( + event: InternalPromptProgressEvent, + snapshot: &InternalPromptProgressState, + elapsed: Duration, + error: Option<&str>, +) -> String { + let elapsed_seconds = elapsed.as_secs(); + let step_label = if snapshot.step == 0 { + "current step pending".to_string() + } else { + format!("current step {}", snapshot.step) + }; + let mut status_bits = vec![step_label, format!("phase {}", snapshot.phase)]; + if let Some(detail) = snapshot + .detail + .as_deref() + .filter(|detail| !detail.is_empty()) + { + status_bits.push(detail.to_string()); + } + let status = status_bits.join(" · "); + match event { + InternalPromptProgressEvent::Started => { + format!( + "🧭 {} status · planning started · {status}", + snapshot.command_label + ) + } + InternalPromptProgressEvent::Update => { + format!("… {} status · {status}", snapshot.command_label) + } + InternalPromptProgressEvent::Heartbeat => format!( + "… {} heartbeat · {elapsed_seconds}s elapsed · {status}", + snapshot.command_label + ), + InternalPromptProgressEvent::Complete => format!( + "✔ {} status · completed · {elapsed_seconds}s elapsed · {} steps total", + snapshot.command_label, snapshot.step + ), + InternalPromptProgressEvent::Failed => format!( + "✘ {} status · failed · {elapsed_seconds}s elapsed · {}", + snapshot.command_label, + error.unwrap_or("unknown error") + ), + } +} + +fn describe_tool_progress(name: &str, input: &str) -> String { + let parsed: serde_json::Value = + serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); + match name { + "bash" | "Bash" => { + let command = parsed + .get("command") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if command.is_empty() { + "running shell command".to_string() + } else { + format!("command {}", truncate_for_summary(command.trim(), 100)) + } + } + "read_file" | "Read" => format!("reading {}", extract_tool_path(&parsed)), + "write_file" | "Write" => format!("writing {}", extract_tool_path(&parsed)), + "edit_file" | "Edit" => format!("editing {}", extract_tool_path(&parsed)), + "glob_search" | "Glob" => { + let pattern = parsed + .get("pattern") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + let scope = parsed + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("."); + format!("glob `{pattern}` in {scope}") + } + "grep_search" | "Grep" => { + let pattern = parsed + .get("pattern") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + let scope = parsed + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("."); + format!("grep `{pattern}` in {scope}") + } + "web_search" | "WebSearch" => parsed + .get("query") + .and_then(|value| value.as_str()) + .map_or_else( + || "running web search".to_string(), + |query| format!("query {}", truncate_for_summary(query, 100)), + ), + _ => { + let summary = summarize_tool_payload(input); + if summary.is_empty() { + format!("running {name}") + } else { + format!("{name}: {summary}") + } + } + } +} + +#[allow(clippy::needless_pass_by_value)] +#[allow(clippy::too_many_arguments)] fn build_runtime( session: Session, model: String, @@ -1924,14 +2789,21 @@ fn build_runtime( emit_output: bool, allowed_tools: Option<AllowedToolSet>, permission_mode: PermissionMode, -) -> Result<ConversationRuntime<ProviderRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> -{ - let feature_config = build_runtime_feature_config()?; + progress_reporter: Option<InternalPromptProgressReporter>, +) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> { + let (feature_config, tool_registry) = build_runtime_plugin_state()?; Ok(ConversationRuntime::new_with_features( session, - ProviderRuntimeClient::new(model, enable_tools, emit_output, allowed_tools.clone())?, - CliToolExecutor::new(allowed_tools, emit_output), - permission_policy(permission_mode), + AnthropicRuntimeClient::new( + model, + enable_tools, + emit_output, + allowed_tools.clone(), + tool_registry.clone(), + progress_reporter, + )?, + CliToolExecutor::new(allowed_tools.clone(), emit_output, tool_registry.clone()), + permission_policy(permission_mode, &tool_registry), system_prompt, feature_config, )) @@ -1983,37 +2855,36 @@ impl runtime::PermissionPrompter for CliPermissionPrompter { } } -struct ProviderRuntimeClient { +struct AnthropicRuntimeClient { runtime: tokio::runtime::Runtime, - client: ProviderClient, + client: AnthropicClient, model: String, enable_tools: bool, emit_output: bool, allowed_tools: Option<AllowedToolSet>, + tool_registry: GlobalToolRegistry, + progress_reporter: Option<InternalPromptProgressReporter>, } -impl ProviderRuntimeClient { +impl AnthropicRuntimeClient { fn new( model: String, enable_tools: bool, emit_output: bool, allowed_tools: Option<AllowedToolSet>, + tool_registry: GlobalToolRegistry, + progress_reporter: Option<InternalPromptProgressReporter>, ) -> Result<Self, Box<dyn std::error::Error>> { - let model = resolve_model_alias(&model).to_string(); - let client = match detect_provider_kind(&model) { - ProviderKind::Anthropic => ProviderClient::from_model_with_anthropic_auth( - &model, - Some(resolve_cli_auth_source()?), - )?, - ProviderKind::Xai | ProviderKind::OpenAi => ProviderClient::from_model(&model)?, - }; Ok(Self { runtime: tokio::runtime::Runtime::new()?, - client, + client: AnthropicClient::from_auth(resolve_cli_auth_source()?) + .with_base_url(api::read_base_url()), model, enable_tools, emit_output, allowed_tools, + tool_registry, + progress_reporter, }) } } @@ -2028,24 +2899,20 @@ fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> { })?) } -impl ApiClient for ProviderRuntimeClient { +impl ApiClient for AnthropicRuntimeClient { #[allow(clippy::too_many_lines)] fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_model_phase(); + } let message_request = MessageRequest { model: self.model.clone(), max_tokens: max_tokens_for_model(&self.model), messages: convert_messages(&request.messages), system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")), - tools: self.enable_tools.then(|| { - filter_tool_specs(self.allowed_tools.as_ref()) - .into_iter() - .map(|spec| ToolDefinition { - name: spec.name.to_string(), - description: Some(spec.description.to_string()), - input_schema: spec.input_schema, - }) - .collect() - }), + tools: self + .enable_tools + .then(|| filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())), tool_choice: self.enable_tools.then_some(ToolChoice::Auto), stream: true, }; @@ -2066,7 +2933,7 @@ impl ApiClient for ProviderRuntimeClient { let renderer = TerminalRenderer::new(); let mut markdown_stream = MarkdownStreamState::default(); let mut events = Vec::new(); - let mut pending_tools: BTreeMap<u32, (String, String, String)> = BTreeMap::new(); + let mut pending_tool: Option<(String, String, String)> = None; let mut saw_stop = false; while let Some(event) = stream @@ -2077,29 +2944,24 @@ impl ApiClient for ProviderRuntimeClient { match event { ApiStreamEvent::MessageStart(start) => { for block in start.message.content { - push_output_block( - block, - 0, - out, - &mut events, - &mut pending_tools, - true, - )?; + push_output_block(block, out, &mut events, &mut pending_tool, true)?; } } ApiStreamEvent::ContentBlockStart(start) => { push_output_block( start.content_block, - start.index, out, &mut events, - &mut pending_tools, + &mut pending_tool, true, )?; } ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { ContentBlockDelta::TextDelta { text } => { if !text.is_empty() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_text_phase(&text); + } if let Some(rendered) = markdown_stream.push(&renderer, &text) { write!(out, "{rendered}") .and_then(|()| out.flush()) @@ -2109,20 +2971,23 @@ impl ApiClient for ProviderRuntimeClient { } } ContentBlockDelta::InputJsonDelta { partial_json } => { - if let Some((_, _, input)) = pending_tools.get_mut(&delta.index) { + if let Some((_, _, input)) = &mut pending_tool { input.push_str(&partial_json); } } ContentBlockDelta::ThinkingDelta { .. } | ContentBlockDelta::SignatureDelta { .. } => {} }, - ApiStreamEvent::ContentBlockStop(stop) => { + ApiStreamEvent::ContentBlockStop(_) => { if let Some(rendered) = markdown_stream.flush(&renderer) { write!(out, "{rendered}") .and_then(|()| out.flush()) .map_err(|error| RuntimeError::new(error.to_string()))?; } - if let Some((id, name, input)) = pending_tools.remove(&stop.index) { + if let Some((id, name, input)) = pending_tool.take() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_tool_phase(&name, &input); + } // Display tool call now that input is fully accumulated writeln!(out, "\n{}", format_tool_call_start(&name, &input)) .and_then(|()| out.flush()) @@ -2238,7 +3103,12 @@ fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec<serde_json::Value fn slash_command_completion_candidates() -> Vec<String> { slash_command_specs() .iter() - .map(|spec| format!("/{}", spec.name)) + .flat_map(|spec| { + std::iter::once(spec.name) + .chain(spec.aliases.iter().copied()) + .map(|name| format!("/{name}")) + .collect::<Vec<_>>() + }) .collect() } @@ -2319,13 +3189,17 @@ fn format_tool_result(name: &str, output: &str, is_error: bool) -> String { "edit_file" | "Edit" => format_edit_result(icon, &parsed), "glob_search" | "Glob" => format_glob_result(icon, &parsed), "grep_search" | "Grep" => format_grep_result(icon, &parsed), - _ => { - let summary = truncate_for_summary(output.trim(), 200); - format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {summary}") - } + _ => format_generic_tool_result(icon, name, &parsed), } } +const DISPLAY_TRUNCATION_NOTICE: &str = + "\x1b[2m… output truncated for display; full result preserved in session.\x1b[0m"; +const READ_DISPLAY_MAX_LINES: usize = 80; +const READ_DISPLAY_MAX_CHARS: usize = 6_000; +const TOOL_OUTPUT_DISPLAY_MAX_LINES: usize = 60; +const TOOL_OUTPUT_DISPLAY_MAX_CHARS: usize = 4_000; + fn extract_tool_path(parsed: &serde_json::Value) -> String { parsed .get("file_path") @@ -2386,23 +3260,34 @@ fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String { .get("backgroundTaskId") .and_then(|value| value.as_str()) { - lines[0].push_str(&format!(" backgrounded ({task_id})")); + write!(&mut lines[0], " backgrounded ({task_id})").expect("write to string"); } else if let Some(status) = parsed .get("returnCodeInterpretation") .and_then(|value| value.as_str()) .filter(|status| !status.is_empty()) { - lines[0].push_str(&format!(" {status}")); + write!(&mut lines[0], " {status}").expect("write to string"); } if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) { if !stdout.trim().is_empty() { - lines.push(stdout.trim_end().to_string()); + lines.push(truncate_output_for_display( + stdout, + TOOL_OUTPUT_DISPLAY_MAX_LINES, + TOOL_OUTPUT_DISPLAY_MAX_CHARS, + )); } } if let Some(stderr) = parsed.get("stderr").and_then(|value| value.as_str()) { if !stderr.trim().is_empty() { - lines.push(format!("\x1b[38;5;203m{}\x1b[0m", stderr.trim_end())); + lines.push(format!( + "\x1b[38;5;203m{}\x1b[0m", + truncate_output_for_display( + stderr, + TOOL_OUTPUT_DISPLAY_MAX_LINES, + TOOL_OUTPUT_DISPLAY_MAX_CHARS, + ) + )); } } @@ -2414,15 +3299,15 @@ fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String { let path = extract_tool_path(file); let start_line = file .get("startLine") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(1); let num_lines = file .get("numLines") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(0); let total_lines = file .get("totalLines") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(num_lines); let content = file .get("content") @@ -2435,7 +3320,7 @@ fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String { start_line, end_line.max(start_line), total_lines, - content + truncate_output_for_display(content, READ_DISPLAY_MAX_LINES, READ_DISPLAY_MAX_CHARS) ) } @@ -2448,8 +3333,7 @@ fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String { let line_count = parsed .get("content") .and_then(|value| value.as_str()) - .map(|content| content.lines().count()) - .unwrap_or(0); + .map_or(0, |content| content.lines().count()); format!( "{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m", if kind == "create" { "Wrote" } else { "Updated" }, @@ -2480,7 +3364,7 @@ fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String { let path = extract_tool_path(parsed); let suffix = if parsed .get("replaceAll") - .and_then(|value| value.as_bool()) + .and_then(serde_json::Value::as_bool) .unwrap_or(false) { " (replace all)" @@ -2508,7 +3392,7 @@ fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String { fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String { let num_files = parsed .get("numFiles") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(0); let filenames = parsed .get("filenames") @@ -2532,11 +3416,11 @@ fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String { fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String { let num_matches = parsed .get("numMatches") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(0); let num_files = parsed .get("numFiles") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(0); let content = parsed .get("content") @@ -2558,7 +3442,14 @@ fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String { "{icon} \x1b[38;5;245mgrep_search\x1b[0m {num_matches} matches across {num_files} files" ); if !content.trim().is_empty() { - format!("{summary}\n{}", content.trim_end()) + format!( + "{summary}\n{}", + truncate_output_for_display( + content, + TOOL_OUTPUT_DISPLAY_MAX_LINES, + TOOL_OUTPUT_DISPLAY_MAX_CHARS, + ) + ) } else if !filenames.is_empty() { format!("{summary}\n{filenames}") } else { @@ -2566,6 +3457,30 @@ fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String { } } +fn format_generic_tool_result(icon: &str, name: &str, parsed: &serde_json::Value) -> String { + let rendered_output = match parsed { + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Null => String::new(), + serde_json::Value::Object(_) | serde_json::Value::Array(_) => { + serde_json::to_string_pretty(parsed).unwrap_or_else(|_| parsed.to_string()) + } + _ => parsed.to_string(), + }; + let preview = truncate_output_for_display( + &rendered_output, + TOOL_OUTPUT_DISPLAY_MAX_LINES, + TOOL_OUTPUT_DISPLAY_MAX_CHARS, + ); + + if preview.is_empty() { + format!("{icon} \x1b[38;5;245m{name}\x1b[0m") + } else if preview.contains('\n') { + format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n{preview}") + } else { + format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {preview}") + } +} + fn summarize_tool_payload(payload: &str) -> String { let compact = match serde_json::from_str::<serde_json::Value>(payload) { Ok(value) => value.to_string(), @@ -2584,12 +3499,55 @@ fn truncate_for_summary(value: &str, limit: usize) -> String { } } +fn truncate_output_for_display(content: &str, max_lines: usize, max_chars: usize) -> String { + let original = content.trim_end_matches('\n'); + if original.is_empty() { + return String::new(); + } + + let mut preview_lines = Vec::new(); + let mut used_chars = 0usize; + let mut truncated = false; + + for (index, line) in original.lines().enumerate() { + if index >= max_lines { + truncated = true; + break; + } + + let newline_cost = usize::from(!preview_lines.is_empty()); + let available = max_chars.saturating_sub(used_chars + newline_cost); + if available == 0 { + truncated = true; + break; + } + + let line_chars = line.chars().count(); + if line_chars > available { + preview_lines.push(line.chars().take(available).collect::<String>()); + truncated = true; + break; + } + + preview_lines.push(line.to_string()); + used_chars += newline_cost + line_chars; + } + + let mut preview = preview_lines.join("\n"); + if truncated { + if !preview.is_empty() { + preview.push('\n'); + } + preview.push_str(DISPLAY_TRUNCATION_NOTICE); + } + preview +} + fn push_output_block( block: OutputContentBlock, - block_index: u32, out: &mut (impl Write + ?Sized), events: &mut Vec<AssistantEvent>, - pending_tools: &mut BTreeMap<u32, (String, String, String)>, + pending_tool: &mut Option<(String, String, String)>, streaming_tool_input: bool, ) -> Result<(), RuntimeError> { match block { @@ -2614,7 +3572,7 @@ fn push_output_block( } else { input.to_string() }; - pending_tools.insert(block_index, (id, name, initial_input)); + *pending_tool = Some((id, name, initial_input)); } OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {} } @@ -2626,13 +3584,11 @@ fn response_to_events( out: &mut (impl Write + ?Sized), ) -> Result<Vec<AssistantEvent>, RuntimeError> { let mut events = Vec::new(); - let mut pending_tools = BTreeMap::new(); + let mut pending_tool = None; - for (index, block) in response.content.into_iter().enumerate() { - let index = - u32::try_from(index).map_err(|_| RuntimeError::new("response block index overflow"))?; - push_output_block(block, index, out, &mut events, &mut pending_tools, false)?; - if let Some((id, name, input)) = pending_tools.remove(&index) { + for block in response.content { + push_output_block(block, out, &mut events, &mut pending_tool, false)?; + if let Some((id, name, input)) = pending_tool.take() { events.push(AssistantEvent::ToolUse { id, name, input }); } } @@ -2651,14 +3607,20 @@ struct CliToolExecutor { renderer: TerminalRenderer, emit_output: bool, allowed_tools: Option<AllowedToolSet>, + tool_registry: GlobalToolRegistry, } impl CliToolExecutor { - fn new(allowed_tools: Option<AllowedToolSet>, emit_output: bool) -> Self { + fn new( + allowed_tools: Option<AllowedToolSet>, + emit_output: bool, + tool_registry: GlobalToolRegistry, + ) -> Self { Self { renderer: TerminalRenderer::new(), emit_output, allowed_tools, + tool_registry, } } } @@ -2676,7 +3638,7 @@ impl ToolExecutor for CliToolExecutor { } let value = serde_json::from_str(input) .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - match execute_tool(tool_name, &value) { + match self.tool_registry.execute(tool_name, &value) { Ok(output) => { if self.emit_output { let markdown = format_tool_result(tool_name, &output, false); @@ -2699,16 +3661,13 @@ impl ToolExecutor for CliToolExecutor { } } -fn permission_policy(mode: PermissionMode) -> PermissionPolicy { - tool_permission_specs() - .into_iter() - .fold(PermissionPolicy::new(mode), |policy, spec| { - policy.with_tool_requirement(spec.name, spec.required_permission) - }) -} - -fn tool_permission_specs() -> Vec<ToolSpec> { - mvp_tool_specs() +fn permission_policy(mode: PermissionMode, tool_registry: &GlobalToolRegistry) -> PermissionPolicy { + tool_registry.permission_specs(None).into_iter().fold( + PermissionPolicy::new(mode), + |policy, (name, required_permission)| { + policy.with_tool_requirement(name, required_permission) + }, + ) } fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> { @@ -2781,6 +3740,8 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { )?; writeln!(out, " claw dump-manifests")?; writeln!(out, " claw bootstrap-plan")?; + writeln!(out, " claw agents")?; + writeln!(out, " claw skills")?; writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?; writeln!(out, " claw login")?; writeln!(out, " claw logout")?; @@ -2835,6 +3796,8 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { out, " claw --resume session.json /status /diff /export notes.txt" )?; + writeln!(out, " claw agents")?; + writeln!(out, " claw /skills")?; writeln!(out, " claw login")?; writeln!(out, " claw init")?; Ok(()) @@ -2847,19 +3810,47 @@ fn print_help() { #[cfg(test)] mod tests { use super::{ - filter_tool_specs, format_compact_report, format_cost_report, format_model_report, - format_model_switch_report, format_permissions_report, format_permissions_switch_report, - format_resume_report, format_status_report, format_tool_call_start, format_tool_result, - normalize_permission_mode, parse_args, parse_git_status_metadata, print_help_to, - push_output_block, render_config_report, render_memory_report, render_repl_help, - resolve_model_alias, response_to_events, resume_supported_slash_commands, status_context, - CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, + describe_tool_progress, filter_tool_specs, format_compact_report, format_cost_report, + format_internal_prompt_progress_line, format_model_report, format_model_switch_report, + format_permissions_report, format_permissions_switch_report, format_resume_report, + format_status_report, format_tool_call_start, format_tool_result, + normalize_permission_mode, parse_args, parse_git_status_metadata, permission_policy, + print_help_to, push_output_block, render_config_report, render_memory_report, + render_repl_help, resolve_model_alias, response_to_events, resume_supported_slash_commands, + status_context, CliAction, CliOutputFormat, InternalPromptProgressEvent, + InternalPromptProgressState, SlashCommand, StatusUsage, DEFAULT_MODEL, }; use api::{MessageResponse, OutputContentBlock, Usage}; + use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; use runtime::{AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode}; use serde_json::json; - use std::collections::BTreeMap; use std::path::PathBuf; + use std::time::Duration; + use tools::GlobalToolRegistry; + + fn registry_with_plugin_tool() -> GlobalToolRegistry { + GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new( + "plugin-demo@external", + "plugin-demo", + PluginToolDefinition { + name: "plugin_echo".to_string(), + description: Some("Echo plugin payload".to_string()), + input_schema: json!({ + "type": "object", + "properties": { + "message": { "type": "string" } + }, + "required": ["message"], + "additionalProperties": false + }), + }, + "echo".to_string(), + Vec::new(), + PluginToolPermission::WorkspaceWrite, + None, + )]) + .expect("plugin tool registry should build") + } #[test] fn defaults_to_repl_when_no_args() { @@ -2938,9 +3929,6 @@ mod tests { assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6"); assert_eq!(resolve_model_alias("sonnet"), "claude-sonnet-4-6"); assert_eq!(resolve_model_alias("haiku"), "claude-haiku-4-5-20251213"); - assert_eq!(resolve_model_alias("grok"), "grok-3"); - assert_eq!(resolve_model_alias("grok-mini"), "grok-3-mini"); - assert_eq!(resolve_model_alias("grok-2"), "grok-2"); assert_eq!(resolve_model_alias("claude-opus"), "claude-opus"); } @@ -3030,6 +4018,43 @@ mod tests { parse_args(&["init".to_string()]).expect("init should parse"), CliAction::Init ); + assert_eq!( + parse_args(&["agents".to_string()]).expect("agents should parse"), + CliAction::Agents { args: None } + ); + assert_eq!( + parse_args(&["skills".to_string()]).expect("skills should parse"), + CliAction::Skills { args: None } + ); + assert_eq!( + parse_args(&["agents".to_string(), "--help".to_string()]) + .expect("agents help should parse"), + CliAction::Agents { + args: Some("--help".to_string()) + } + ); + } + + #[test] + fn parses_direct_agents_and_skills_slash_commands() { + assert_eq!( + parse_args(&["/agents".to_string()]).expect("/agents should parse"), + CliAction::Agents { args: None } + ); + assert_eq!( + parse_args(&["/skills".to_string()]).expect("/skills should parse"), + CliAction::Skills { args: None } + ); + assert_eq!( + parse_args(&["/skills".to_string(), "help".to_string()]) + .expect("/skills help should parse"), + CliAction::Skills { + args: Some("help".to_string()) + } + ); + let error = parse_args(&["/status".to_string()]) + .expect_err("/status should remain REPL-only when invoked directly"); + assert!(error.contains("unsupported direct slash command")); } #[test] @@ -3076,7 +4101,7 @@ mod tests { .into_iter() .map(str::to_string) .collect(); - let filtered = filter_tool_specs(Some(&allowed)); + let filtered = filter_tool_specs(&GlobalToolRegistry::builtin(), Some(&allowed)); let names = filtered .into_iter() .map(|spec| spec.name) @@ -3084,6 +4109,24 @@ mod tests { assert_eq!(names, vec!["read_file", "grep_search"]); } + #[test] + fn filtered_tool_specs_include_plugin_tools() { + let filtered = filter_tool_specs(®istry_with_plugin_tool(), None); + let names = filtered + .into_iter() + .map(|definition| definition.name) + .collect::<Vec<_>>(); + assert!(names.contains(&"bash".to_string())); + assert!(names.contains(&"plugin_echo".to_string())); + } + + #[test] + fn permission_policy_uses_plugin_tool_permissions() { + let policy = permission_policy(PermissionMode::ReadOnly, ®istry_with_plugin_tool()); + let required = policy.required_mode_for("plugin_echo"); + assert_eq!(required, PermissionMode::WorkspaceWrite); + } + #[test] fn shared_help_uses_resume_annotation_copy() { let help = commands::render_slash_command_help(); @@ -3109,6 +4152,12 @@ mod tests { assert!(help.contains("/version")); assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch <session-id>]")); + assert!(help.contains( + "/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]" + )); + assert!(help.contains("aliases: /plugins, /marketplace")); + assert!(help.contains("/agents")); + assert!(help.contains("/skills")); assert!(help.contains("/exit")); } @@ -3122,7 +4171,7 @@ mod tests { names, vec![ "help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff", - "version", "export", + "version", "export", "agents", "skills", ] ); } @@ -3189,6 +4238,9 @@ mod tests { print_help_to(&mut help).expect("help should render"); let help = String::from_utf8(help).expect("help should be utf8"); assert!(help.contains("claw init")); + assert!(help.contains("claw agents")); + assert!(help.contains("claw skills")); + assert!(help.contains("claw /skills")); } #[test] @@ -3259,6 +4311,9 @@ mod tests { fn config_report_supports_section_views() { let report = render_config_report(Some("env")).expect("config report should render"); assert!(report.contains("Merged section: env")); + let plugins_report = + render_config_report(Some("plugins")).expect("plugins config report should render"); + assert!(plugins_report.contains("Merged section: plugins")); } #[test] @@ -3404,20 +4459,169 @@ mod tests { assert!(done.contains("hello")); } + #[test] + fn tool_rendering_truncates_large_read_output_for_display_only() { + let content = (0..200) + .map(|index| format!("line {index:03}")) + .collect::<Vec<_>>() + .join("\n"); + let output = json!({ + "file": { + "filePath": "src/main.rs", + "content": content, + "numLines": 200, + "startLine": 1, + "totalLines": 200 + } + }) + .to_string(); + + let rendered = format_tool_result("read_file", &output, false); + + assert!(rendered.contains("line 000")); + assert!(rendered.contains("line 079")); + assert!(!rendered.contains("line 199")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("line 199")); + } + + #[test] + fn tool_rendering_truncates_large_bash_output_for_display_only() { + let stdout = (0..120) + .map(|index| format!("stdout {index:03}")) + .collect::<Vec<_>>() + .join("\n"); + let output = json!({ + "stdout": stdout, + "stderr": "", + "returnCodeInterpretation": "completed successfully" + }) + .to_string(); + + let rendered = format_tool_result("bash", &output, false); + + assert!(rendered.contains("stdout 000")); + assert!(rendered.contains("stdout 059")); + assert!(!rendered.contains("stdout 119")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("stdout 119")); + } + + #[test] + fn tool_rendering_truncates_generic_long_output_for_display_only() { + let items = (0..120) + .map(|index| format!("payload {index:03}")) + .collect::<Vec<_>>(); + let output = json!({ + "summary": "plugin payload", + "items": items, + }) + .to_string(); + + let rendered = format_tool_result("plugin_echo", &output, false); + + assert!(rendered.contains("plugin_echo")); + assert!(rendered.contains("payload 000")); + assert!(rendered.contains("payload 040")); + assert!(!rendered.contains("payload 080")); + assert!(!rendered.contains("payload 119")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("payload 119")); + } + + #[test] + fn tool_rendering_truncates_raw_generic_output_for_display_only() { + let output = (0..120) + .map(|index| format!("raw {index:03}")) + .collect::<Vec<_>>() + .join("\n"); + + let rendered = format_tool_result("plugin_echo", &output, false); + + assert!(rendered.contains("plugin_echo")); + assert!(rendered.contains("raw 000")); + assert!(rendered.contains("raw 059")); + assert!(!rendered.contains("raw 119")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("raw 119")); + } + + #[test] + fn ultraplan_progress_lines_include_phase_step_and_elapsed_status() { + let snapshot = InternalPromptProgressState { + command_label: "Ultraplan", + task_label: "ship plugin progress".to_string(), + step: 3, + phase: "running read_file".to_string(), + detail: Some("reading rust/crates/rusty-claude-cli/src/main.rs".to_string()), + saw_final_text: false, + }; + + let started = format_internal_prompt_progress_line( + InternalPromptProgressEvent::Started, + &snapshot, + Duration::from_secs(0), + None, + ); + let heartbeat = format_internal_prompt_progress_line( + InternalPromptProgressEvent::Heartbeat, + &snapshot, + Duration::from_secs(9), + None, + ); + let completed = format_internal_prompt_progress_line( + InternalPromptProgressEvent::Complete, + &snapshot, + Duration::from_secs(12), + None, + ); + let failed = format_internal_prompt_progress_line( + InternalPromptProgressEvent::Failed, + &snapshot, + Duration::from_secs(12), + Some("network timeout"), + ); + + assert!(started.contains("planning started")); + assert!(started.contains("current step 3")); + assert!(heartbeat.contains("heartbeat")); + assert!(heartbeat.contains("9s elapsed")); + assert!(heartbeat.contains("phase running read_file")); + assert!(completed.contains("completed")); + assert!(completed.contains("3 steps total")); + assert!(failed.contains("failed")); + assert!(failed.contains("network timeout")); + } + + #[test] + fn describe_tool_progress_summarizes_known_tools() { + assert_eq!( + describe_tool_progress("read_file", r#"{"path":"src/main.rs"}"#), + "reading src/main.rs" + ); + assert!( + describe_tool_progress("bash", r#"{"command":"cargo test -p rusty-claude-cli"}"#) + .contains("cargo test -p rusty-claude-cli") + ); + assert_eq!( + describe_tool_progress("grep_search", r#"{"pattern":"ultraplan","path":"rust"}"#), + "grep `ultraplan` in rust" + ); + } + #[test] fn push_output_block_renders_markdown_text() { let mut out = Vec::new(); let mut events = Vec::new(); - let mut pending_tools = BTreeMap::new(); + let mut pending_tool = None; push_output_block( OutputContentBlock::Text { text: "# Heading".to_string(), }, - 0, &mut out, &mut events, - &mut pending_tools, + &mut pending_tool, false, ) .expect("text block should render"); @@ -3431,7 +4635,7 @@ mod tests { fn push_output_block_skips_empty_object_prefix_for_tool_streams() { let mut out = Vec::new(); let mut events = Vec::new(); - let mut pending_tools = BTreeMap::new(); + let mut pending_tool = None; push_output_block( OutputContentBlock::ToolUse { @@ -3439,83 +4643,20 @@ mod tests { name: "read_file".to_string(), input: json!({}), }, - 1, &mut out, &mut events, - &mut pending_tools, + &mut pending_tool, true, ) .expect("tool block should accumulate"); assert!(events.is_empty()); assert_eq!( - pending_tools.remove(&1), + pending_tool, Some(("tool-1".to_string(), "read_file".to_string(), String::new(),)) ); } - #[test] - fn pending_tools_preserve_multiple_streaming_tool_calls_by_index() { - let mut out = Vec::new(); - let mut events = Vec::new(); - let mut pending_tools = BTreeMap::new(); - - push_output_block( - OutputContentBlock::ToolUse { - id: "tool-1".to_string(), - name: "read_file".to_string(), - input: json!({}), - }, - 1, - &mut out, - &mut events, - &mut pending_tools, - true, - ) - .expect("first tool should accumulate"); - push_output_block( - OutputContentBlock::ToolUse { - id: "tool-2".to_string(), - name: "grep_search".to_string(), - input: json!({}), - }, - 2, - &mut out, - &mut events, - &mut pending_tools, - true, - ) - .expect("second tool should accumulate"); - - pending_tools - .get_mut(&1) - .expect("first tool pending") - .2 - .push_str("{\"path\":\"src/main.rs\"}"); - pending_tools - .get_mut(&2) - .expect("second tool pending") - .2 - .push_str("{\"pattern\":\"TODO\"}"); - - assert_eq!( - pending_tools.remove(&1), - Some(( - "tool-1".to_string(), - "read_file".to_string(), - "{\"path\":\"src/main.rs\"}".to_string(), - )) - ); - assert_eq!( - pending_tools.remove(&2), - Some(( - "tool-2".to_string(), - "grep_search".to_string(), - "{\"pattern\":\"TODO\"}".to_string(), - )) - ); - } - #[test] fn response_to_events_preserves_empty_object_json_input_outside_streaming() { let mut out = Vec::new(); @@ -3585,4 +4726,43 @@ mod tests { if name == "read_file" && input == "{\"path\":\"rust/Cargo.toml\"}" )); } + + #[test] + fn response_to_events_ignores_thinking_blocks() { + let mut out = Vec::new(); + let events = response_to_events( + MessageResponse { + id: "msg-3".to_string(), + kind: "message".to_string(), + model: "claude-opus-4-6".to_string(), + role: "assistant".to_string(), + content: vec![ + OutputContentBlock::Thinking { + thinking: "step 1".to_string(), + signature: Some("sig_123".to_string()), + }, + OutputContentBlock::Text { + text: "Final answer".to_string(), + }, + ], + stop_reason: Some("end_turn".to_string()), + stop_sequence: None, + usage: Usage { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + request_id: None, + }, + &mut out, + ) + .expect("response conversion should succeed"); + + assert!(matches!( + &events[0], + AssistantEvent::TextDelta(text) if text == "Final answer" + )); + assert!(!String::from_utf8(out).expect("utf8").contains("step 1")); + } } diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 33fa399..c22db4c 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -8,6 +8,7 @@ use api::{ MessageRequest, MessageResponse, OutputContentBlock, ProviderClient, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, }; +use plugins::PluginTool; use reqwest::blocking::Client; use runtime::{ edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file, @@ -55,6 +56,161 @@ pub struct ToolSpec { pub required_permission: PermissionMode, } +#[derive(Debug, Clone, PartialEq)] +pub struct GlobalToolRegistry { + plugin_tools: Vec<PluginTool>, +} + +impl GlobalToolRegistry { + #[must_use] + pub fn builtin() -> Self { + Self { + plugin_tools: Vec::new(), + } + } + + pub fn with_plugin_tools(plugin_tools: Vec<PluginTool>) -> Result<Self, String> { + let builtin_names = mvp_tool_specs() + .into_iter() + .map(|spec| spec.name.to_string()) + .collect::<BTreeSet<_>>(); + let mut seen_plugin_names = BTreeSet::new(); + + for tool in &plugin_tools { + let name = tool.definition().name.clone(); + if builtin_names.contains(&name) { + return Err(format!( + "plugin tool `{name}` conflicts with a built-in tool name" + )); + } + if !seen_plugin_names.insert(name.clone()) { + return Err(format!("duplicate plugin tool name `{name}`")); + } + } + + Ok(Self { plugin_tools }) + } + + pub fn normalize_allowed_tools(&self, values: &[String]) -> Result<Option<BTreeSet<String>>, String> { + if values.is_empty() { + return Ok(None); + } + + let builtin_specs = mvp_tool_specs(); + let canonical_names = builtin_specs + .iter() + .map(|spec| spec.name.to_string()) + .chain(self.plugin_tools.iter().map(|tool| tool.definition().name.clone())) + .collect::<Vec<_>>(); + let mut name_map = canonical_names + .iter() + .map(|name| (normalize_tool_name(name), name.clone())) + .collect::<BTreeMap<_, _>>(); + + for (alias, canonical) in [ + ("read", "read_file"), + ("write", "write_file"), + ("edit", "edit_file"), + ("glob", "glob_search"), + ("grep", "grep_search"), + ] { + name_map.insert(alias.to_string(), canonical.to_string()); + } + + let mut allowed = BTreeSet::new(); + for value in values { + for token in value + .split(|ch: char| ch == ',' || ch.is_whitespace()) + .filter(|token| !token.is_empty()) + { + let normalized = normalize_tool_name(token); + let canonical = name_map.get(&normalized).ok_or_else(|| { + format!( + "unsupported tool in --allowedTools: {token} (expected one of: {})", + canonical_names.join(", ") + ) + })?; + allowed.insert(canonical.clone()); + } + } + + Ok(Some(allowed)) + } + + #[must_use] + pub fn definitions(&self, allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolDefinition> { + let builtin = mvp_tool_specs() + .into_iter() + .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name))) + .map(|spec| ToolDefinition { + name: spec.name.to_string(), + description: Some(spec.description.to_string()), + input_schema: spec.input_schema, + }); + let plugin = self + .plugin_tools + .iter() + .filter(|tool| { + allowed_tools.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str())) + }) + .map(|tool| ToolDefinition { + name: tool.definition().name.clone(), + description: tool.definition().description.clone(), + input_schema: tool.definition().input_schema.clone(), + }); + builtin.chain(plugin).collect() + } + + #[must_use] + pub fn permission_specs( + &self, + allowed_tools: Option<&BTreeSet<String>>, + ) -> Vec<(String, PermissionMode)> { + let builtin = mvp_tool_specs() + .into_iter() + .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name))) + .map(|spec| (spec.name.to_string(), spec.required_permission)); + let plugin = self + .plugin_tools + .iter() + .filter(|tool| { + allowed_tools.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str())) + }) + .map(|tool| { + ( + tool.definition().name.clone(), + permission_mode_from_plugin(tool.required_permission()), + ) + }); + builtin.chain(plugin).collect() + } + + pub fn execute(&self, name: &str, input: &Value) -> Result<String, String> { + if mvp_tool_specs().iter().any(|spec| spec.name == name) { + return execute_tool(name, input); + } + self.plugin_tools + .iter() + .find(|tool| tool.definition().name == name) + .ok_or_else(|| format!("unsupported tool: {name}"))? + .execute(input) + .map_err(|error| error.to_string()) + } +} + +fn normalize_tool_name(value: &str) -> String { + value.trim().replace('-', "_").to_ascii_lowercase() +} + +fn permission_mode_from_plugin(value: &str) -> PermissionMode { + match value { + "read-only" => PermissionMode::ReadOnly, + "workspace-write" => PermissionMode::WorkspaceWrite, + "danger-full-access" => PermissionMode::DangerFullAccess, + other => panic!("unsupported plugin permission: {other}"), + } +} + #[must_use] #[allow(clippy::too_many_lines)] pub fn mvp_tool_specs() -> Vec<ToolSpec> {