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!("HTTP {} from ClawHub: {}", status, body,));
56
57                    if attempt < CLAWHUB_MAX_RETRIES {
58                        std::thread::sleep(delay);
59                        continue;
60                    }
61
62                    // Final attempt — fall through to bail below.
63                    anyhow::bail!(
64                        "ClawHub request failed (HTTP {}) after {} retries: {}",
65                        status,
66                        CLAWHUB_MAX_RETRIES,
67                        body,
68                    );
69                }
70
71                // Non-retryable error — bail immediately.
72                anyhow::bail!(
73                    "ClawHub request failed (HTTP {}): {}",
74                    status,
75                    resp.text().unwrap_or_default(),
76                );
77            }
78            Err(e) => {
79                // Retry on timeout / connect errors.
80                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
94/// Exponential backoff: 500ms → 1s → 2s → 4s … capped at CLAWHUB_MAX_DELAY.
95fn 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
102// ── ClawHub constants ───────────────────────────────────────────────────────
103
104/// Default ClawHub registry URL.
105pub const DEFAULT_REGISTRY_URL: &str = "https://clawhub.ai";
106
107// ── Skill types ─────────────────────────────────────────────────────────────
108
109/// Where a skill was installed from.
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
111pub enum SkillSource {
112    /// Locally authored (found on disk, not from a registry).
113    #[default]
114    Local,
115    /// Installed from a ClawHub registry.
116    Registry {
117        /// The registry URL it was fetched from.
118        registry_url: String,
119        /// The version that is currently installed (semver tag or `latest`).
120        version: String,
121    },
122}
123
124/// Represents a skill that can be loaded and executed
125#[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    /// Raw instructions from SKILL.md (after frontmatter)
132    #[serde(default)]
133    pub instructions: String,
134    /// Parsed metadata from frontmatter
135    #[serde(default)]
136    pub metadata: SkillMetadata,
137    /// Where this skill was installed from.
138    #[serde(default)]
139    pub source: SkillSource,
140    /// Secrets linked to this skill (vault key names).
141    /// When the skill is the active context, `SkillOnly` credentials
142    /// whose allowed-list includes this skill's name are accessible.
143    #[serde(default)]
144    pub linked_secrets: Vec<String>,
145}
146
147/// OpenClaw-compatible skill metadata
148#[derive(Debug, Clone, Default, Serialize, Deserialize)]
149pub struct SkillMetadata {
150    /// Always include this skill (skip gating)
151    #[serde(default)]
152    pub always: bool,
153    /// Optional emoji for UI
154    pub emoji: Option<String>,
155    /// Homepage URL
156    pub homepage: Option<String>,
157    /// Required OS platforms (darwin, linux, win32)
158    #[serde(default)]
159    pub os: Vec<String>,
160    /// Gating requirements
161    #[serde(default)]
162    pub requires: SkillRequirements,
163    /// Primary env var for API key
164    #[serde(rename = "primaryEnv")]
165    pub primary_env: Option<String>,
166}
167
168/// Skill gating requirements
169#[derive(Debug, Clone, Default, Serialize, Deserialize)]
170pub struct SkillRequirements {
171    /// All these binaries must exist on PATH
172    #[serde(default)]
173    pub bins: Vec<String>,
174    /// At least one of these binaries must exist
175    #[serde(rename = "anyBins", default)]
176    pub any_bins: Vec<String>,
177    /// All these env vars must be set
178    #[serde(default)]
179    pub env: Vec<String>,
180    /// All these config paths must be truthy
181    #[serde(default)]
182    pub config: Vec<String>,
183}
184
185/// Result of checking skill requirements
186#[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// ── ClawHub registry types ──────────────────────────────────────────────────
196
197/// Manifest used when publishing a skill to ClawHub.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct SkillManifest {
200    /// Skill name (must be unique within the registry namespace).
201    pub name: String,
202    /// Semver version string.
203    pub version: String,
204    /// Human-readable description.
205    pub description: String,
206    /// Author / maintainer.
207    #[serde(default)]
208    pub author: String,
209    /// SPDX licence identifier.
210    #[serde(default)]
211    pub license: String,
212    /// Repository URL (source code).
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub repository: Option<String>,
215    /// Names of secrets this skill needs (informational; the user still
216    /// controls which vault entries to link).
217    #[serde(default)]
218    pub required_secrets: Vec<String>,
219    /// Gating metadata.
220    #[serde(default)]
221    pub metadata: SkillMetadata,
222}
223
224/// A single entry returned by a registry search.
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct RegistryEntry {
227    /// Skill slug (used for installation)
228    #[serde(alias = "slug")]
229    pub name: String,
230    #[serde(default)]
231    pub version: String,
232    /// Description text
233    #[serde(alias = "summary")]
234    pub description: String,
235    /// Display name (optional)
236    #[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/// Response wrapper from the ClawHub API.
247#[derive(Debug, Clone, Serialize, Deserialize)]
248struct RegistrySearchResponse {
249    /// ClawHub uses "results" array
250    #[serde(default)]
251    results: Vec<RegistryEntry>,
252    /// Legacy field (keep for compatibility)
253    #[serde(default)]
254    skills: Vec<RegistryEntry>,
255    #[serde(default)]
256    total: usize,
257}
258
259// ── ClawHub extended API types ──────────────────────────────────────────────
260
261/// A trending / featured skill from the ClawHub API.
262#[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/// Response wrapper for trending skills.
280#[derive(Debug, Clone, Serialize, Deserialize)]
281struct TrendingResponse {
282    #[serde(default)]
283    results: Vec<TrendingEntry>,
284    #[serde(default)]
285    skills: Vec<TrendingEntry>,
286}
287
288/// A skill category on ClawHub.
289#[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/// Response wrapper for categories.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302struct CategoriesResponse {
303    #[serde(default)]
304    categories: Vec<Category>,
305}
306
307/// ClawHub user profile.
308#[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/// Response wrapper for profile.
327#[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/// A starred skill entry.
336#[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/// Response wrapper for starred skills.
350#[derive(Debug, Clone, Serialize, Deserialize)]
351struct StarredResponse {
352    #[serde(default)]
353    results: Vec<StarredEntry>,
354}
355
356/// Auth response from ClawHub login.
357#[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/// Detailed info about a single skill from the registry.
370#[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
401// ── Skill manager ───────────────────────────────────────────────────────────
402
403/// Manages skills compatible with OpenClaw
404pub struct SkillManager {
405    skills_dirs: Vec<PathBuf>,
406    skills: Vec<Skill>,
407    /// Environment variables to check against
408    env_vars: HashMap<String, String>,
409    /// ClawHub registry URL (overridable via config).
410    registry_url: String,
411    /// ClawHub auth token (optional; needed for publish / private skills).
412    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    /// Create with multiple skill directories (for precedence)
427    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    /// Configure the ClawHub registry URL and optional auth token.
438    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    /// Get the primary skills directory (last in the list — user's writable dir).
444    /// Skills are loaded from first to last, with later dirs overriding earlier ones.
445    /// Installation goes to the last dir (user-writable, highest priority).
446    pub fn primary_skills_dir(&self) -> Option<&Path> {
447        self.skills_dirs.last().map(|p| p.as_path())
448    }
449
450    /// Load skills from all configured directories
451    /// Later directories have higher precedence (override earlier ones by name)
452    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            // Look for skill directories containing SKILL.md
462            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                            // Check if we already have this skill (override by precedence)
471                            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                // Also support legacy .skill/.json/.yaml files
482                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    /// Load a skill from SKILL.md format (AgentSkills compatible)
503    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        // Parse frontmatter as YAML
508        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        // Parse metadata if present
520        let metadata = if let Some(meta_val) = frontmatter.get("metadata") {
521            // metadata can be a string (JSON) or an object
522            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                // Convert YAML Value to JSON Value via serialization round-trip
526                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        // Replace {baseDir} placeholder in instructions
536        let base_dir = path.parent().unwrap_or(Path::new("."));
537        let instructions = instructions.replace("{baseDir}", &base_dir.display().to_string());
538
539        // Extract linked_secrets from frontmatter if present.
540        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    /// Load a legacy skill file (.skill/.json/.yaml)
563    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    /// Check if a skill passes its gating requirements
585    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        // Always-enabled skills skip all gates
595        if skill.metadata.always {
596            return result;
597        }
598
599        // Check OS requirement
600        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        // Check required binaries
618        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        // Check anyBins (at least one must exist)
626        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        // Check required env vars
642        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        // Config checks would require access to config - mark as missing for now
650        // In a real implementation, this would check openclaw.json
651        result.missing_config = skill.metadata.requires.config.clone();
652        if !result.missing_config.is_empty() {
653            // Don't fail on config checks for now - they require config integration
654        }
655
656        result
657    }
658
659    /// Check if a binary exists on PATH
660    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                // On Windows, also check with .exe
668                #[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    /// Get all loaded skills
681    pub fn get_skills(&self) -> &[Skill] {
682        &self.skills
683    }
684
685    /// Get only enabled skills that pass gating
686    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    /// Get a specific skill by name
694    pub fn get_skill(&self, name: &str) -> Option<&Skill> {
695        self.skills.iter().find(|s| s.name == name)
696    }
697
698    /// Enable or disable a skill
699    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    /// Generate prompt context for all eligible skills
709    pub fn generate_prompt_context(&self) -> String {
710        // Get all enabled skills (not just those passing gates)
711        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                // Show what's missing
762                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        // Add note about ClawHub for finding more skills
786        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        // Add skill creation instructions so the agent can create skills from conversation
790        self.append_skill_creation_instructions(&mut context);
791
792        context
793    }
794
795    /// Append instructions that teach the agent how to create new skills.
796    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    /// Get full instructions for a skill (for when agent reads SKILL.md)
832    pub fn get_skill_instructions(&self, name: &str) -> Option<String> {
833        self.get_skill(name).map(|s| s.instructions.clone())
834    }
835
836    // ── Skill creation ──────────────────────────────────────────────
837
838    /// Create a new skill on disk from name, description, and instructions.
839    ///
840    /// Writes `<primary_skills_dir>/<name>/SKILL.md` with YAML frontmatter
841    /// and the supplied markdown body, then reloads the skill list so the
842    /// new skill is immediately available.
843    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        // Validate name is kebab-case-ish (no slashes, no spaces, no dots-leading)
851        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        // Build frontmatter
871        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        // Reload so the new skill is immediately visible
882        self.load_skills()?;
883
884        Ok(skill_path)
885    }
886
887    // ── Secret linking ──────────────────────────────────────────────
888
889    /// Link a vault credential to a skill so the skill can access it
890    /// via the `SkillOnly` policy.
891    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    /// Unlink a vault credential from a skill.
905    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    /// Return the linked secrets for a skill (empty vec if not found).
917    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    // ── Skill removal ───────────────────────────────────────────────
924
925    /// Remove a skill by name.  If it was installed from a registry,
926    /// its directory is deleted from disk.
927    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 the skill lives inside one of our managed skill directories,
937        // remove it from disk.
938        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    // ── Detailed info ───────────────────────────────────────────────
953
954    /// Return a human-readable summary of a skill.
955    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    // ── ClawHub registry operations ─────────────────────────────────
1000
1001    /// Try to reach the registry with a short timeout.  Returns `true`
1002    /// if the base URL responds, `false` on any network error.
1003    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    /// Search the ClawHub registry for skills matching a query.
1013    ///
1014    /// If the registry is unreachable, falls back to matching against
1015    /// locally-loaded skills so the user still gets useful results.
1016    pub fn search_registry(&self, query: &str) -> Result<Vec<RegistryEntry>> {
1017        // ── Try remote registry first ───────────────────────────
1018        match self.search_registry_remote(query) {
1019            Ok(results) => return Ok(results),
1020            Err(_) => {
1021                // Fall through to local search.
1022            }
1023        }
1024
1025        // ── Fallback: search locally loaded skills ──────────────
1026        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    /// Internal: attempt a remote registry search.
1056    fn search_registry_remote(&self, query: &str) -> Result<Vec<RegistryEntry>> {
1057        // ClawHub API: /api/search?q=<query>
1058        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        // ClawHub returns "results", legacy might return "skills"
1077        let entries = if !body.results.is_empty() {
1078            body.results
1079        } else {
1080            body.skills
1081        };
1082
1083        Ok(entries)
1084    }
1085
1086    /// Install a skill from the ClawHub registry into the primary
1087    /// skills directory.  Returns the installed `Skill`.
1088    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        // ClawHub download API: /api/v1/download?slug=<name>&version=<version>
1099        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        // Response is a zip file
1118        let zip_bytes = resp.bytes().context("Failed to read zip data")?;
1119
1120        // Use last directory (user's writable dir) for installations, not first (bundled/read-only)
1121        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        // Extract zip to skill directory
1131        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        // Write .clawhub metadata
1150        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        // Load the newly-installed skill.
1168        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        // Add or replace in the in-memory list.
1176        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    /// Publish a local skill to the ClawHub registry.
1186    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        // Read the skill content.
1198        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(), // TODO: extract from frontmatter
1203            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    // ── ClawHub extended API operations ─────────────────────────────
1250
1251    /// Return the registry URL for display or browser opening.
1252    pub fn registry_url(&self) -> &str {
1253        &self.registry_url
1254    }
1255
1256    /// Return the registry auth token (if set).
1257    pub fn registry_token(&self) -> Option<&str> {
1258        self.registry_token.as_deref()
1259    }
1260
1261    /// Authenticate with ClawHub using a username and password.
1262    /// Returns the API token on success, which should be saved to config.
1263    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    /// Authenticate with ClawHub using a pre-existing API token.
1289    /// Validates the token and returns the profile info.
1290    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    /// Check authentication status (whether a token is configured and valid).
1316    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    /// Fetch trending / popular skills from the ClawHub registry.
1342    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(&params.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    /// Fetch available categories from the ClawHub registry.
1390    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    /// Fetch the authenticated user's profile from ClawHub.
1417    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    /// Fetch the authenticated user's starred skills from ClawHub.
1450    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    /// Star a skill on ClawHub.
1480    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    /// Unstar a skill on ClawHub.
1511    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    /// Get detailed info about a registry skill (not a locally installed one).
1542    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
1575/// Parse YAML frontmatter from a markdown file
1576fn parse_frontmatter(content: &str) -> Result<(serde_yaml::Value, String)> {
1577    let content = content.trim_start();
1578
1579    if !content.starts_with("---") {
1580        // No frontmatter, treat entire content as instructions
1581        return Ok((
1582            serde_yaml::Value::Mapping(Default::default()),
1583            content.to_string(),
1584        ));
1585    }
1586
1587    // Find the closing ---
1588    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        // No closing ---, treat as no frontmatter
1599        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        // 'ls' or 'dir' should exist on most systems
1646        #[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        // Linking the same secret again should not duplicate.
1741        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}