1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7const CLAWHUB_MAX_RETRIES: u32 = 4;
11const CLAWHUB_BASE_DELAY: Duration = Duration::from_millis(500);
13const CLAWHUB_MAX_DELAY: Duration = Duration::from_secs(15);
15
16fn blocking_request_with_retry(
19 client: &reqwest::blocking::Client,
20 url: &str,
21 token: Option<&str>,
22 timeout: Duration,
23) -> Result<reqwest::blocking::Response> {
24 let mut last_err: Option<anyhow::Error> = None;
25
26 for attempt in 1..=CLAWHUB_MAX_RETRIES {
27 let mut req = client.get(url);
28 if let Some(tok) = token {
29 req = req.bearer_auth(tok);
30 }
31
32 match req.timeout(timeout).send() {
33 Ok(resp) => {
34 let status = resp.status();
35
36 if status.is_success() {
38 return Ok(resp);
39 }
40
41 if status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
43 let retry_after = resp
44 .headers()
45 .get(reqwest::header::RETRY_AFTER)
46 .and_then(|v| v.to_str().ok())
47 .and_then(|v| v.trim().parse::<u64>().ok())
48 .map(Duration::from_secs);
49
50 let body = resp.text().unwrap_or_default();
51
52 let backoff = backoff_delay(attempt);
53 let delay = retry_after.unwrap_or(backoff);
54
55 last_err = Some(anyhow::anyhow!(
56 "HTTP {} from ClawHub: {}",
57 status,
58 body,
59 ));
60
61 if attempt < CLAWHUB_MAX_RETRIES {
62 std::thread::sleep(delay);
63 continue;
64 }
65
66 anyhow::bail!(
68 "ClawHub request failed (HTTP {}) after {} retries: {}",
69 status,
70 CLAWHUB_MAX_RETRIES,
71 body,
72 );
73 }
74
75 anyhow::bail!(
77 "ClawHub request failed (HTTP {}): {}",
78 status,
79 resp.text().unwrap_or_default(),
80 );
81 }
82 Err(e) => {
83 if (e.is_timeout() || e.is_connect()) && attempt < CLAWHUB_MAX_RETRIES {
85 let delay = backoff_delay(attempt);
86 last_err = Some(e.into());
87 std::thread::sleep(delay);
88 continue;
89 }
90 return Err(e.into());
91 }
92 }
93 }
94
95 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("ClawHub request failed after retries")))
96}
97
98fn backoff_delay(attempt: u32) -> Duration {
100 let shift = attempt.saturating_sub(1).min(31);
101 let multiplier = 1u64 << shift;
102 let millis = CLAWHUB_BASE_DELAY.as_millis() as u64 * multiplier;
103 Duration::from_millis(millis).min(CLAWHUB_MAX_DELAY)
104}
105
106pub const DEFAULT_REGISTRY_URL: &str = "https://clawhub.ai";
110
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
115#[derive(Default)]
116pub enum SkillSource {
117 #[default]
119 Local,
120 Registry {
122 registry_url: String,
124 version: String,
126 },
127}
128
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct Skill {
133 pub name: String,
134 pub description: Option<String>,
135 pub path: PathBuf,
136 pub enabled: bool,
137 #[serde(default)]
139 pub instructions: String,
140 #[serde(default)]
142 pub metadata: SkillMetadata,
143 #[serde(default)]
145 pub source: SkillSource,
146 #[serde(default)]
150 pub linked_secrets: Vec<String>,
151}
152
153#[derive(Debug, Clone, Default, Serialize, Deserialize)]
155pub struct SkillMetadata {
156 #[serde(default)]
158 pub always: bool,
159 pub emoji: Option<String>,
161 pub homepage: Option<String>,
163 #[serde(default)]
165 pub os: Vec<String>,
166 #[serde(default)]
168 pub requires: SkillRequirements,
169 #[serde(rename = "primaryEnv")]
171 pub primary_env: Option<String>,
172}
173
174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
176pub struct SkillRequirements {
177 #[serde(default)]
179 pub bins: Vec<String>,
180 #[serde(rename = "anyBins", default)]
182 pub any_bins: Vec<String>,
183 #[serde(default)]
185 pub env: Vec<String>,
186 #[serde(default)]
188 pub config: Vec<String>,
189}
190
191#[derive(Debug, Clone)]
193pub struct GateCheckResult {
194 pub passed: bool,
195 pub missing_bins: Vec<String>,
196 pub missing_env: Vec<String>,
197 pub missing_config: Vec<String>,
198 pub wrong_os: bool,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct SkillManifest {
206 pub name: String,
208 pub version: String,
210 pub description: String,
212 #[serde(default)]
214 pub author: String,
215 #[serde(default)]
217 pub license: String,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub repository: Option<String>,
221 #[serde(default)]
224 pub required_secrets: Vec<String>,
225 #[serde(default)]
227 pub metadata: SkillMetadata,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct RegistryEntry {
233 #[serde(alias = "slug")]
235 pub name: String,
236 #[serde(default)]
237 pub version: String,
238 #[serde(alias = "summary")]
240 pub description: String,
241 #[serde(rename = "displayName", default)]
243 pub display_name: String,
244 #[serde(default)]
245 pub author: String,
246 #[serde(default)]
247 pub downloads: u64,
248 #[serde(default)]
249 pub required_secrets: Vec<String>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254struct RegistrySearchResponse {
255 #[serde(default)]
257 results: Vec<RegistryEntry>,
258 #[serde(default)]
260 skills: Vec<RegistryEntry>,
261 #[serde(default)]
262 total: usize,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct TrendingEntry {
270 pub name: String,
271 #[serde(default)]
272 pub description: String,
273 #[serde(default)]
274 pub author: String,
275 #[serde(default)]
276 pub downloads: u64,
277 #[serde(default)]
278 pub stars: u64,
279 #[serde(default)]
280 pub category: String,
281 #[serde(default)]
282 pub version: String,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
287struct TrendingResponse {
288 #[serde(default)]
289 results: Vec<TrendingEntry>,
290 #[serde(default)]
291 skills: Vec<TrendingEntry>,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct Category {
297 pub name: String,
298 #[serde(default)]
299 pub slug: String,
300 #[serde(default)]
301 pub description: String,
302 #[serde(default)]
303 pub count: u64,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
308struct CategoriesResponse {
309 #[serde(default)]
310 categories: Vec<Category>,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct ClawHubProfile {
316 #[serde(default)]
317 pub username: String,
318 #[serde(default)]
319 pub display_name: String,
320 #[serde(default)]
321 pub email: String,
322 #[serde(default)]
323 pub bio: String,
324 #[serde(default)]
325 pub published_count: u64,
326 #[serde(default)]
327 pub starred_count: u64,
328 #[serde(default)]
329 pub joined: String,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
334struct ProfileResponse {
335 #[serde(default)]
336 pub profile: Option<ClawHubProfile>,
337 #[serde(default)]
338 pub error: Option<String>,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct StarredEntry {
344 pub name: String,
345 #[serde(default)]
346 pub description: String,
347 #[serde(default)]
348 pub author: String,
349 #[serde(default)]
350 pub version: String,
351 #[serde(default)]
352 pub starred_at: String,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
357struct StarredResponse {
358 #[serde(default)]
359 results: Vec<StarredEntry>,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct AuthResponse {
365 #[serde(default)]
366 pub ok: bool,
367 #[serde(default)]
368 pub token: Option<String>,
369 #[serde(default)]
370 pub username: Option<String>,
371 #[serde(default)]
372 pub message: Option<String>,
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize)]
377pub struct RegistrySkillDetail {
378 pub name: String,
379 #[serde(default)]
380 pub description: String,
381 #[serde(default)]
382 pub author: String,
383 #[serde(default)]
384 pub version: String,
385 #[serde(default)]
386 pub license: String,
387 #[serde(default)]
388 pub repository: Option<String>,
389 #[serde(default)]
390 pub homepage: Option<String>,
391 #[serde(default)]
392 pub downloads: u64,
393 #[serde(default)]
394 pub stars: u64,
395 #[serde(default)]
396 pub created_at: String,
397 #[serde(default)]
398 pub updated_at: String,
399 #[serde(default)]
400 pub readme: Option<String>,
401 #[serde(default)]
402 pub required_secrets: Vec<String>,
403 #[serde(default)]
404 pub categories: Vec<String>,
405}
406
407pub struct SkillManager {
411 skills_dirs: Vec<PathBuf>,
412 skills: Vec<Skill>,
413 env_vars: HashMap<String, String>,
415 registry_url: String,
417 registry_token: Option<String>,
419}
420
421impl SkillManager {
422 pub fn new(skills_dir: PathBuf) -> Self {
423 Self {
424 skills_dirs: vec![skills_dir],
425 skills: Vec::new(),
426 env_vars: std::env::vars().collect(),
427 registry_url: DEFAULT_REGISTRY_URL.to_string(),
428 registry_token: None,
429 }
430 }
431
432 pub fn with_dirs(dirs: Vec<PathBuf>) -> Self {
434 Self {
435 skills_dirs: dirs,
436 skills: Vec::new(),
437 env_vars: std::env::vars().collect(),
438 registry_url: DEFAULT_REGISTRY_URL.to_string(),
439 registry_token: None,
440 }
441 }
442
443 pub fn set_registry(&mut self, url: &str, token: Option<String>) {
445 self.registry_url = url.to_string();
446 self.registry_token = token;
447 }
448
449 pub fn primary_skills_dir(&self) -> Option<&Path> {
453 self.skills_dirs.last().map(|p| p.as_path())
454 }
455
456 pub fn load_skills(&mut self) -> Result<()> {
459 self.skills.clear();
460 let mut seen_names: HashMap<String, usize> = HashMap::new();
461
462 for dir in &self.skills_dirs.clone() {
463 if !dir.exists() {
464 continue;
465 }
466
467 for entry in std::fs::read_dir(dir)? {
469 let entry = entry?;
470 let path = entry.path();
471
472 if path.is_dir() {
473 let skill_file = path.join("SKILL.md");
474 if skill_file.exists() {
475 if let Ok(skill) = self.load_skill_md(&skill_file) {
476 if let Some(&idx) = seen_names.get(&skill.name) {
478 self.skills[idx] = skill.clone();
479 } else {
480 seen_names.insert(skill.name.clone(), self.skills.len());
481 self.skills.push(skill);
482 }
483 }
484 }
485 }
486
487 if path.is_file() {
489 if let Some(ext) = path.extension() {
490 if ext == "skill" || ext == "json" || ext == "yaml" || ext == "yml" {
491 if let Ok(skill) = self.load_skill_legacy(&path) {
492 if let Some(&idx) = seen_names.get(&skill.name) {
493 self.skills[idx] = skill.clone();
494 } else {
495 seen_names.insert(skill.name.clone(), self.skills.len());
496 self.skills.push(skill);
497 }
498 }
499 }
500 }
501 }
502 }
503 }
504
505 Ok(())
506 }
507
508 fn load_skill_md(&self, path: &Path) -> Result<Skill> {
510 let content = std::fs::read_to_string(path)?;
511 let (frontmatter, instructions) = parse_frontmatter(&content)?;
512
513 let name = frontmatter
515 .get("name")
516 .and_then(|v| v.as_str())
517 .ok_or_else(|| anyhow::anyhow!("Skill missing 'name' in frontmatter"))?
518 .to_string();
519
520 let description = frontmatter
521 .get("description")
522 .and_then(|v| v.as_str())
523 .map(|s| s.to_string());
524
525 let metadata = if let Some(meta_val) = frontmatter.get("metadata") {
527 if let Some(meta_str) = meta_val.as_str() {
529 serde_json::from_str(meta_str).unwrap_or_default()
530 } else if let Some(openclaw) = meta_val.get("openclaw") {
531 let json_str = serde_json::to_string(&openclaw).unwrap_or_default();
533 serde_json::from_str(&json_str).unwrap_or_default()
534 } else {
535 SkillMetadata::default()
536 }
537 } else {
538 SkillMetadata::default()
539 };
540
541 let base_dir = path.parent().unwrap_or(Path::new("."));
543 let instructions = instructions.replace("{baseDir}", &base_dir.display().to_string());
544
545 let linked_secrets: Vec<String> = frontmatter
547 .get("linked_secrets")
548 .and_then(|v| v.as_sequence())
549 .map(|seq| {
550 seq.iter()
551 .filter_map(|v| v.as_str().map(|s| s.to_string()))
552 .collect()
553 })
554 .unwrap_or_default();
555
556 Ok(Skill {
557 name,
558 description,
559 path: path.to_path_buf(),
560 enabled: true,
561 instructions,
562 metadata,
563 source: SkillSource::Local,
564 linked_secrets,
565 })
566 }
567
568 fn load_skill_legacy(&self, path: &Path) -> Result<Skill> {
570 let is_json = path.extension().is_some_and(|e| e == "json" || e == "skill");
571 let is_yaml = path.extension().is_some_and(|e| e == "yaml" || e == "yml");
572
573 if !is_json && !is_yaml {
574 anyhow::bail!("Unsupported skill file format: {:?}", path);
575 }
576
577 let content = std::fs::read_to_string(path)?;
578
579 let skill: Skill = if is_yaml {
580 serde_yaml::from_str(&content)?
581 } else {
582 serde_json::from_str(&content)?
583 };
584
585 Ok(skill)
586 }
587
588 pub fn check_gates(&self, skill: &Skill) -> GateCheckResult {
590 let mut result = GateCheckResult {
591 passed: true,
592 missing_bins: Vec::new(),
593 missing_env: Vec::new(),
594 missing_config: Vec::new(),
595 wrong_os: false,
596 };
597
598 if skill.metadata.always {
600 return result;
601 }
602
603 if !skill.metadata.os.is_empty() {
605 let current_os = if cfg!(target_os = "macos") {
606 "darwin"
607 } else if cfg!(target_os = "linux") {
608 "linux"
609 } else if cfg!(target_os = "windows") {
610 "win32"
611 } else {
612 "unknown"
613 };
614
615 if !skill.metadata.os.iter().any(|os| os == current_os) {
616 result.wrong_os = true;
617 result.passed = false;
618 }
619 }
620
621 for bin in &skill.metadata.requires.bins {
623 if !self.binary_exists(bin) {
624 result.missing_bins.push(bin.clone());
625 result.passed = false;
626 }
627 }
628
629 if !skill.metadata.requires.any_bins.is_empty() {
631 let any_found = skill
632 .metadata
633 .requires
634 .any_bins
635 .iter()
636 .any(|bin| self.binary_exists(bin));
637 if !any_found {
638 result.missing_bins.extend(skill.metadata.requires.any_bins.clone());
639 result.passed = false;
640 }
641 }
642
643 for env_var in &skill.metadata.requires.env {
645 if !self.env_vars.contains_key(env_var) {
646 result.missing_env.push(env_var.clone());
647 result.passed = false;
648 }
649 }
650
651 result.missing_config = skill.metadata.requires.config.clone();
654 if !result.missing_config.is_empty() {
655 }
657
658 result
659 }
660
661 fn binary_exists(&self, name: &str) -> bool {
663 if let Ok(path_var) = std::env::var("PATH") {
664 for dir in std::env::split_paths(&path_var) {
665 let candidate = dir.join(name);
666 if candidate.exists() {
667 return true;
668 }
669 #[cfg(windows)]
671 {
672 let candidate_exe = dir.join(format!("{}.exe", name));
673 if candidate_exe.exists() {
674 return true;
675 }
676 }
677 }
678 }
679 false
680 }
681
682 pub fn get_skills(&self) -> &[Skill] {
684 &self.skills
685 }
686
687 pub fn get_eligible_skills(&self) -> Vec<&Skill> {
689 self.skills
690 .iter()
691 .filter(|s| s.enabled && self.check_gates(s).passed)
692 .collect()
693 }
694
695 pub fn get_skill(&self, name: &str) -> Option<&Skill> {
697 self.skills.iter().find(|s| s.name == name)
698 }
699
700 pub fn set_skill_enabled(&mut self, name: &str, enabled: bool) -> Result<()> {
702 if let Some(skill) = self.skills.iter_mut().find(|s| s.name == name) {
703 skill.enabled = enabled;
704 Ok(())
705 } else {
706 anyhow::bail!("Skill not found: {}", name)
707 }
708 }
709
710 pub fn generate_prompt_context(&self) -> String {
712 let eligible = self.get_eligible_skills();
713
714 let mut context = String::from("## Skills (mandatory)\n\n");
715 context.push_str("Before replying: scan <available_skills> <description> entries.\n");
716 context.push_str("- If exactly one skill clearly applies: read its SKILL.md at <location> with `read_file`, then follow it.\n");
717 context.push_str("- If multiple could apply: choose the most specific one, then read/follow it.\n");
718 context.push_str("- If none clearly apply: do not read any SKILL.md.\n");
719 context.push_str("Constraints: never read more than one skill up front; only read after selecting.\n\n");
720 context.push_str("The following skills provide specialized instructions for specific tasks.\n");
721 context.push_str("Use the read_file tool to load a skill's file when the task matches its description.\n");
722 context.push_str("When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.\n\n");
723
724 if eligible.is_empty() {
725 context.push_str("No skills are currently loaded.\n\n");
726 context.push_str("To find and install skills:\n");
727 context.push_str("- Browse: https://clawhub.com\n");
728 context.push_str("- Install: `/skill install <skill-name>` or `rustyclaw clawhub install <skill-name>`\n\n");
729 self.append_skill_creation_instructions(&mut context);
730 return context;
731 }
732
733 context.push_str("<available_skills>\n");
734
735 for skill in eligible {
736 context.push_str(" <skill>\n");
737 context.push_str(&format!(" <name>{}</name>\n", skill.name));
738 if let Some(ref desc) = skill.description {
739 context.push_str(&format!(" <description>{}</description>\n", desc));
740 }
741 context.push_str(&format!(" <location>{}</location>\n", skill.path.display()));
742 context.push_str(" </skill>\n");
743 }
744
745 context.push_str("</available_skills>\n\n");
746
747 context.push_str("To find more skills: https://clawhub.com\n");
749 context.push_str("To install a skill: `/skill install <skill-name>` or `rustyclaw clawhub install <skill-name>`\n\n");
750
751 self.append_skill_creation_instructions(&mut context);
753
754 context
755 }
756
757 fn append_skill_creation_instructions(&self, context: &mut String) {
759 context.push_str("## Creating New Skills\n\n");
760 context.push_str("When a user asks you to create, author, or scaffold a new skill, use the `skill_create` tool.\n");
761 context.push_str("This tool creates the skill directory and SKILL.md file in the correct location.\n\n");
762 context.push_str("A skill is a directory containing a `SKILL.md` file with YAML frontmatter and markdown instructions.\n\n");
763 context.push_str("<skill_template>\n");
764 context.push_str("```\n");
765 context.push_str("---\n");
766 context.push_str("name: my-skill-name\n");
767 context.push_str("description: A concise one-line description of what this skill does\n");
768 context.push_str("metadata: {\"openclaw\": {\"emoji\": \"🔧\"}}\n");
769 context.push_str("---\n\n");
770 context.push_str("# Skill Title\n\n");
771 context.push_str("Detailed instructions for the agent to follow when this skill is activated.\n");
772 context.push_str("Include step-by-step guidance, tool usage patterns, and any constraints.\n");
773 context.push_str("```\n");
774 context.push_str("</skill_template>\n\n");
775 context.push_str("Frontmatter fields:\n");
776 context.push_str("- `name` (required): kebab-case identifier, used as the directory name\n");
777 context.push_str("- `description` (required): shown in skill listings, used for matching\n");
778 context.push_str("- `metadata` (optional): JSON with gating requirements, e.g.\n");
779 context.push_str(" `{\"openclaw\": {\"emoji\": \"⚡\", \"always\": false, \"requires\": {\"bins\": [\"git\", \"node\"]}}}`\n\n");
780
781 if let Some(dir) = self.primary_skills_dir() {
782 context.push_str(&format!("Skills directory: {}\n", dir.display()));
783 }
784 }
785
786 pub fn get_skill_instructions(&self, name: &str) -> Option<String> {
788 self.get_skill(name).map(|s| s.instructions.clone())
789 }
790
791 pub fn create_skill(
799 &mut self,
800 name: &str,
801 description: &str,
802 instructions: &str,
803 metadata_json: Option<&str>,
804 ) -> Result<PathBuf> {
805 if name.is_empty() {
807 anyhow::bail!("Skill name cannot be empty");
808 }
809 if name.contains('/') || name.contains('\\') || name.contains(' ') {
810 anyhow::bail!(
811 "Skill name must be a simple identifier (no slashes or spaces): {name}"
812 );
813 }
814
815 let skills_dir = self
816 .primary_skills_dir()
817 .ok_or_else(|| anyhow::anyhow!("No skills directory configured"))?
818 .to_path_buf();
819
820 let skill_dir = skills_dir.join(name);
821 if skill_dir.join("SKILL.md").exists() {
822 anyhow::bail!("Skill already exists: {name} (at {})", skill_dir.display());
823 }
824
825 std::fs::create_dir_all(&skill_dir)?;
826
827 let mut fm = format!("---\nname: {name}\ndescription: {description}\n");
829 if let Some(meta) = metadata_json {
830 fm.push_str(&format!("metadata: {meta}\n"));
831 }
832 fm.push_str("---\n\n");
833
834 let content = format!("{fm}{instructions}\n");
835 let skill_path = skill_dir.join("SKILL.md");
836 std::fs::write(&skill_path, &content)?;
837
838 self.load_skills()?;
840
841 Ok(skill_path)
842 }
843
844 pub fn link_secret(&mut self, skill_name: &str, secret_name: &str) -> Result<()> {
849 let skill = self
850 .skills
851 .iter_mut()
852 .find(|s| s.name == skill_name)
853 .ok_or_else(|| anyhow::anyhow!("Skill not found: {}", skill_name))?;
854
855 if !skill.linked_secrets.contains(&secret_name.to_string()) {
856 skill.linked_secrets.push(secret_name.to_string());
857 }
858 Ok(())
859 }
860
861 pub fn unlink_secret(&mut self, skill_name: &str, secret_name: &str) -> Result<()> {
863 let skill = self
864 .skills
865 .iter_mut()
866 .find(|s| s.name == skill_name)
867 .ok_or_else(|| anyhow::anyhow!("Skill not found: {}", skill_name))?;
868
869 skill.linked_secrets.retain(|s| s != secret_name);
870 Ok(())
871 }
872
873 pub fn get_linked_secrets(&self, skill_name: &str) -> Vec<String> {
875 self.get_skill(skill_name)
876 .map(|s| s.linked_secrets.clone())
877 .unwrap_or_default()
878 }
879
880 pub fn remove_skill(&mut self, name: &str) -> Result<()> {
885 let idx = self
886 .skills
887 .iter()
888 .position(|s| s.name == name)
889 .ok_or_else(|| anyhow::anyhow!("Skill not found: {}", name))?;
890
891 let skill = self.skills.remove(idx);
892
893 if let Some(parent) = skill.path.parent() {
896 for dir in &self.skills_dirs {
897 if parent.starts_with(dir) || parent == dir.as_path() {
898 if parent.is_dir() {
899 let _ = std::fs::remove_dir_all(parent);
900 }
901 break;
902 }
903 }
904 }
905
906 Ok(())
907 }
908
909 pub fn skill_info(&self, name: &str) -> Option<String> {
913 let skill = self.get_skill(name)?;
914 let gate = self.check_gates(skill);
915 let mut out = String::new();
916 out.push_str(&format!("Skill: {}\n", skill.name));
917 if let Some(ref desc) = skill.description {
918 out.push_str(&format!("Description: {}\n", desc));
919 }
920 out.push_str(&format!("Enabled: {}\n", skill.enabled));
921 out.push_str(&format!("Gates passed: {}\n", gate.passed));
922 out.push_str(&format!("Path: {}\n", skill.path.display()));
923 match &skill.source {
924 SkillSource::Local => out.push_str("Source: local\n"),
925 SkillSource::Registry { registry_url, version } => {
926 out.push_str(&format!("Source: registry ({}@{})\n", registry_url, version));
927 }
928 }
929 if !skill.linked_secrets.is_empty() {
930 out.push_str(&format!("Linked secrets: {}\n", skill.linked_secrets.join(", ")));
931 }
932 if !gate.missing_bins.is_empty() {
933 out.push_str(&format!("Missing binaries: {}\n", gate.missing_bins.join(", ")));
934 }
935 if !gate.missing_env.is_empty() {
936 out.push_str(&format!("Missing env vars: {}\n", gate.missing_env.join(", ")));
937 }
938 Some(out)
939 }
940
941 fn registry_reachable(&self) -> bool {
946 let client = reqwest::blocking::Client::new();
947 client
948 .head(&self.registry_url)
949 .timeout(std::time::Duration::from_secs(3))
950 .send()
951 .is_ok()
952 }
953
954 pub fn search_registry(&self, query: &str) -> Result<Vec<RegistryEntry>> {
959 match self.search_registry_remote(query) {
961 Ok(results) => return Ok(results),
962 Err(_) => {
963 }
965 }
966
967 let q_lower = query.to_lowercase();
969 let local_results: Vec<RegistryEntry> = self
970 .skills
971 .iter()
972 .filter(|s| {
973 s.name.to_lowercase().contains(&q_lower)
974 || s.description
975 .as_deref()
976 .unwrap_or_default()
977 .to_lowercase()
978 .contains(&q_lower)
979 })
980 .map(|s| RegistryEntry {
981 name: s.name.clone(),
982 display_name: String::new(),
983 version: match &s.source {
984 SkillSource::Registry { version, .. } => version.clone(),
985 SkillSource::Local => "local".to_string(),
986 },
987 description: s.description.clone().unwrap_or_default(),
988 author: String::new(),
989 downloads: 0,
990 required_secrets: s.linked_secrets.clone(),
991 })
992 .collect();
993
994 Ok(local_results)
995 }
996
997 fn search_registry_remote(&self, query: &str) -> Result<Vec<RegistryEntry>> {
999 let url = format!(
1001 "{}/api/search?q={}",
1002 self.registry_url,
1003 urlencoding::encode(query),
1004 );
1005
1006 let client = reqwest::blocking::Client::new();
1007 let resp = blocking_request_with_retry(
1008 &client,
1009 &url,
1010 self.registry_token.as_deref(),
1011 Duration::from_secs(10),
1012 )
1013 .context("ClawHub registry is not reachable")?;
1014
1015 let body: RegistrySearchResponse = resp.json().context("Failed to parse registry response")?;
1016
1017 let entries = if !body.results.is_empty() {
1019 body.results
1020 } else {
1021 body.skills
1022 };
1023
1024 Ok(entries)
1025 }
1026
1027 pub fn install_from_registry(&mut self, name: &str, version: Option<&str>) -> Result<Skill> {
1030 if !self.registry_reachable() {
1031 anyhow::bail!(
1032 "ClawHub registry ({}) is not reachable. \
1033 Check your internet connection or set a custom registry URL \
1034 with `clawhub_url` in your config.",
1035 self.registry_url,
1036 );
1037 }
1038
1039 let mut url = format!("{}/api/v1/download?slug={}", self.registry_url, urlencoding::encode(name));
1041 if let Some(v) = version {
1042 url.push_str(&format!("&version={}", urlencoding::encode(v)));
1043 }
1044
1045 let client = reqwest::blocking::Client::new();
1046 let resp = blocking_request_with_retry(
1047 &client,
1048 &url,
1049 self.registry_token.as_deref(),
1050 Duration::from_secs(30),
1051 )
1052 .context("Failed to download skill from ClawHub")?;
1053
1054 let zip_bytes = resp.bytes().context("Failed to read zip data")?;
1056
1057 let skills_dir = self
1059 .skills_dirs
1060 .last()
1061 .cloned()
1062 .ok_or_else(|| anyhow::anyhow!("No skills directory configured"))?;
1063
1064 let skill_dir = skills_dir.join(name);
1065 std::fs::create_dir_all(&skill_dir)?;
1066
1067 let cursor = std::io::Cursor::new(zip_bytes);
1069 let mut archive = zip::ZipArchive::new(cursor).context("Invalid zip archive")?;
1070
1071 for i in 0..archive.len() {
1072 let mut file = archive.by_index(i)?;
1073 let outpath = skill_dir.join(file.name());
1074
1075 if file.name().ends_with('/') {
1076 std::fs::create_dir_all(&outpath)?;
1077 } else {
1078 if let Some(parent) = outpath.parent() {
1079 std::fs::create_dir_all(parent)?;
1080 }
1081 let mut outfile = std::fs::File::create(&outpath)?;
1082 std::io::copy(&mut file, &mut outfile)?;
1083 }
1084 }
1085
1086 let clawhub_dir = skill_dir.join(".clawhub");
1088 std::fs::create_dir_all(&clawhub_dir)?;
1089 let meta = serde_json::json!({
1090 "version": 1,
1091 "registry": self.registry_url,
1092 "slug": name,
1093 "installedVersion": version.unwrap_or("latest"),
1094 "installedAt": std::time::SystemTime::now()
1095 .duration_since(std::time::UNIX_EPOCH)
1096 .unwrap_or_default()
1097 .as_millis() as u64,
1098 });
1099 std::fs::write(clawhub_dir.join("install.json"), serde_json::to_string_pretty(&meta)?)?;
1100
1101 let skill_md_path = skill_dir.join("SKILL.md");
1103 let mut skill = self.load_skill_md(&skill_md_path)?;
1104 skill.source = SkillSource::Registry {
1105 registry_url: self.registry_url.clone(),
1106 version: version.unwrap_or("latest").to_string(),
1107 };
1108
1109 if let Some(idx) = self.skills.iter().position(|s| s.name == skill.name) {
1111 self.skills[idx] = skill.clone();
1112 } else {
1113 self.skills.push(skill.clone());
1114 }
1115
1116 Ok(skill)
1117 }
1118
1119 pub fn publish_to_registry(&self, skill_name: &str) -> Result<String> {
1121 let skill = self
1122 .get_skill(skill_name)
1123 .ok_or_else(|| anyhow::anyhow!("Skill not found: {}", skill_name))?;
1124
1125 let token = self
1126 .registry_token
1127 .as_ref()
1128 .ok_or_else(|| anyhow::anyhow!("ClawHub auth token required for publishing. Set clawhub_token in config."))?;
1129
1130 let content = std::fs::read_to_string(&skill.path)
1132 .context("Failed to read skill file")?;
1133
1134 let manifest = SkillManifest {
1135 name: skill.name.clone(),
1136 version: "0.1.0".to_string(), description: skill.description.clone().unwrap_or_default(),
1138 author: String::new(),
1139 license: "MIT".to_string(),
1140 repository: skill.metadata.homepage.clone(),
1141 required_secrets: skill.linked_secrets.clone(),
1142 metadata: skill.metadata.clone(),
1143 };
1144
1145 let payload = serde_json::json!({
1146 "manifest": manifest,
1147 "skill_md": content,
1148 });
1149
1150 if !self.registry_reachable() {
1151 anyhow::bail!(
1152 "ClawHub registry ({}) is not reachable. \
1153 Check your internet connection or set a custom registry URL \
1154 with `clawhub_url` in your config.",
1155 self.registry_url,
1156 );
1157 }
1158
1159 let url = format!("{}/skills/publish", self.registry_url);
1160 let client = reqwest::blocking::Client::new();
1161 let resp = client
1162 .post(&url)
1163 .bearer_auth(token)
1164 .json(&payload)
1165 .timeout(std::time::Duration::from_secs(30))
1166 .send()
1167 .context("Failed to publish to ClawHub")?;
1168
1169 if !resp.status().is_success() {
1170 anyhow::bail!(
1171 "ClawHub publish failed (HTTP {}): {}",
1172 resp.status(),
1173 resp.text().unwrap_or_default(),
1174 );
1175 }
1176
1177 Ok(format!(
1178 "Published {} v{} to {}",
1179 manifest.name, manifest.version, self.registry_url,
1180 ))
1181 }
1182
1183 pub fn registry_url(&self) -> &str {
1187 &self.registry_url
1188 }
1189
1190 pub fn registry_token(&self) -> Option<&str> {
1192 self.registry_token.as_deref()
1193 }
1194
1195 pub fn auth_login(&self, username: &str, password: &str) -> Result<AuthResponse> {
1198 let url = format!("{}/api/v1/auth/login", self.registry_url);
1199 let client = reqwest::blocking::Client::new();
1200 let payload = serde_json::json!({
1201 "username": username,
1202 "password": password,
1203 });
1204
1205 let resp = client
1206 .post(&url)
1207 .json(&payload)
1208 .timeout(std::time::Duration::from_secs(10))
1209 .send()
1210 .context("Failed to connect to ClawHub for authentication")?;
1211
1212 if !resp.status().is_success() {
1213 let status = resp.status();
1214 let body = resp.text().unwrap_or_default();
1215 anyhow::bail!("ClawHub auth failed (HTTP {}): {}", status, body);
1216 }
1217
1218 let auth: AuthResponse = resp.json().context("Failed to parse auth response")?;
1219 Ok(auth)
1220 }
1221
1222 pub fn auth_token(&self, token: &str) -> Result<AuthResponse> {
1225 let url = format!("{}/api/v1/auth/verify", self.registry_url);
1226 let client = reqwest::blocking::Client::new();
1227
1228 let resp = client
1229 .get(&url)
1230 .bearer_auth(token)
1231 .timeout(std::time::Duration::from_secs(10))
1232 .send()
1233 .context("Failed to connect to ClawHub for token verification")?;
1234
1235 if !resp.status().is_success() {
1236 let status = resp.status();
1237 let body = resp.text().unwrap_or_default();
1238 anyhow::bail!("ClawHub token verification failed (HTTP {}): {}", status, body);
1239 }
1240
1241 let auth: AuthResponse = resp.json().context("Failed to parse auth response")?;
1242 Ok(auth)
1243 }
1244
1245 pub fn auth_status(&self) -> Result<String> {
1247 match &self.registry_token {
1248 Some(token) => {
1249 match self.auth_token(token) {
1250 Ok(resp) if resp.ok => {
1251 let user = resp.username.unwrap_or_else(|| "unknown".into());
1252 Ok(format!("Authenticated as '{}' on {}", user, self.registry_url))
1253 }
1254 Ok(_) => Ok(format!("Token configured but invalid on {}", self.registry_url)),
1255 Err(_) => Ok(format!(
1256 "Token configured but registry unreachable ({})",
1257 self.registry_url,
1258 )),
1259 }
1260 }
1261 None => Ok(format!("Not authenticated. Run `/clawhub auth login` or set clawhub_token in config.")),
1262 }
1263 }
1264
1265 pub fn trending(&self, category: Option<&str>, limit: Option<usize>) -> Result<Vec<TrendingEntry>> {
1267 let mut url = format!("{}/api/v1/trending", self.registry_url);
1268 let mut params = vec![];
1269 if let Some(cat) = category {
1270 params.push(format!("category={}", urlencoding::encode(cat)));
1271 }
1272 if let Some(n) = limit {
1273 params.push(format!("limit={}", n));
1274 }
1275 if !params.is_empty() {
1276 url.push('?');
1277 url.push_str(¶ms.join("&"));
1278 }
1279
1280 let client = reqwest::blocking::Client::new();
1281 let mut req = client.get(&url);
1282 if let Some(ref token) = self.registry_token {
1283 req = req.bearer_auth(token);
1284 }
1285
1286 let resp = req
1287 .timeout(std::time::Duration::from_secs(5))
1288 .send()
1289 .context("ClawHub registry is not reachable")?;
1290
1291 if !resp.status().is_success() {
1292 anyhow::bail!(
1293 "ClawHub trending request failed (HTTP {}): {}",
1294 resp.status(),
1295 resp.text().unwrap_or_default(),
1296 );
1297 }
1298
1299 let body: TrendingResponse = resp.json().context("Failed to parse trending response")?;
1300 let entries = if !body.results.is_empty() {
1301 body.results
1302 } else {
1303 body.skills
1304 };
1305
1306 Ok(entries)
1307 }
1308
1309 pub fn categories(&self) -> Result<Vec<Category>> {
1311 let url = format!("{}/api/v1/categories", self.registry_url);
1312 let client = reqwest::blocking::Client::new();
1313 let mut req = client.get(&url);
1314 if let Some(ref token) = self.registry_token {
1315 req = req.bearer_auth(token);
1316 }
1317
1318 let resp = req
1319 .timeout(std::time::Duration::from_secs(5))
1320 .send()
1321 .context("ClawHub registry is not reachable")?;
1322
1323 if !resp.status().is_success() {
1324 anyhow::bail!(
1325 "ClawHub categories request failed (HTTP {}): {}",
1326 resp.status(),
1327 resp.text().unwrap_or_default(),
1328 );
1329 }
1330
1331 let body: CategoriesResponse = resp.json().context("Failed to parse categories response")?;
1332 Ok(body.categories)
1333 }
1334
1335 pub fn profile(&self) -> Result<ClawHubProfile> {
1337 let token = self.registry_token.as_ref().ok_or_else(|| {
1338 anyhow::anyhow!("Not authenticated. Run `/clawhub auth login` or set clawhub_token in config.")
1339 })?;
1340
1341 let url = format!("{}/api/v1/profile", self.registry_url);
1342 let client = reqwest::blocking::Client::new();
1343
1344 let resp = client
1345 .get(&url)
1346 .bearer_auth(token)
1347 .timeout(std::time::Duration::from_secs(5))
1348 .send()
1349 .context("ClawHub registry is not reachable")?;
1350
1351 if !resp.status().is_success() {
1352 anyhow::bail!(
1353 "ClawHub profile request failed (HTTP {}): {}",
1354 resp.status(),
1355 resp.text().unwrap_or_default(),
1356 );
1357 }
1358
1359 let body: ProfileResponse = resp.json().context("Failed to parse profile response")?;
1360 match body.profile {
1361 Some(profile) => Ok(profile),
1362 None => anyhow::bail!(body.error.unwrap_or_else(|| "Profile not found".into())),
1363 }
1364 }
1365
1366 pub fn starred(&self) -> Result<Vec<StarredEntry>> {
1368 let token = self.registry_token.as_ref().ok_or_else(|| {
1369 anyhow::anyhow!("Not authenticated. Run `/clawhub auth login` or set clawhub_token in config.")
1370 })?;
1371
1372 let url = format!("{}/api/v1/starred", self.registry_url);
1373 let client = reqwest::blocking::Client::new();
1374
1375 let resp = client
1376 .get(&url)
1377 .bearer_auth(token)
1378 .timeout(std::time::Duration::from_secs(5))
1379 .send()
1380 .context("ClawHub registry is not reachable")?;
1381
1382 if !resp.status().is_success() {
1383 anyhow::bail!(
1384 "ClawHub starred request failed (HTTP {}): {}",
1385 resp.status(),
1386 resp.text().unwrap_or_default(),
1387 );
1388 }
1389
1390 let body: StarredResponse = resp.json().context("Failed to parse starred response")?;
1391 Ok(body.results)
1392 }
1393
1394 pub fn star(&self, skill_name: &str) -> Result<String> {
1396 let token = self.registry_token.as_ref().ok_or_else(|| {
1397 anyhow::anyhow!("Not authenticated. Run `/clawhub auth login` first.")
1398 })?;
1399
1400 let url = format!(
1401 "{}/api/v1/skills/{}/star",
1402 self.registry_url,
1403 urlencoding::encode(skill_name),
1404 );
1405 let client = reqwest::blocking::Client::new();
1406
1407 let resp = client
1408 .post(&url)
1409 .bearer_auth(token)
1410 .timeout(std::time::Duration::from_secs(5))
1411 .send()
1412 .context("ClawHub registry is not reachable")?;
1413
1414 if !resp.status().is_success() {
1415 anyhow::bail!(
1416 "ClawHub star failed (HTTP {}): {}",
1417 resp.status(),
1418 resp.text().unwrap_or_default(),
1419 );
1420 }
1421
1422 Ok(format!("Starred '{}'", skill_name))
1423 }
1424
1425 pub fn unstar(&self, skill_name: &str) -> Result<String> {
1427 let token = self.registry_token.as_ref().ok_or_else(|| {
1428 anyhow::anyhow!("Not authenticated. Run `/clawhub auth login` first.")
1429 })?;
1430
1431 let url = format!(
1432 "{}/api/v1/skills/{}/star",
1433 self.registry_url,
1434 urlencoding::encode(skill_name),
1435 );
1436 let client = reqwest::blocking::Client::new();
1437
1438 let resp = client
1439 .delete(&url)
1440 .bearer_auth(token)
1441 .timeout(std::time::Duration::from_secs(5))
1442 .send()
1443 .context("ClawHub registry is not reachable")?;
1444
1445 if !resp.status().is_success() {
1446 anyhow::bail!(
1447 "ClawHub unstar failed (HTTP {}): {}",
1448 resp.status(),
1449 resp.text().unwrap_or_default(),
1450 );
1451 }
1452
1453 Ok(format!("Unstarred '{}'", skill_name))
1454 }
1455
1456 pub fn registry_info(&self, skill_name: &str) -> Result<RegistrySkillDetail> {
1458 let url = format!(
1459 "{}/api/v1/skills/{}",
1460 self.registry_url,
1461 urlencoding::encode(skill_name),
1462 );
1463
1464 let client = reqwest::blocking::Client::new();
1465 let mut req = client.get(&url);
1466 if let Some(ref token) = self.registry_token {
1467 req = req.bearer_auth(token);
1468 }
1469
1470 let resp = req
1471 .timeout(std::time::Duration::from_secs(5))
1472 .send()
1473 .context("ClawHub registry is not reachable")?;
1474
1475 if !resp.status().is_success() {
1476 anyhow::bail!(
1477 "ClawHub skill info failed (HTTP {}): {}",
1478 resp.status(),
1479 resp.text().unwrap_or_default(),
1480 );
1481 }
1482
1483 let detail: RegistrySkillDetail =
1484 resp.json().context("Failed to parse skill detail response")?;
1485 Ok(detail)
1486 }
1487}
1488
1489fn parse_frontmatter(content: &str) -> Result<(serde_yaml::Value, String)> {
1491 let content = content.trim_start();
1492
1493 if !content.starts_with("---") {
1494 return Ok((serde_yaml::Value::Mapping(Default::default()), content.to_string()));
1496 }
1497
1498 let after_first = &content[3..];
1500 if let Some(end_idx) = after_first.find("\n---") {
1501 let frontmatter_str = &after_first[..end_idx];
1502 let instructions = after_first[end_idx + 4..].trim_start().to_string();
1503
1504 let frontmatter: serde_yaml::Value = serde_yaml::from_str(frontmatter_str)
1505 .context("Failed to parse YAML frontmatter")?;
1506
1507 Ok((frontmatter, instructions))
1508 } else {
1509 Ok((serde_yaml::Value::Mapping(Default::default()), content.to_string()))
1511 }
1512}
1513
1514#[cfg(test)]
1515mod tests {
1516 use super::*;
1517
1518 #[test]
1519 fn test_skill_manager_creation() {
1520 let temp_dir = std::env::temp_dir().join("rustyclaw_test_skills");
1521 let manager = SkillManager::new(temp_dir);
1522 assert_eq!(manager.get_skills().len(), 0);
1523 }
1524
1525 #[test]
1526 fn test_parse_frontmatter_with_yaml() {
1527 let content = r#"---
1528name: test-skill
1529description: A test skill
1530---
1531
1532# Instructions
1533
1534Do the thing.
1535"#;
1536 let (fm, instructions) = parse_frontmatter(content).unwrap();
1537 assert_eq!(fm["name"].as_str(), Some("test-skill"));
1538 assert_eq!(fm["description"].as_str(), Some("A test skill"));
1539 assert!(instructions.contains("Do the thing"));
1540 }
1541
1542 #[test]
1543 fn test_parse_frontmatter_without_yaml() {
1544 let content = "# Just some markdown\n\nNo frontmatter here.";
1545 let (fm, instructions) = parse_frontmatter(content).unwrap();
1546 assert!(fm.is_mapping());
1547 assert!(instructions.contains("Just some markdown"));
1548 }
1549
1550 #[test]
1551 fn test_binary_exists() {
1552 let manager = SkillManager::new(std::env::temp_dir());
1553 #[cfg(unix)]
1555 assert!(manager.binary_exists("ls"));
1556 #[cfg(windows)]
1557 assert!(manager.binary_exists("cmd"));
1558 }
1559
1560 #[test]
1561 fn test_gate_check_always() {
1562 let manager = SkillManager::new(std::env::temp_dir());
1563 let skill = Skill {
1564 name: "test".into(),
1565 description: None,
1566 path: PathBuf::new(),
1567 enabled: true,
1568 instructions: String::new(),
1569 metadata: SkillMetadata {
1570 always: true,
1571 ..Default::default()
1572 },
1573 source: SkillSource::Local,
1574 linked_secrets: vec![],
1575 };
1576 let result = manager.check_gates(&skill);
1577 assert!(result.passed);
1578 }
1579
1580 #[test]
1581 fn test_gate_check_missing_bin() {
1582 let manager = SkillManager::new(std::env::temp_dir());
1583 let skill = Skill {
1584 name: "test".into(),
1585 description: None,
1586 path: PathBuf::new(),
1587 enabled: true,
1588 instructions: String::new(),
1589 metadata: SkillMetadata {
1590 requires: SkillRequirements {
1591 bins: vec!["nonexistent_binary_12345".into()],
1592 ..Default::default()
1593 },
1594 ..Default::default()
1595 },
1596 source: SkillSource::Local,
1597 linked_secrets: vec![],
1598 };
1599 let result = manager.check_gates(&skill);
1600 assert!(!result.passed);
1601 assert!(result.missing_bins.contains(&"nonexistent_binary_12345".to_string()));
1602 }
1603
1604 #[test]
1605 fn test_generate_prompt_context() {
1606 let mut manager = SkillManager::new(std::env::temp_dir());
1607 manager.skills.push(Skill {
1608 name: "test-skill".into(),
1609 description: Some("Does testing".into()),
1610 path: PathBuf::from("/skills/test/SKILL.md"),
1611 enabled: true,
1612 instructions: "Test instructions".into(),
1613 metadata: SkillMetadata::default(),
1614 source: SkillSource::Local,
1615 linked_secrets: vec![],
1616 });
1617 let context = manager.generate_prompt_context();
1618 assert!(context.contains("test-skill"));
1619 assert!(context.contains("Does testing"));
1620 assert!(context.contains("<available_skills>"));
1621 }
1622
1623 #[test]
1624 fn test_link_and_unlink_secret() {
1625 let mut manager = SkillManager::new(std::env::temp_dir());
1626 manager.skills.push(Skill {
1627 name: "deploy".into(),
1628 description: Some("Deploy things".into()),
1629 path: PathBuf::from("/skills/deploy/SKILL.md"),
1630 enabled: true,
1631 instructions: String::new(),
1632 metadata: SkillMetadata::default(),
1633 source: SkillSource::Local,
1634 linked_secrets: vec![],
1635 });
1636
1637 manager.link_secret("deploy", "AWS_KEY").unwrap();
1638 manager.link_secret("deploy", "AWS_SECRET").unwrap();
1639 assert_eq!(manager.get_linked_secrets("deploy"), vec!["AWS_KEY", "AWS_SECRET"]);
1640
1641 manager.link_secret("deploy", "AWS_KEY").unwrap();
1643 assert_eq!(manager.get_linked_secrets("deploy").len(), 2);
1644
1645 manager.unlink_secret("deploy", "AWS_KEY").unwrap();
1646 assert_eq!(manager.get_linked_secrets("deploy"), vec!["AWS_SECRET"]);
1647 }
1648
1649 #[test]
1650 fn test_link_secret_skill_not_found() {
1651 let mut manager = SkillManager::new(std::env::temp_dir());
1652 assert!(manager.link_secret("nonexistent", "key").is_err());
1653 }
1654
1655 #[test]
1656 fn test_skill_info() {
1657 let mut manager = SkillManager::new(std::env::temp_dir());
1658 manager.skills.push(Skill {
1659 name: "web-scrape".into(),
1660 description: Some("Scrape web pages".into()),
1661 path: PathBuf::from("/skills/web-scrape/SKILL.md"),
1662 enabled: true,
1663 instructions: String::new(),
1664 metadata: SkillMetadata::default(),
1665 source: SkillSource::Registry {
1666 registry_url: "https://registry.clawhub.dev/api/v1".into(),
1667 version: "1.0.0".into(),
1668 },
1669 linked_secrets: vec!["SCRAPER_KEY".into()],
1670 });
1671
1672 let info = manager.skill_info("web-scrape").unwrap();
1673 assert!(info.contains("web-scrape"));
1674 assert!(info.contains("registry"));
1675 assert!(info.contains("SCRAPER_KEY"));
1676 assert!(manager.skill_info("nonexistent").is_none());
1677 }
1678
1679 #[test]
1680 fn test_remove_skill() {
1681 let mut manager = SkillManager::new(std::env::temp_dir());
1682 manager.skills.push(Skill {
1683 name: "temp-skill".into(),
1684 description: None,
1685 path: PathBuf::from("/nonexistent/SKILL.md"),
1686 enabled: true,
1687 instructions: String::new(),
1688 metadata: SkillMetadata::default(),
1689 source: SkillSource::Local,
1690 linked_secrets: vec![],
1691 });
1692 assert_eq!(manager.get_skills().len(), 1);
1693 manager.remove_skill("temp-skill").unwrap();
1694 assert_eq!(manager.get_skills().len(), 0);
1695 assert!(manager.remove_skill("temp-skill").is_err());
1696 }
1697
1698 #[test]
1699 fn test_skill_source_default() {
1700 assert_eq!(SkillSource::default(), SkillSource::Local);
1701 }
1702
1703 #[test]
1704 fn test_base64_decode() {
1705 use base64::{Engine as _, engine::general_purpose::STANDARD};
1706 let encoded = "SGVsbG8=";
1707 let decoded = STANDARD.decode(encoded).unwrap();
1708 assert_eq!(decoded, b"Hello");
1709 }
1710}