Skip to main content

rustyclaw_core/
skills.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7// ── Blocking retry helpers (for ClawHub HTTP calls) ─────────────────────────
8
9/// Maximum retry attempts for ClawHub API calls.
10const CLAWHUB_MAX_RETRIES: u32 = 4;
11/// Base delay for exponential backoff.
12const CLAWHUB_BASE_DELAY: Duration = Duration::from_millis(500);
13/// Maximum delay cap.
14const CLAWHUB_MAX_DELAY: Duration = Duration::from_secs(15);
15
16/// Send a blocking request with retry + exponential backoff for 429 / 5xx.
17/// Returns the successful response or the last error.
18fn 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                // Success — return immediately.
37                if status.is_success() {
38                    return Ok(resp);
39                }
40
41                // Retry on 429 or 5xx — honour Retry-After header if present.
42                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                    // Final attempt — fall through to bail below.
67                    anyhow::bail!(
68                        "ClawHub request failed (HTTP {}) after {} retries: {}",
69                        status,
70                        CLAWHUB_MAX_RETRIES,
71                        body,
72                    );
73                }
74
75                // Non-retryable error — bail immediately.
76                anyhow::bail!(
77                    "ClawHub request failed (HTTP {}): {}",
78                    status,
79                    resp.text().unwrap_or_default(),
80                );
81            }
82            Err(e) => {
83                // Retry on timeout / connect errors.
84                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
98/// Exponential backoff: 500ms → 1s → 2s → 4s … capped at CLAWHUB_MAX_DELAY.
99fn 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
106// ── ClawHub constants ───────────────────────────────────────────────────────
107
108/// Default ClawHub registry URL.
109pub const DEFAULT_REGISTRY_URL: &str = "https://clawhub.ai";
110
111// ── Skill types ─────────────────────────────────────────────────────────────
112
113/// Where a skill was installed from.
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
115#[derive(Default)]
116pub enum SkillSource {
117    /// Locally authored (found on disk, not from a registry).
118    #[default]
119    Local,
120    /// Installed from a ClawHub registry.
121    Registry {
122        /// The registry URL it was fetched from.
123        registry_url: String,
124        /// The version that is currently installed (semver tag or `latest`).
125        version: String,
126    },
127}
128
129
130/// Represents a skill that can be loaded and executed
131#[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    /// Raw instructions from SKILL.md (after frontmatter)
138    #[serde(default)]
139    pub instructions: String,
140    /// Parsed metadata from frontmatter
141    #[serde(default)]
142    pub metadata: SkillMetadata,
143    /// Where this skill was installed from.
144    #[serde(default)]
145    pub source: SkillSource,
146    /// Secrets linked to this skill (vault key names).
147    /// When the skill is the active context, `SkillOnly` credentials
148    /// whose allowed-list includes this skill's name are accessible.
149    #[serde(default)]
150    pub linked_secrets: Vec<String>,
151}
152
153/// OpenClaw-compatible skill metadata
154#[derive(Debug, Clone, Default, Serialize, Deserialize)]
155pub struct SkillMetadata {
156    /// Always include this skill (skip gating)
157    #[serde(default)]
158    pub always: bool,
159    /// Optional emoji for UI
160    pub emoji: Option<String>,
161    /// Homepage URL
162    pub homepage: Option<String>,
163    /// Required OS platforms (darwin, linux, win32)
164    #[serde(default)]
165    pub os: Vec<String>,
166    /// Gating requirements
167    #[serde(default)]
168    pub requires: SkillRequirements,
169    /// Primary env var for API key
170    #[serde(rename = "primaryEnv")]
171    pub primary_env: Option<String>,
172}
173
174/// Skill gating requirements
175#[derive(Debug, Clone, Default, Serialize, Deserialize)]
176pub struct SkillRequirements {
177    /// All these binaries must exist on PATH
178    #[serde(default)]
179    pub bins: Vec<String>,
180    /// At least one of these binaries must exist
181    #[serde(rename = "anyBins", default)]
182    pub any_bins: Vec<String>,
183    /// All these env vars must be set
184    #[serde(default)]
185    pub env: Vec<String>,
186    /// All these config paths must be truthy
187    #[serde(default)]
188    pub config: Vec<String>,
189}
190
191/// Result of checking skill requirements
192#[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// ── ClawHub registry types ──────────────────────────────────────────────────
202
203/// Manifest used when publishing a skill to ClawHub.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct SkillManifest {
206    /// Skill name (must be unique within the registry namespace).
207    pub name: String,
208    /// Semver version string.
209    pub version: String,
210    /// Human-readable description.
211    pub description: String,
212    /// Author / maintainer.
213    #[serde(default)]
214    pub author: String,
215    /// SPDX licence identifier.
216    #[serde(default)]
217    pub license: String,
218    /// Repository URL (source code).
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub repository: Option<String>,
221    /// Names of secrets this skill needs (informational; the user still
222    /// controls which vault entries to link).
223    #[serde(default)]
224    pub required_secrets: Vec<String>,
225    /// Gating metadata.
226    #[serde(default)]
227    pub metadata: SkillMetadata,
228}
229
230/// A single entry returned by a registry search.
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct RegistryEntry {
233    /// Skill slug (used for installation)
234    #[serde(alias = "slug")]
235    pub name: String,
236    #[serde(default)]
237    pub version: String,
238    /// Description text
239    #[serde(alias = "summary")]
240    pub description: String,
241    /// Display name (optional)
242    #[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/// Response wrapper from the ClawHub API.
253#[derive(Debug, Clone, Serialize, Deserialize)]
254struct RegistrySearchResponse {
255    /// ClawHub uses "results" array
256    #[serde(default)]
257    results: Vec<RegistryEntry>,
258    /// Legacy field (keep for compatibility)
259    #[serde(default)]
260    skills: Vec<RegistryEntry>,
261    #[serde(default)]
262    total: usize,
263}
264
265// ── ClawHub extended API types ──────────────────────────────────────────────
266
267/// A trending / featured skill from the ClawHub API.
268#[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/// Response wrapper for trending skills.
286#[derive(Debug, Clone, Serialize, Deserialize)]
287struct TrendingResponse {
288    #[serde(default)]
289    results: Vec<TrendingEntry>,
290    #[serde(default)]
291    skills: Vec<TrendingEntry>,
292}
293
294/// A skill category on ClawHub.
295#[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/// Response wrapper for categories.
307#[derive(Debug, Clone, Serialize, Deserialize)]
308struct CategoriesResponse {
309    #[serde(default)]
310    categories: Vec<Category>,
311}
312
313/// ClawHub user profile.
314#[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/// Response wrapper for profile.
333#[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/// A starred skill entry.
342#[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/// Response wrapper for starred skills.
356#[derive(Debug, Clone, Serialize, Deserialize)]
357struct StarredResponse {
358    #[serde(default)]
359    results: Vec<StarredEntry>,
360}
361
362/// Auth response from ClawHub login.
363#[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/// Detailed info about a single skill from the registry.
376#[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
407// ── Skill manager ───────────────────────────────────────────────────────────
408
409/// Manages skills compatible with OpenClaw
410pub struct SkillManager {
411    skills_dirs: Vec<PathBuf>,
412    skills: Vec<Skill>,
413    /// Environment variables to check against
414    env_vars: HashMap<String, String>,
415    /// ClawHub registry URL (overridable via config).
416    registry_url: String,
417    /// ClawHub auth token (optional; needed for publish / private skills).
418    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    /// Create with multiple skill directories (for precedence)
433    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    /// Configure the ClawHub registry URL and optional auth token.
444    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    /// Get the primary skills directory (last in the list — user's writable dir).
450    /// Skills are loaded from first to last, with later dirs overriding earlier ones.
451    /// Installation goes to the last dir (user-writable, highest priority).
452    pub fn primary_skills_dir(&self) -> Option<&Path> {
453        self.skills_dirs.last().map(|p| p.as_path())
454    }
455
456    /// Load skills from all configured directories
457    /// Later directories have higher precedence (override earlier ones by name)
458    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            // Look for skill directories containing SKILL.md
468            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                            // Check if we already have this skill (override by precedence)
477                            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                // Also support legacy .skill/.json/.yaml files
488                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    /// Load a skill from SKILL.md format (AgentSkills compatible)
509    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        // Parse frontmatter as YAML
514        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        // Parse metadata if present
526        let metadata = if let Some(meta_val) = frontmatter.get("metadata") {
527            // metadata can be a string (JSON) or an object
528            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                // Convert YAML Value to JSON Value via serialization round-trip
532                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        // Replace {baseDir} placeholder in instructions
542        let base_dir = path.parent().unwrap_or(Path::new("."));
543        let instructions = instructions.replace("{baseDir}", &base_dir.display().to_string());
544
545        // Extract linked_secrets from frontmatter if present.
546        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    /// Load a legacy skill file (.skill/.json/.yaml)
569    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    /// Check if a skill passes its gating requirements
589    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        // Always-enabled skills skip all gates
599        if skill.metadata.always {
600            return result;
601        }
602
603        // Check OS requirement
604        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        // Check required binaries
622        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        // Check anyBins (at least one must exist)
630        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        // Check required env vars
644        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        // Config checks would require access to config - mark as missing for now
652        // In a real implementation, this would check openclaw.json
653        result.missing_config = skill.metadata.requires.config.clone();
654        if !result.missing_config.is_empty() {
655            // Don't fail on config checks for now - they require config integration
656        }
657
658        result
659    }
660
661    /// Check if a binary exists on PATH
662    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                // On Windows, also check with .exe
670                #[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    /// Get all loaded skills
683    pub fn get_skills(&self) -> &[Skill] {
684        &self.skills
685    }
686
687    /// Get only enabled skills that pass gating
688    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    /// Get a specific skill by name
696    pub fn get_skill(&self, name: &str) -> Option<&Skill> {
697        self.skills.iter().find(|s| s.name == name)
698    }
699
700    /// Enable or disable a skill
701    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    /// Generate prompt context for all eligible skills
711    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        // Add note about ClawHub for finding more skills
748        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        // Add skill creation instructions so the agent can create skills from conversation
752        self.append_skill_creation_instructions(&mut context);
753
754        context
755    }
756
757    /// Append instructions that teach the agent how to create new skills.
758    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    /// Get full instructions for a skill (for when agent reads SKILL.md)
787    pub fn get_skill_instructions(&self, name: &str) -> Option<String> {
788        self.get_skill(name).map(|s| s.instructions.clone())
789    }
790
791    // ── Skill creation ──────────────────────────────────────────────
792
793    /// Create a new skill on disk from name, description, and instructions.
794    ///
795    /// Writes `<primary_skills_dir>/<name>/SKILL.md` with YAML frontmatter
796    /// and the supplied markdown body, then reloads the skill list so the
797    /// new skill is immediately available.
798    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        // Validate name is kebab-case-ish (no slashes, no spaces, no dots-leading)
806        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        // Build frontmatter
828        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        // Reload so the new skill is immediately visible
839        self.load_skills()?;
840
841        Ok(skill_path)
842    }
843
844    // ── Secret linking ──────────────────────────────────────────────
845
846    /// Link a vault credential to a skill so the skill can access it
847    /// via the `SkillOnly` policy.
848    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    /// Unlink a vault credential from a skill.
862    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    /// Return the linked secrets for a skill (empty vec if not found).
874    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    // ── Skill removal ───────────────────────────────────────────────
881
882    /// Remove a skill by name.  If it was installed from a registry,
883    /// its directory is deleted from disk.
884    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 the skill lives inside one of our managed skill directories,
894        // remove it from disk.
895        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    // ── Detailed info ───────────────────────────────────────────────
910
911    /// Return a human-readable summary of a skill.
912    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    // ── ClawHub registry operations ─────────────────────────────────
942
943    /// Try to reach the registry with a short timeout.  Returns `true`
944    /// if the base URL responds, `false` on any network error.
945    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    /// Search the ClawHub registry for skills matching a query.
955    ///
956    /// If the registry is unreachable, falls back to matching against
957    /// locally-loaded skills so the user still gets useful results.
958    pub fn search_registry(&self, query: &str) -> Result<Vec<RegistryEntry>> {
959        // ── Try remote registry first ───────────────────────────
960        match self.search_registry_remote(query) {
961            Ok(results) => return Ok(results),
962            Err(_) => {
963                // Fall through to local search.
964            }
965        }
966
967        // ── Fallback: search locally loaded skills ──────────────
968        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    /// Internal: attempt a remote registry search.
998    fn search_registry_remote(&self, query: &str) -> Result<Vec<RegistryEntry>> {
999        // ClawHub API: /api/search?q=<query>
1000        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        // ClawHub returns "results", legacy might return "skills"
1018        let entries = if !body.results.is_empty() {
1019            body.results
1020        } else {
1021            body.skills
1022        };
1023
1024        Ok(entries)
1025    }
1026
1027    /// Install a skill from the ClawHub registry into the primary
1028    /// skills directory.  Returns the installed `Skill`.
1029    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        // ClawHub download API: /api/v1/download?slug=<name>&version=<version>
1040        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        // Response is a zip file
1055        let zip_bytes = resp.bytes().context("Failed to read zip data")?;
1056
1057        // Use last directory (user's writable dir) for installations, not first (bundled/read-only)
1058        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        // Extract zip to skill directory
1068        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        // Write .clawhub metadata
1087        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        // Load the newly-installed skill.
1102        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        // Add or replace in the in-memory list.
1110        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    /// Publish a local skill to the ClawHub registry.
1120    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        // Read the skill content.
1131        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(), // TODO: extract from frontmatter
1137            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    // ── ClawHub extended API operations ─────────────────────────────
1184
1185    /// Return the registry URL for display or browser opening.
1186    pub fn registry_url(&self) -> &str {
1187        &self.registry_url
1188    }
1189
1190    /// Return the registry auth token (if set).
1191    pub fn registry_token(&self) -> Option<&str> {
1192        self.registry_token.as_deref()
1193    }
1194
1195    /// Authenticate with ClawHub using a username and password.
1196    /// Returns the API token on success, which should be saved to config.
1197    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    /// Authenticate with ClawHub using a pre-existing API token.
1223    /// Validates the token and returns the profile info.
1224    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    /// Check authentication status (whether a token is configured and valid).
1246    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    /// Fetch trending / popular skills from the ClawHub registry.
1266    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(&params.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    /// Fetch available categories from the ClawHub registry.
1310    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    /// Fetch the authenticated user's profile from ClawHub.
1336    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    /// Fetch the authenticated user's starred skills from ClawHub.
1367    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    /// Star a skill on ClawHub.
1395    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    /// Unstar a skill on ClawHub.
1426    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    /// Get detailed info about a registry skill (not a locally installed one).
1457    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
1489/// Parse YAML frontmatter from a markdown file
1490fn parse_frontmatter(content: &str) -> Result<(serde_yaml::Value, String)> {
1491    let content = content.trim_start();
1492
1493    if !content.starts_with("---") {
1494        // No frontmatter, treat entire content as instructions
1495        return Ok((serde_yaml::Value::Mapping(Default::default()), content.to_string()));
1496    }
1497
1498    // Find the closing ---
1499    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        // No closing ---, treat as no frontmatter
1510        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        // 'ls' or 'dir' should exist on most systems
1554        #[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        // Linking the same secret again should not duplicate.
1642        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}