microclaw_engine/clawhub/
cli.rs1use 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
11async 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 { query: String },
255 Install {
257 slug: String,
258 #[arg(long)]
259 force: bool,
260 },
261 List,
263 Available {
265 #[arg(long)]
266 all: bool,
267 },
268 Inspect { slug: String },
270 Verify,
273 Audit {
276 path: Option<String>,
278 #[arg(long, default_value_t = 0.5)]
280 similarity: f64,
281 #[arg(long, default_value_t = 30)]
283 stale_days: i64,
284 #[arg(long, default_value_t = 80)]
286 min_body_chars: usize,
287 #[arg(long)]
289 json: bool,
290 #[arg(long)]
292 strict: bool,
293 },
294}