From 3fbe290901a004d4c7de5ee1182cf4ada63a9127 Mon Sep 17 00:00:00 2001 From: Reid <61492567+reidliu41@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:20:20 +0800 Subject: [PATCH] feat(cli): add `ironclaw skills list/search/info` subcommands (#918) --- FEATURE_PARITY.md | 2 +- src/cli/mod.rs | 10 + src/cli/skills.rs | 375 ++++++++++++++++++ ...li__tests__help_output_without_import.snap | 2 +- ...ests__long_help_output_without_import.snap | 2 +- src/main.rs | 5 + 6 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 src/cli/skills.rs diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 075d8007..2df78f47 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -165,7 +165,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | `agents` | ✅ | ❌ | P3 | Multi-agent management | | `sessions` | ✅ | ❌ | P3 | Session listing (shows subagent models) | | `memory` | ✅ | ✅ | - | Memory search CLI | -| `skills` | ✅ | ✅ | - | Skills tools + web API endpoints (install, list, activate) | +| `skills` | ✅ | ✅ | - | CLI subcommands (list, search, info) + agent tools + web API endpoints | | `pairing` | ✅ | ✅ | - | list/approve, account selector | | `nodes` | ✅ | ❌ | P3 | Device management, remove/clear flows | | `plugins` | ✅ | ❌ | P3 | Plugin management | diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0d165597..7d76e019 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -22,6 +22,7 @@ pub mod oauth_defaults; mod pairing; mod registry; mod service; +mod skills; pub mod status; mod tool; @@ -36,6 +37,7 @@ pub use memory::run_memory_command_with_db; pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store}; pub use registry::{RegistryCommand, run_registry_command}; pub use service::{ServiceCommand, run_service_command}; +pub use skills::{SkillsCommand, run_skills_command}; pub use status::run_status_command; pub use tool::{ToolCommand, run_tool_command}; @@ -166,6 +168,14 @@ pub enum Command { )] Service(ServiceCommand), + /// Manage SKILL.md-based skills + #[command( + subcommand, + about = "Manage skills", + long_about = "List, search, and inspect SKILL.md-based skills.\nExamples:\n ironclaw skills list\n ironclaw skills search 'writing'\n ironclaw skills info my-skill" + )] + Skills(SkillsCommand), + /// Probe external dependencies and validate configuration #[command( about = "Run diagnostics", diff --git a/src/cli/skills.rs b/src/cli/skills.rs new file mode 100644 index 00000000..1f3cc46b --- /dev/null +++ b/src/cli/skills.rs @@ -0,0 +1,375 @@ +//! Skills management CLI commands. +//! +//! Commands for listing, searching, and inspecting SKILL.md-based skills. +//! List and info operate on the filesystem only; search queries the ClawHub registry. + +use std::path::Path; + +use clap::Subcommand; + +use crate::config::SkillsConfig; +use crate::skills::catalog::SkillCatalog; +use crate::skills::{SkillRegistry, SkillSource}; + +#[derive(Subcommand, Debug, Clone)] +pub enum SkillsCommand { + /// List all discovered skills + List { + /// Show detailed information (keywords, patterns, source path) + #[arg(short, long)] + verbose: bool, + + /// Output as JSON + #[arg(long)] + json: bool, + }, + + /// Search ClawHub registry for skills + Search { + /// Search query + query: String, + + /// Output as JSON + #[arg(long)] + json: bool, + }, + + /// Show detailed info about a specific skill + Info { + /// Skill name + name: String, + + /// Output as JSON + #[arg(long)] + json: bool, + }, +} + +/// Run the skills CLI subcommand. +pub async fn run_skills_command( + cmd: SkillsCommand, + config_path: Option<&Path>, +) -> anyhow::Result<()> { + let full_config = crate::config::Config::from_env_with_toml(config_path) + .await + .map_err(|e| anyhow::anyhow!("{e:#}"))?; + let config = full_config.skills; + + if !config.enabled { + anyhow::bail!("Skills system is disabled (SKILLS_ENABLED=false)"); + } + + match cmd { + SkillsCommand::List { verbose, json } => cmd_list(&config, verbose, json).await, + SkillsCommand::Search { query, json } => cmd_search(&query, json).await, + SkillsCommand::Info { name, json } => cmd_info(&config, &name, json).await, + } +} + +/// Discover skills from all configured directories. +async fn discover_skills(config: &SkillsConfig) -> SkillRegistry { + let mut registry = SkillRegistry::new(config.local_dir.clone()) + .with_installed_dir(config.installed_dir.clone()); + registry.discover_all().await; + registry +} + +/// Format a skill source path for display. +fn format_source(source: &SkillSource) -> &str { + match source { + SkillSource::Workspace(_) => "workspace", + SkillSource::User(_) => "user", + SkillSource::Bundled(_) => "bundled", + } +} + +/// List all discovered skills. +async fn cmd_list(config: &SkillsConfig, verbose: bool, json: bool) -> anyhow::Result<()> { + let registry = discover_skills(config).await; + let skills = registry.skills(); + + if json { + let entries: Vec = skills + .iter() + .map(|s| { + let mut v = serde_json::json!({ + "name": s.manifest.name, + "version": s.manifest.version, + "description": s.manifest.description, + "trust": s.trust.to_string(), + "source": format_source(&s.source), + }); + if verbose { + v["keywords"] = serde_json::json!(s.manifest.activation.keywords); + v["tags"] = serde_json::json!(s.manifest.activation.tags); + v["patterns"] = serde_json::json!(s.manifest.activation.patterns); + } + v + }) + .collect(); + println!( + "{}", + serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string()) + ); + return Ok(()); + } + + if skills.is_empty() { + println!("No skills found."); + println!(); + println!("Skills directories:"); + println!(" User: {}", config.local_dir.display()); + println!(" Installed: {}", config.installed_dir.display()); + println!(); + println!("Use 'ironclaw skills search ' to find skills on ClawHub."); + return Ok(()); + } + + println!("Discovered {} skill(s):\n", skills.len()); + + for s in skills { + if verbose { + println!(" {} v{}", s.manifest.name, s.manifest.version); + println!(" Trust: {}", s.trust); + println!(" Source: {}", format_source(&s.source)); + if !s.manifest.description.is_empty() { + println!(" Description: {}", s.manifest.description); + } + if !s.manifest.activation.keywords.is_empty() { + println!( + " Keywords: {}", + s.manifest.activation.keywords.join(", ") + ); + } + if !s.manifest.activation.tags.is_empty() { + println!(" Tags: {}", s.manifest.activation.tags.join(", ")); + } + println!(); + } else { + let desc = truncate(&s.manifest.description, 50); + println!( + " {:<24} v{:<10} [{}] {}", + s.manifest.name, s.manifest.version, s.trust, desc, + ); + } + } + + if !verbose { + println!(); + println!( + "Use --verbose for details, or 'ironclaw skills info ' for a specific skill." + ); + } + + Ok(()) +} + +/// Search ClawHub registry. +async fn cmd_search(query: &str, json: bool) -> anyhow::Result<()> { + let catalog = SkillCatalog::new(); + let outcome = catalog.search(query).await; + + let mut entries = outcome.results; + catalog.enrich_search_results(&mut entries, 5).await; + + if json { + let json_entries: Vec = entries + .iter() + .map(|e| { + serde_json::json!({ + "slug": e.slug, + "name": e.name, + "description": e.description, + "version": e.version, + "stars": e.stars, + "downloads": e.downloads, + "owner": e.owner, + }) + }) + .collect(); + let result = serde_json::json!({ + "query": query, + "results": json_entries, + "error": outcome.error, + }); + println!( + "{}", + serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string()) + ); + return Ok(()); + } + + println!("ClawHub results for \"{}\":\n", query); + + if entries.is_empty() { + if let Some(ref err) = outcome.error { + println!(" (registry error: {})", err); + } else { + println!(" No results found."); + } + return Ok(()); + } + + for entry in &entries { + let owner_str = entry + .owner + .as_deref() + .map(|o| format!(" by {o}")) + .unwrap_or_default(); + + let stats: Vec = [ + entry.stars.map(|s| format!("{s} stars")), + entry.downloads.map(|d| format!("{d} downloads")), + ] + .into_iter() + .flatten() + .collect(); + let stats_str = if stats.is_empty() { + String::new() + } else { + format!(" ({})", stats.join(", ")) + }; + + println!( + " {} v{}{}{}", + entry.slug, entry.version, owner_str, stats_str + ); + if !entry.description.is_empty() { + println!(" {}", truncate(&entry.description, 70)); + } + } + + if let Some(ref err) = outcome.error { + println!("\n (note: {})", err); + } + + Ok(()) +} + +/// Show detailed info about a specific skill. +async fn cmd_info(config: &SkillsConfig, name: &str, json: bool) -> anyhow::Result<()> { + let registry = discover_skills(config).await; + let skill = registry.find_by_name(name).ok_or_else(|| { + anyhow::anyhow!( + "Skill '{}' not found. Use 'ironclaw skills list' to see available skills.", + name + ) + })?; + + if json { + let v = serde_json::json!({ + "name": skill.manifest.name, + "version": skill.manifest.version, + "description": skill.manifest.description, + "trust": skill.trust.to_string(), + "source": format_source(&skill.source), + "content_hash": skill.content_hash, + "activation": { + "keywords": skill.manifest.activation.keywords, + "patterns": skill.manifest.activation.patterns, + "tags": skill.manifest.activation.tags, + "exclude_keywords": skill.manifest.activation.exclude_keywords, + "max_context_tokens": skill.manifest.activation.max_context_tokens, + }, + "prompt_length": skill.prompt_content.len(), + }); + println!( + "{}", + serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string()) + ); + return Ok(()); + } + + println!("Skill: {}", skill.manifest.name); + println!(" Version: {}", skill.manifest.version); + println!(" Trust: {}", skill.trust); + println!(" Source: {}", format_source(&skill.source)); + if !skill.manifest.description.is_empty() { + println!(" Description: {}", skill.manifest.description); + } + println!(" Hash: {}", skill.content_hash); + println!( + " Prompt size: {} bytes (~{} tokens)", + skill.prompt_content.len(), + skill.prompt_content.split_whitespace().count() * 13 / 10 + ); + + let act = &skill.manifest.activation; + if !act.keywords.is_empty() { + println!(" Keywords: {}", act.keywords.join(", ")); + } + if !act.exclude_keywords.is_empty() { + println!(" Exclude: {}", act.exclude_keywords.join(", ")); + } + if !act.patterns.is_empty() { + println!(" Patterns: {}", act.patterns.join(", ")); + } + if !act.tags.is_empty() { + println!(" Tags: {}", act.tags.join(", ")); + } + println!(" Max tokens: {}", act.max_context_tokens); + + if let Some(ref meta) = skill.manifest.metadata + && let Some(ref oc) = meta.openclaw + { + let reqs = &oc.requires; + if !reqs.bins.is_empty() { + println!(" Requires bins: {}", reqs.bins.join(", ")); + } + if !reqs.env.is_empty() { + println!(" Requires env: {}", reqs.env.join(", ")); + } + if !reqs.config.is_empty() { + println!(" Requires config: {}", reqs.config.join(", ")); + } + } + + Ok(()) +} + +/// Truncate a string to max chars, appending "..." if truncated. +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let truncated: String = s.chars().take(max.saturating_sub(3)).collect(); + format!("{truncated}...") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_short_string() { + assert_eq!(truncate("hello", 10), "hello"); + } + + #[test] + fn truncate_long_string() { + assert_eq!(truncate("hello world foo bar", 10), "hello w..."); + } + + #[test] + fn truncate_multibyte_safe() { + // Should not panic on multibyte characters + let s = "日本語テスト"; + let result = truncate(s, 4); + assert!(result.ends_with("...")); + } + + #[test] + fn format_source_variants() { + use std::path::PathBuf; + assert_eq!( + format_source(&SkillSource::Workspace(PathBuf::new())), + "workspace" + ); + assert_eq!(format_source(&SkillSource::User(PathBuf::new())), "user"); + assert_eq!( + format_source(&SkillSource::Bundled(PathBuf::new())), + "bundled" + ); + } +} diff --git a/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap b/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap index 4c2c5dbc..72e609e8 100644 --- a/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap +++ b/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap @@ -1,6 +1,5 @@ --- source: src/cli/mod.rs -assertion_line: 310 expression: help --- Secure personal AI assistant that protects your data and expands its capabilities @@ -17,6 +16,7 @@ Commands: memory Manage workspace memory pairing Manage DM pairing service Manage OS service + skills Manage skills doctor Run diagnostics status Show system status completion Generate completions diff --git a/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap b/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap index 95fe9b57..c8e12c29 100644 --- a/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap +++ b/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap @@ -1,6 +1,5 @@ --- source: src/cli/mod.rs -assertion_line: 326 expression: help --- IronClaw is a secure AI assistant. Use 'ironclaw --help' for details. @@ -20,6 +19,7 @@ Commands: memory Manage workspace memory pairing Manage DM pairing service Manage OS service + skills Manage skills doctor Run diagnostics status Show system status completion Generate completions diff --git a/src/main.rs b/src/main.rs index 0f48755b..a2e5e441 100644 --- a/src/main.rs +++ b/src/main.rs @@ -75,6 +75,11 @@ async fn async_main() -> anyhow::Result<()> { init_cli_tracing(); return run_service_command(service_cmd); } + Some(Command::Skills(skills_cmd)) => { + init_cli_tracing(); + return ironclaw::cli::run_skills_command(skills_cmd.clone(), cli.config.as_deref()) + .await; + } Some(Command::Doctor) => { init_cli_tracing(); return ironclaw::cli::run_doctor_command().await;