Skip to main content

microclaw_engine/clawhub/
cli.rs

1use crate::clawhub::service::{ClawHubGateway, RegistryClawHubGateway};
2use crate::config::Config;
3use crate::error::MicroClawError;
4use crate::internal::clawhub::install::InstallOptions;
5use crate::skills::SkillManager;
6use clap::{Parser, Subcommand};
7use std::path::PathBuf;
8use std::sync::Arc;
9use tokio::time::{sleep, Duration};
10
11/// Retry an async operation up to 3 times with brief delays
12async fn retry_with_backoff<T, F, Fut>(mut operation: F) -> Result<T, MicroClawError>
13where
14    F: FnMut() -> Fut,
15    Fut: std::future::Future<Output = Result<T, MicroClawError>>,
16{
17    let mut last_error = None;
18    for attempt in 1..=3 {
19        match operation().await {
20            Ok(result) => return Ok(result),
21            Err(e) => {
22                last_error = Some(e);
23                if attempt < 3 {
24                    sleep(Duration::from_millis(500)).await;
25                }
26            }
27        }
28    }
29    Err(last_error
30        .unwrap_or_else(|| MicroClawError::Config("Unexpected error during retry".to_string())))
31}
32
33pub async fn handle_skill_cli(args: &[String], config: &Config) -> Result<(), MicroClawError> {
34    let cli = match SkillCli::try_parse_from(
35        std::iter::once("skill").chain(args.iter().map(std::string::String::as_str)),
36    ) {
37        Ok(cli) => cli,
38        Err(err)
39            if matches!(
40                err.kind(),
41                clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
42            ) =>
43        {
44            err.print()
45                .map_err(|e| MicroClawError::Config(e.to_string()))?;
46            return Ok(());
47        }
48        Err(err) => return Err(MicroClawError::Config(err.to_string())),
49    };
50    let subcommand = cli.command;
51
52    let gateway: Arc<dyn ClawHubGateway> = Arc::new(RegistryClawHubGateway::from_config(config));
53
54    match subcommand {
55        Some(SkillCommand::Search { query }) => {
56            let gateway = gateway.clone();
57            let results = retry_with_backoff(|| {
58                let gateway = gateway.clone();
59                let query = query.clone();
60                async move { gateway.search(&query, 10, "trending").await }
61            })
62            .await;
63            match results {
64                Ok(results) => {
65                    println!("Found {} skills:\n", results.len());
66                    for r in results {
67                        println!("  {} - {}", r.slug, r.name);
68                        println!("    {}", r.description);
69                        println!("    {} installs", r.install_count);
70                        if let Some(vt) = r.virustotal {
71                            println!("    VirusTotal: {} ({})", vt.status, vt.report_count);
72                        }
73                        println!();
74                    }
75                }
76                Err(e) => eprintln!("Search failed: {}", e),
77            }
78            Ok(())
79        }
80        Some(SkillCommand::Install { slug, force }) => {
81            let skills_dir = PathBuf::from(config.skills_data_dir());
82            let lockfile_path = config.clawhub_lockfile_path();
83
84            let gateway = gateway.clone();
85            let options = InstallOptions {
86                force,
87                skip_gates: false,
88                skip_security: config.clawhub.skip_security_warnings,
89            };
90            let result = retry_with_backoff(|| {
91                let gateway = gateway.clone();
92                let skills_dir = skills_dir.clone();
93                let lockfile_path = lockfile_path.clone();
94                let options = options.clone();
95                let slug = slug.clone();
96                async move {
97                    gateway
98                        .install(&slug, None, &skills_dir, &lockfile_path, &options)
99                        .await
100                }
101            })
102            .await;
103            match result {
104                Ok(result) => {
105                    println!("{}", result.message);
106                    for w in &result.warnings {
107                        println!("  ⚠ {}", w);
108                    }
109                    if result.requires_restart {
110                        println!("Restart MicroClaw or run /reload-skills to activate.");
111                    }
112                }
113                Err(e) => eprintln!("Install failed: {}", e),
114            }
115            Ok(())
116        }
117        Some(SkillCommand::List) => {
118            let lockfile_path = config.clawhub_lockfile_path();
119            let lock = gateway.read_lockfile(&lockfile_path)?;
120            if lock.skills.is_empty() {
121                println!("No ClawHub skills installed.");
122            } else {
123                println!("Installed ClawHub skills:\n");
124                for (slug, entry) in &lock.skills {
125                    println!(
126                        "  {} - v{} (installed: {})",
127                        slug, entry.installed_version, entry.installed_at
128                    );
129                }
130            }
131            Ok(())
132        }
133        Some(SkillCommand::Verify) => {
134            let lockfile_path = config.clawhub_lockfile_path();
135            let lock = gateway.read_lockfile(&lockfile_path)?;
136            if lock.skills.is_empty() {
137                println!("No ClawHub skills installed.");
138                return Ok(());
139            }
140            let skills_dir = PathBuf::from(config.skills_data_dir());
141            let mut bad = 0usize;
142            for (slug, entry) in &lock.skills {
143                use crate::internal::clawhub::install::TreeVerification;
144                match crate::internal::clawhub::install::verify_tree(entry, &skills_dir) {
145                    TreeVerification::Ok => println!("  ✓ {slug} — tree matches lockfile"),
146                    TreeVerification::Unpinned => println!(
147                        "  - {slug} — no tree hash recorded (installed before pinning; reinstall to pin)"
148                    ),
149                    TreeVerification::Missing => {
150                        bad += 1;
151                        println!("  ✗ {slug} — installed directory missing or unreadable");
152                    }
153                    TreeVerification::Modified { expected, actual } => {
154                        bad += 1;
155                        println!(
156                            "  ✗ {slug} — tree MODIFIED since install
157      expected {expected}
158      actual   {actual}"
159                        );
160                    }
161                }
162            }
163            if bad > 0 {
164                return Err(MicroClawError::Config(format!(
165                    "{bad} ClawHub skill(s) failed verification — inspect or reinstall them"
166                )));
167            }
168            Ok(())
169        }
170        Some(SkillCommand::Available { all }) => {
171            let manager = SkillManager::from_skills_and_runtime(
172                &config.skills_data_dir(),
173                &config.runtime_data_dir(),
174            )
175            .with_config_verification(config);
176            if all {
177                println!("{}", manager.list_skills_formatted_all());
178            } else {
179                println!("{}", manager.list_skills_formatted());
180            }
181            Ok(())
182        }
183        Some(SkillCommand::Inspect { slug }) => {
184            let gateway = gateway.clone();
185            let meta = retry_with_backoff(|| {
186                let gateway = gateway.clone();
187                let slug = slug.clone();
188                async move { gateway.get_skill(&slug).await }
189            })
190            .await;
191            match meta {
192                Ok(meta) => {
193                    println!("Skill: {} ({})", meta.name, meta.slug);
194                    println!("{}", meta.description);
195                    println!("\nVersions:");
196                    for v in &meta.versions {
197                        let marker = if v.latest { " (latest)" } else { "" };
198                        println!("  v{}{}", v.version, marker);
199                    }
200                    if let Some(vt) = meta.virustotal {
201                        println!("\nVirusTotal: {} ({} reports)", vt.status, vt.report_count);
202                    }
203                }
204                Err(e) => eprintln!("Inspect failed: {}", e),
205            }
206            Ok(())
207        }
208        Some(SkillCommand::Audit {
209            path,
210            similarity,
211            stale_days,
212            min_body_chars,
213            json,
214            strict,
215        }) => {
216            let cfg = crate::skill_audit::AuditConfig {
217                similarity,
218                stale_days,
219                min_body_chars,
220                ..crate::skill_audit::AuditConfig::default()
221            };
222            let dir = path.unwrap_or_else(|| config.skills_data_dir());
223            let code = crate::skill_audit::run_audit(&dir, &cfg, json, strict);
224            std::process::exit(code);
225        }
226        None => {
227            println!("Usage: microclaw skill <command>");
228            println!("\nCommands:");
229            println!("  search <query>   Search for skills");
230            println!("  install <slug>    Install a skill");
231            println!("  list              List installed skills");
232            println!("  available [--all] List local skills (with diagnostics when --all)");
233            println!("  inspect <slug>    Show skill details");
234            println!("  audit [--json] [--strict]  Audit local skills for curation actions");
235            Ok(())
236        }
237    }
238}
239
240#[derive(Debug, Parser)]
241#[command(
242    name = "microclaw skill",
243    about = "Manage ClawHub skills",
244    disable_help_subcommand = true
245)]
246struct SkillCli {
247    #[command(subcommand)]
248    command: Option<SkillCommand>,
249}
250
251#[derive(Debug, Subcommand)]
252enum SkillCommand {
253    /// Search for skills
254    Search { query: String },
255    /// Install a skill
256    Install {
257        slug: String,
258        #[arg(long)]
259        force: bool,
260    },
261    /// List installed skills
262    List,
263    /// List local skills (with diagnostics when --all)
264    Available {
265        #[arg(long)]
266        all: bool,
267    },
268    /// Show skill details
269    Inspect { slug: String },
270    /// Verify installed ClawHub skills against their lockfile tree hashes
271    /// (detects post-install modification). Exits non-zero on any mismatch.
272    Verify,
273    /// Audit the local skill corpus for curation actions (duplicates, stale,
274    /// thin, cap headroom). Read-only and deterministic.
275    Audit {
276        /// Skills directory to audit (defaults to the configured skills dir)
277        path: Option<String>,
278        /// Token-Jaccard threshold for near-duplicate detection (0.0-1.0)
279        #[arg(long, default_value_t = 0.5)]
280        similarity: f64,
281        /// Days since last update before an agent-created skill is stale
282        #[arg(long, default_value_t = 30)]
283        stale_days: i64,
284        /// Minimum body chars before an agent-created skill is "thin"
285        #[arg(long, default_value_t = 80)]
286        min_body_chars: usize,
287        /// Emit a JSON report instead of human-readable text
288        #[arg(long)]
289        json: bool,
290        /// Exit non-zero when the audit finds anything actionable
291        #[arg(long)]
292        strict: bool,
293    },
294}