feat(cli): add ironclaw skills list/search/info subcommands (#918)

This commit is contained in:
Reid
2026-03-11 20:20:20 -07:00
committed by GitHub
parent f05896fe6a
commit 3fbe290901
6 changed files with 393 additions and 3 deletions
+1 -1
View File
@@ -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 |
+10
View File
@@ -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",
+375
View File
@@ -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<serde_json::Value> = 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 <query>' 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 <name>' 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<serde_json::Value> = 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<String> = [
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"
);
}
}
@@ -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
@@ -1,6 +1,5 @@
---
source: src/cli/mod.rs
assertion_line: 326
expression: help
---
IronClaw is a secure AI assistant. Use 'ironclaw <subcommand> --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
+5
View File
@@ -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;