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