Skip to main content

zeroclaw_runtime/skills/
mod.rs

1pub mod skill_http;
2pub mod skill_tool;
3use anyhow::{Context, Result};
4use directories::UserDirs;
5use reqwest::Url;
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, HashMap, HashSet};
8use std::io::Cursor;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use std::time::{Duration, SystemTime};
12
13use zip::ZipArchive;
14
15pub mod audit;
16pub mod bundle;
17pub mod constants;
18pub mod creator;
19pub mod document;
20pub mod frontmatter;
21pub mod improver;
22pub mod reference;
23pub mod scaffold;
24pub mod service;
25mod suggestions;
26pub mod testing;
27
28pub use bundle::{BundleError, BundleSummary};
29pub use document::{DocumentParseError, SkillDocument};
30pub use frontmatter::SkillFrontmatter;
31pub use reference::{SkillRef, SkillRefError};
32pub use scaffold::{ScaffoldError, ScaffoldOptions};
33pub use service::{RemoveMode, ServiceError, SkillSummary, SkillsService};
34pub(crate) use suggestions::render_missing_skill_install_suggestion;
35
36const OPEN_SKILLS_REPO_URL: &str = "https://github.com/besoeasy/open-skills";
37const OPEN_SKILLS_SYNC_MARKER: &str = ".zeroclaw-open-skills-sync";
38const OPEN_SKILLS_SYNC_INTERVAL_SECS: u64 = 60 * 60 * 24 * 7;
39
40// ─── ClawhHub / OpenClaw registry installers ───────────────────────────────
41const CLAWHUB_DOMAIN: &str = "clawhub.ai";
42const CLAWHUB_WWW_DOMAIN: &str = "www.clawhub.ai";
43const CLAWHUB_DOWNLOAD_API: &str = "https://clawhub.ai/api/v1/download";
44const MAX_CLAWHUB_ZIP_BYTES: u64 = 50 * 1024 * 1024; // 50 MiB
45
46// ─── Skills registry (zeroclaw-skills) ────────────────────────────────────────
47const SKILLS_REGISTRY_REPO_URL: &str = "https://github.com/zeroclaw-labs/zeroclaw-skills";
48const SKILLS_REGISTRY_DIR_NAME: &str = "skills-registry";
49const SKILLS_REGISTRY_SYNC_MARKER: &str = ".zeroclaw-skills-registry-sync";
50const SKILLS_REGISTRY_SYNC_INTERVAL_SECS: u64 = 60 * 60 * 24;
51
52/// A skill is a user-defined or community-built capability.
53/// Skills live in `~/.zeroclaw/workspace/skills/<name>/SKILL.md`
54/// and can include tool definitions, prompts, and automation scripts.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct Skill {
57    pub name: String,
58    pub description: String,
59    pub version: String,
60    #[serde(default)]
61    pub author: Option<String>,
62    #[serde(default)]
63    pub tags: Vec<String>,
64    #[serde(default)]
65    pub tools: Vec<SkillTool>,
66    #[serde(default)]
67    pub prompts: Vec<String>,
68    #[serde(skip)]
69    pub location: Option<PathBuf>,
70}
71
72impl ::zeroclaw_api::attribution::Attributable for Skill {
73    fn role(&self) -> ::zeroclaw_api::attribution::Role {
74        ::zeroclaw_api::attribution::Role::Skill
75    }
76    fn alias(&self) -> &str {
77        &self.name
78    }
79}
80
81/// A tool defined by a skill (shell command, HTTP call, etc.)
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct SkillTool {
84    pub name: String,
85    pub description: String,
86    /// "shell", "http", "script", "builtin", "mcp"
87    pub kind: String,
88    /// The command/URL/script to execute (unused for builtin/mcp kinds)
89    #[serde(default)]
90    pub command: String,
91    #[serde(default)]
92    pub args: HashMap<String, String>,
93    /// For `kind = "builtin"`: the name of the built-in tool to delegate to.
94    /// For `kind = "mcp"`: the prefixed MCP tool name `{server}__{tool}`
95    /// (e.g. `images__generate`).
96    #[serde(default)]
97    pub target: Option<String>,
98    /// For `kind = "builtin"` / `kind = "mcp"`: arguments fixed by the skill
99    /// manifest. These are **locked** — they are applied on top of the
100    /// caller-supplied args and cannot be overridden by the model. This is
101    /// what scopes a delegated tool (e.g. `target = "composio"` +
102    /// `locked_args = { action_name = "TEXT_TO_PDF" }` exposes exactly one
103    /// action). Accepts the legacy key `default_args` for compatibility.
104    #[serde(default, alias = "default_args")]
105    pub locked_args: HashMap<String, String>,
106}
107
108/// Skill manifest parsed from SKILL.toml
109#[derive(Debug, Clone, Serialize, Deserialize)]
110struct SkillManifest {
111    skill: SkillMeta,
112    /// SkillForge-emitted provenance metadata. Lives in a top-level `[forge]`
113    /// table so that `SkillMeta` (the canonical skill-identity contract) is
114    /// not coupled to the SkillForge integrator's emit format. Hand-authored
115    /// SKILL.toml files omit this; auto-integrated skills carry it. See
116    /// #6210 for the architectural rationale (FND-001 §4.2).
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    forge: Option<ForgeMetadata>,
119    #[serde(default)]
120    tools: Vec<SkillTool>,
121    #[serde(default)]
122    prompts: Vec<String>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(deny_unknown_fields)]
127struct SkillMeta {
128    name: String,
129    description: String,
130    #[serde(default = "default_version")]
131    version: String,
132    #[serde(default)]
133    author: Option<String>,
134    #[serde(default)]
135    tags: Vec<String>,
136    #[serde(default)]
137    prompts: Vec<String>,
138}
139
140/// Provenance metadata emitted by the SkillForge integrator (see
141/// `crates/zeroclaw-runtime/src/skillforge/integrate.rs`). Lives at the
142/// top level of SKILL.toml under `[forge]`, kept separate from
143/// `[skill]` so the canonical skill identity stays decoupled from the
144/// integrator's emit format. Strict by design: a typo here is just as
145/// bad as a typo in `[skill]` (silent misconfiguration of provenance).
146#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148struct ForgeMetadata {
149    /// Upstream URL the skill was integrated from.
150    #[serde(default)]
151    source: Option<String>,
152    /// Upstream owner (GitHub user / org).
153    #[serde(default)]
154    owner: Option<String>,
155    /// Primary language reported by the source (or `"unknown"`).
156    #[serde(default)]
157    language: Option<String>,
158    /// `true` if the upstream repo carries a license file.
159    #[serde(default)]
160    license: Option<bool>,
161    /// Upstream star count at integration time.
162    #[serde(default)]
163    stars: Option<u64>,
164    /// Upstream `updated_at` timestamp formatted `YYYY-MM-DD`, or
165    /// `"unknown"` if the integrator could not resolve one.
166    #[serde(default)]
167    updated_at: Option<String>,
168    /// Runtime/version requirements declared by the integrator.
169    #[serde(default)]
170    requirements: BTreeMap<String, toml::Value>,
171    /// Free-form integrator metadata (e.g. `auto_integrated`,
172    /// `forge_timestamp`). **This is the intended extension point** for
173    /// future SkillForge metadata: prefer adding new keys under
174    /// `[forge.metadata.X]` over new top-level `[forge]` fields, which
175    /// would require a coordinated `ForgeMetadata` schema bump and break
176    /// strict parsing for anyone running an older runtime.
177    #[serde(default)]
178    metadata: BTreeMap<String, toml::Value>,
179}
180
181#[derive(Debug, Clone, Default)]
182struct SkillMarkdownMeta {
183    name: Option<String>,
184    description: Option<String>,
185    version: Option<String>,
186    author: Option<String>,
187    tags: Vec<String>,
188}
189
190fn default_version() -> String {
191    "0.1.0".to_string()
192}
193
194/// Trust tier of a skill listed in the `zeroclaw-skills` registry.
195///
196/// Derived from the `tags` array in `registry.json`. `Unknown` is used as the
197/// "no recognized tier tag" fallback and is treated like `Community` for trust
198/// purposes when displaying the install banner.
199///
200/// `Featured` is intentionally kept as a distinct variant even though it
201/// renders identically to `Community` today: the registry's `Featured` tag is
202/// a separate curation signal (zeroclaw-labs hand-picked, but still authored
203/// outside zeroclaw-labs) and we expect to render it differently later — e.g.
204/// "Featured — community-curated by zeroclaw-labs but not maintained by us".
205/// Keeping the variant now avoids a churn-y enum extension once that copy
206/// lands.
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum SkillTier {
209    Official,
210    Community,
211    Featured,
212    Unknown,
213}
214
215#[derive(Debug, Deserialize)]
216struct RegistryIndex {
217    #[serde(default)]
218    skills: Vec<RegistryEntry>,
219}
220
221#[derive(Debug, Deserialize)]
222struct RegistryEntry {
223    name: String,
224    #[serde(default)]
225    version: Option<String>,
226    #[serde(default)]
227    tags: Vec<String>,
228}
229
230fn tier_from_tags(tags: &[String]) -> SkillTier {
231    let has = |needle: &str| tags.iter().any(|t| t.eq_ignore_ascii_case(needle));
232    if has("Official") {
233        SkillTier::Official
234    } else if has("Community") {
235        SkillTier::Community
236    } else if has("Featured") {
237        SkillTier::Featured
238    } else {
239        SkillTier::Unknown
240    }
241}
242
243/// Look up a skill in `<registry_dir>/registry.json` and return its trust tier
244/// and version. Returns `(SkillTier::Unknown, None)` if the index file is
245/// missing, malformed, or does not list the skill.
246pub fn lookup_registry_skill_tier(registry_dir: &Path, name: &str) -> (SkillTier, Option<String>) {
247    let path = registry_dir.join("registry.json");
248    let Ok(data) = std::fs::read_to_string(&path) else {
249        return (SkillTier::Unknown, None);
250    };
251    let Ok(index) = serde_json::from_str::<RegistryIndex>(&data) else {
252        return (SkillTier::Unknown, None);
253    };
254    let Some(entry) = index.skills.into_iter().find(|e| e.name == name) else {
255        return (SkillTier::Unknown, None);
256    };
257    (tier_from_tags(&entry.tags), entry.version)
258}
259
260/// Build the install-time tier banner. `Official` skills get a single
261/// informational line; everything else (including `Featured` and the
262/// missing-tag fallback) gets the Community warn block.
263pub fn build_install_tier_banner(name: &str, version: Option<&str>, tier: SkillTier) -> String {
264    let version_label = version.unwrap_or("?");
265    let args = [("name", name), ("version", version_label)];
266    let key = match tier {
267        SkillTier::Official => "cli-skills-install-tier-official",
268        SkillTier::Community | SkillTier::Featured | SkillTier::Unknown => {
269            "cli-skills-install-tier-community"
270        }
271    };
272    let mut banner = crate::i18n::get_required_cli_string_with_args(key, &args);
273    if !banner.ends_with('\n') {
274        banner.push('\n');
275    }
276    banner
277}
278
279/// Print the install-time tier banner to stdout.
280pub fn print_install_tier_banner(name: &str, version: Option<&str>, tier: SkillTier) {
281    print!("{}", build_install_tier_banner(name, version, tier));
282}
283
284/// Emit a user-visible warning when a skill directory is skipped due to audit
285/// findings. When the findings mention blocked scripts and `allow_scripts` is
286/// `false`, the message includes actionable remediation guidance so users know
287/// how to enable their skill.
288fn warn_skipped_skill(path: &Path, summary: &str, allow_scripts: bool) {
289    let scripts_blocked = summary.contains("script-like files are blocked");
290    if scripts_blocked && !allow_scripts {
291        ::zeroclaw_log::record!(
292            WARN,
293            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
294                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
295            &format!(
296                "skipping skill directory {}: {summary}. \
297             To allow script files in skills, set `skills.allow_scripts = true` in your config.",
298                path.display().to_string()
299            )
300        );
301        eprintln!(
302            "warning: skill '{}' was skipped because it contains script files. \
303             Set `skills.allow_scripts = true` in your zeroclaw config to enable it.",
304            path.file_name()
305                .map(|n| n.to_string_lossy().into_owned())
306                .unwrap_or_else(|| path.display().to_string()),
307        );
308    } else {
309        ::zeroclaw_log::record!(
310            WARN,
311            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
312                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
313            &format!(
314                "skipping insecure skill directory {}: {summary}",
315                path.display().to_string()
316            )
317        );
318    }
319}
320
321fn warn_metadata_drift(skill_dir: &Path, toml_skill: &Skill, md_path: &Path) {
322    if !md_path.exists() {
323        return;
324    }
325    let Ok(md_content) = std::fs::read_to_string(md_path) else {
326        return;
327    };
328    let parsed = parse_skill_markdown(&md_content);
329    let dir_name = skill_dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
330
331    if let Some(ref md_name) = parsed.meta.name
332        && md_name != &toml_skill.name
333    {
334        ::zeroclaw_log::record!(
335            WARN,
336            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
337                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
338            &format!(
339                "skill '{}': name mismatch between TOML ('{}') and SKILL.md ('{}')",
340                dir_name, toml_skill.name, md_name
341            )
342        );
343    }
344    if let Some(ref md_desc) = parsed.meta.description {
345        let md_desc = md_desc.trim();
346        if !md_desc.is_empty() && md_desc != ">-" && md_desc != toml_skill.description.trim() {
347            ::zeroclaw_log::record!(
348                WARN,
349                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
350                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
351                &format!(
352                    "skill '{}': description mismatch between TOML and SKILL.md — TOML takes precedence",
353                    dir_name
354                )
355            );
356        }
357    }
358}
359
360/// Load all skills from the workspace skills directory
361pub fn load_skills(workspace_dir: &Path) -> Vec<Skill> {
362    load_skills_with_open_skills_config(workspace_dir, None, None, None)
363}
364
365/// Load skills using runtime config values (preferred at runtime).
366pub fn load_skills_with_config(
367    workspace_dir: &Path,
368    config: &zeroclaw_config::schema::Config,
369) -> Vec<Skill> {
370    #[allow(unused_mut)]
371    let mut skills = load_skills_with_open_skills_config(
372        workspace_dir,
373        Some(config.skills.open_skills_enabled),
374        config.skills.open_skills_dir.as_deref(),
375        Some(config.skills.allow_scripts),
376    );
377
378    #[cfg(feature = "plugins-wasm")]
379    skills.extend(load_plugin_skills_from_config(config));
380
381    skills
382}
383
384/// Per-agent skill discovery. Walks `[agents.<agent_alias>].skill_bundles`,
385/// resolves each bundle's directory via the shared
386/// [`zeroclaw_config::skill_bundles::resolve_directory`] helper, and unions
387/// the skills under each bundle with whatever
388/// [`load_skills_with_config`] would return for the install (workspace
389/// skills, open-skills, plugin skills). Empty `skill_bundles` falls back
390/// to the install-wide set — keeps freshly-migrated agents working until
391/// the operator assigns a bundle.
392pub fn load_skills_for_agent(
393    workspace_dir: &Path,
394    config: &zeroclaw_config::schema::Config,
395    agent_alias: &str,
396) -> Vec<Skill> {
397    let mut skills = load_skills_with_config(workspace_dir, config);
398    let Some(agent) = config.agent(agent_alias) else {
399        return skills;
400    };
401    if agent.skill_bundles.is_empty() {
402        return skills;
403    }
404    let install_root = config.install_root_dir();
405    let allow_scripts = config.skills.allow_scripts;
406    let mut seen: std::collections::HashSet<String> =
407        skills.iter().map(|s| s.name.clone()).collect();
408    for bundle_alias in &agent.skill_bundles {
409        let bundle = match config.skill_bundles.get(bundle_alias) {
410            Some(b) => b,
411            None => {
412                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"agent": agent_alias, "bundle": bundle_alias, "bundle_alias": bundle_alias})), "skipping skill bundle: [skill_bundles.] is not configured");
413                continue;
414            }
415        };
416        let dir = match zeroclaw_config::skill_bundles::resolve_directory(
417            config,
418            &install_root,
419            bundle_alias,
420        ) {
421            Ok(d) => d,
422            Err(e) => {
423                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"agent": agent_alias, "bundle": bundle_alias, "e": e.to_string()})), "skipping skill bundle: ");
424                continue;
425            }
426        };
427        let include: std::collections::HashSet<&str> =
428            bundle.include.iter().map(String::as_str).collect();
429        let exclude: std::collections::HashSet<&str> =
430            bundle.exclude.iter().map(String::as_str).collect();
431        for skill in load_skills_from_directory(&dir, allow_scripts) {
432            if !include.is_empty() && !include.contains(skill.name.as_str()) {
433                continue;
434            }
435            if exclude.contains(skill.name.as_str()) {
436                continue;
437            }
438            // First-write wins so workspace skills override bundle skills
439            // with the same name (legacy agents who edited a workspace
440            // copy keep their override after a bundle is assigned).
441            if seen.insert(skill.name.clone()) {
442                skills.push(skill);
443            }
444        }
445    }
446    skills
447}
448
449/// Load skills using explicit open-skills settings.
450pub fn load_skills_with_open_skills_settings(
451    workspace_dir: &Path,
452    open_skills_enabled: bool,
453    open_skills_dir: Option<&str>,
454    allow_scripts: bool,
455) -> Vec<Skill> {
456    load_skills_with_open_skills_config(
457        workspace_dir,
458        Some(open_skills_enabled),
459        open_skills_dir,
460        Some(allow_scripts),
461    )
462}
463
464fn load_skills_with_open_skills_config(
465    workspace_dir: &Path,
466    config_open_skills_enabled: Option<bool>,
467    config_open_skills_dir: Option<&str>,
468    config_allow_scripts: Option<bool>,
469) -> Vec<Skill> {
470    let mut skills = Vec::new();
471    let allow_scripts = config_allow_scripts.unwrap_or(false);
472
473    if let Some(open_skills_dir) =
474        ensure_open_skills_repo(config_open_skills_enabled, config_open_skills_dir)
475    {
476        skills.extend(load_open_skills(&open_skills_dir, allow_scripts));
477    }
478
479    skills.extend(load_workspace_skills(workspace_dir, allow_scripts));
480    skills
481}
482
483fn load_workspace_skills(workspace_dir: &Path, allow_scripts: bool) -> Vec<Skill> {
484    let skills_dir = workspace_dir.join("skills");
485    load_skills_from_directory(&skills_dir, allow_scripts)
486}
487
488pub fn load_skills_from_directory(skills_dir: &Path, allow_scripts: bool) -> Vec<Skill> {
489    if !skills_dir.exists() {
490        return Vec::new();
491    }
492
493    let mut skills = Vec::new();
494
495    let Ok(entries) = std::fs::read_dir(skills_dir) else {
496        return skills;
497    };
498
499    for entry in entries.flatten() {
500        let path = entry.path();
501        if !path.is_dir() {
502            continue;
503        }
504
505        match audit::audit_skill_directory_with_options(
506            &path,
507            audit::SkillAuditOptions { allow_scripts },
508        ) {
509            Ok(report) if report.is_clean() => {}
510            Ok(report) => {
511                let summary = report.summary();
512                warn_skipped_skill(&path, &summary, allow_scripts);
513                continue;
514            }
515            Err(err) => {
516                ::zeroclaw_log::record!(
517                    WARN,
518                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
519                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
520                    &format!(
521                        "skipping unauditable skill directory {}: {err}",
522                        path.display().to_string()
523                    )
524                );
525                continue;
526            }
527        }
528
529        // Try SKILL.toml first, then manifest.toml (registry format), then SKILL.md
530        let skill_toml_path = path.join("SKILL.toml");
531        let manifest_toml_path = path.join("manifest.toml");
532        let md_path = path.join("SKILL.md");
533
534        let toml_path = if skill_toml_path.exists() {
535            Some(skill_toml_path)
536        } else if manifest_toml_path.exists() {
537            Some(manifest_toml_path)
538        } else {
539            None
540        };
541
542        if let Some(toml_path) = toml_path {
543            match load_skill_toml(&toml_path) {
544                Ok(skill) => {
545                    warn_metadata_drift(&path, &skill, &md_path);
546                    skills.push(skill);
547                }
548                Err(e) => {
549                    ::zeroclaw_log::record!(
550                        WARN,
551                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
552                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
553                            .with_attrs(::serde_json::json!({
554                                "path": toml_path.display().to_string(),
555                                "error": format!("{}", e),
556                            })),
557                        "failed to load SKILL.toml — skill directory skipped"
558                    );
559                }
560            }
561        } else if md_path.exists()
562            && let Ok(skill) = load_skill_md(&md_path, &path)
563        {
564            skills.push(skill);
565        }
566    }
567
568    skills
569}
570
571fn finalize_open_skill(mut skill: Skill) -> Skill {
572    if !skill.tags.iter().any(|tag| tag == "open-skills") {
573        skill.tags.push("open-skills".to_string());
574    }
575    if skill.author.is_none() {
576        skill.author = Some("besoeasy/open-skills".to_string());
577    }
578    skill
579}
580
581fn load_open_skills_from_directory(skills_dir: &Path, allow_scripts: bool) -> Vec<Skill> {
582    if !skills_dir.exists() {
583        return Vec::new();
584    }
585
586    let mut skills = Vec::new();
587
588    let Ok(entries) = std::fs::read_dir(skills_dir) else {
589        return skills;
590    };
591
592    for entry in entries.flatten() {
593        let path = entry.path();
594        if !path.is_dir() {
595            continue;
596        }
597
598        match audit::audit_skill_directory_with_options(
599            &path,
600            audit::SkillAuditOptions { allow_scripts },
601        ) {
602            Ok(report) if report.is_clean() => {}
603            Ok(report) => {
604                let summary = report.summary();
605                warn_skipped_skill(&path, &summary, allow_scripts);
606                continue;
607            }
608            Err(err) => {
609                ::zeroclaw_log::record!(
610                    WARN,
611                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
612                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
613                    &format!(
614                        "skipping unauditable open-skill directory {}: {err}",
615                        path.display().to_string()
616                    )
617                );
618                continue;
619            }
620        }
621
622        let skill_toml_path = path.join("SKILL.toml");
623        let manifest_toml_path = path.join("manifest.toml");
624        let md_path = path.join("SKILL.md");
625
626        let toml_path = if skill_toml_path.exists() {
627            Some(skill_toml_path)
628        } else if manifest_toml_path.exists() {
629            Some(manifest_toml_path)
630        } else {
631            None
632        };
633
634        if let Some(toml_path) = toml_path {
635            match load_skill_toml(&toml_path) {
636                Ok(skill) => {
637                    warn_metadata_drift(&path, &skill, &md_path);
638                    skills.push(finalize_open_skill(skill));
639                }
640                Err(e) => {
641                    ::zeroclaw_log::record!(
642                        WARN,
643                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
644                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
645                            .with_attrs(::serde_json::json!({
646                                "path": toml_path.display().to_string(),
647                                "error": format!("{}", e),
648                            })),
649                        "failed to load SKILL.toml — skill directory skipped"
650                    );
651                }
652            }
653        } else if md_path.exists()
654            && let Ok(skill) = load_open_skill_md(&md_path)
655        {
656            skills.push(skill);
657        }
658    }
659
660    skills
661}
662
663fn load_open_skills(repo_dir: &Path, allow_scripts: bool) -> Vec<Skill> {
664    // Modern open-skills layout stores skill packages in `skills/<name>/SKILL.md`.
665    // Prefer that structure to avoid treating repository docs (e.g. CONTRIBUTING.md)
666    // as executable skills.
667    let nested_skills_dir = repo_dir.join("skills");
668    if nested_skills_dir.is_dir() {
669        return load_open_skills_from_directory(&nested_skills_dir, allow_scripts);
670    }
671
672    let mut skills = Vec::new();
673
674    let Ok(entries) = std::fs::read_dir(repo_dir) else {
675        return skills;
676    };
677
678    for entry in entries.flatten() {
679        let path = entry.path();
680        if !path.is_file() {
681            continue;
682        }
683
684        let is_markdown = path
685            .extension()
686            .and_then(|ext| ext.to_str())
687            .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
688        if !is_markdown {
689            continue;
690        }
691
692        let is_readme = path
693            .file_name()
694            .and_then(|name| name.to_str())
695            .is_some_and(|name| name.eq_ignore_ascii_case("README.md"));
696        if is_readme {
697            continue;
698        }
699
700        match audit::audit_open_skill_markdown(&path, repo_dir) {
701            Ok(report) if report.is_clean() => {}
702            Ok(report) => {
703                ::zeroclaw_log::record!(
704                    WARN,
705                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
706                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
707                    &format!(
708                        "skipping insecure open-skill file {}: {}",
709                        path.display().to_string(),
710                        report.summary()
711                    )
712                );
713                continue;
714            }
715            Err(err) => {
716                ::zeroclaw_log::record!(
717                    WARN,
718                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
719                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
720                    &format!(
721                        "skipping unauditable open-skill file {}: {err}",
722                        path.display().to_string()
723                    )
724                );
725                continue;
726            }
727        }
728
729        if let Ok(skill) = load_open_skill_md(&path) {
730            skills.push(skill);
731        }
732    }
733
734    skills
735}
736
737fn parse_open_skills_enabled(raw: &str) -> Option<bool> {
738    match raw.trim().to_ascii_lowercase().as_str() {
739        "1" | "true" | "yes" | "on" => Some(true),
740        "0" | "false" | "no" | "off" => Some(false),
741        _ => None,
742    }
743}
744
745fn open_skills_enabled_from_sources(
746    config_open_skills_enabled: Option<bool>,
747    env_override: Option<&str>,
748) -> bool {
749    if let Some(raw) = env_override {
750        if let Some(enabled) = parse_open_skills_enabled(raw) {
751            return enabled;
752        }
753        if !raw.trim().is_empty() {
754            ::zeroclaw_log::record!(
755                WARN,
756                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
757                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
758                "Ignoring invalid ZEROCLAW_OPEN_SKILLS_ENABLED (valid: 1|0|true|false|yes|no|on|off)"
759            );
760        }
761    }
762
763    config_open_skills_enabled.unwrap_or(false)
764}
765
766fn open_skills_enabled(config_open_skills_enabled: Option<bool>) -> bool {
767    let env_override = std::env::var("ZEROCLAW_OPEN_SKILLS_ENABLED").ok();
768    open_skills_enabled_from_sources(config_open_skills_enabled, env_override.as_deref())
769}
770
771fn resolve_open_skills_dir_from_sources(
772    env_dir: Option<&str>,
773    config_dir: Option<&str>,
774    home_dir: Option<&Path>,
775) -> Option<PathBuf> {
776    let parse_dir = |raw: &str| {
777        let trimmed = raw.trim();
778        if trimmed.is_empty() {
779            None
780        } else {
781            Some(PathBuf::from(trimmed))
782        }
783    };
784
785    if let Some(env_dir) = env_dir.and_then(parse_dir) {
786        return Some(env_dir);
787    }
788    if let Some(config_dir) = config_dir.and_then(parse_dir) {
789        return Some(config_dir);
790    }
791    home_dir.map(|home| home.join("open-skills"))
792}
793
794fn resolve_open_skills_dir(config_open_skills_dir: Option<&str>) -> Option<PathBuf> {
795    let env_dir = std::env::var("ZEROCLAW_OPEN_SKILLS_DIR").ok();
796    let home_dir = UserDirs::new().map(|dirs| dirs.home_dir().to_path_buf());
797    resolve_open_skills_dir_from_sources(
798        env_dir.as_deref(),
799        config_open_skills_dir,
800        home_dir.as_deref(),
801    )
802}
803
804fn ensure_open_skills_repo(
805    config_open_skills_enabled: Option<bool>,
806    config_open_skills_dir: Option<&str>,
807) -> Option<PathBuf> {
808    if !open_skills_enabled(config_open_skills_enabled) {
809        return None;
810    }
811
812    let repo_dir = resolve_open_skills_dir(config_open_skills_dir)?;
813
814    if !repo_dir.exists() {
815        if !clone_open_skills_repo(&repo_dir) {
816            return None;
817        }
818        let _ = mark_open_skills_synced(&repo_dir);
819        return Some(repo_dir);
820    }
821
822    if should_sync_open_skills(&repo_dir) {
823        if pull_open_skills_repo(&repo_dir) {
824            let _ = mark_open_skills_synced(&repo_dir);
825        } else {
826            ::zeroclaw_log::record!(
827                WARN,
828                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
829                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
830                &format!(
831                    "open-skills update failed; using local copy from {}",
832                    repo_dir.display().to_string()
833                )
834            );
835        }
836    }
837
838    Some(repo_dir)
839}
840
841fn clone_open_skills_repo(repo_dir: &Path) -> bool {
842    if let Some(parent) = repo_dir.parent()
843        && let Err(err) = std::fs::create_dir_all(parent)
844    {
845        ::zeroclaw_log::record!(
846            WARN,
847            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
848                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
849            &format!(
850                "failed to create open-skills parent directory {}: {err}",
851                parent.display().to_string()
852            )
853        );
854        return false;
855    }
856
857    let output = Command::new("git")
858        .args(["clone", "--depth", "1", OPEN_SKILLS_REPO_URL])
859        .arg(repo_dir)
860        .output();
861
862    match output {
863        Ok(result) if result.status.success() => {
864            ::zeroclaw_log::record!(
865                INFO,
866                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
867                &format!(
868                    "initialized open-skills at {}",
869                    repo_dir.display().to_string()
870                )
871            );
872            true
873        }
874        Ok(result) => {
875            let stderr = String::from_utf8_lossy(&result.stderr);
876            ::zeroclaw_log::record!(
877                WARN,
878                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
879                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
880                    .with_attrs(::serde_json::json!({"stderr": stderr})),
881                "failed to clone open-skills: "
882            );
883            false
884        }
885        Err(err) => {
886            ::zeroclaw_log::record!(
887                WARN,
888                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
889                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
890                    .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
891                "failed to run git clone for open-skills"
892            );
893            false
894        }
895    }
896}
897
898fn pull_open_skills_repo(repo_dir: &Path) -> bool {
899    // If user points to a non-git directory via env var, keep using it without pulling.
900    if !repo_dir.join(".git").exists() {
901        return true;
902    }
903
904    let output = Command::new("git")
905        .arg("-C")
906        .arg(repo_dir)
907        .args(["pull", "--ff-only"])
908        .output();
909
910    match output {
911        Ok(result) if result.status.success() => true,
912        Ok(result) => {
913            let stderr = String::from_utf8_lossy(&result.stderr);
914            ::zeroclaw_log::record!(
915                WARN,
916                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
917                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
918                    .with_attrs(::serde_json::json!({"stderr": stderr})),
919                "failed to pull open-skills updates: "
920            );
921            false
922        }
923        Err(err) => {
924            ::zeroclaw_log::record!(
925                WARN,
926                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
927                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
928                    .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
929                "failed to run git pull for open-skills"
930            );
931            false
932        }
933    }
934}
935
936fn should_sync_open_skills(repo_dir: &Path) -> bool {
937    let marker = repo_dir.join(OPEN_SKILLS_SYNC_MARKER);
938    let Ok(metadata) = std::fs::metadata(marker) else {
939        return true;
940    };
941    let Ok(modified_at) = metadata.modified() else {
942        return true;
943    };
944    let Ok(age) = SystemTime::now().duration_since(modified_at) else {
945        return true;
946    };
947
948    age >= Duration::from_secs(OPEN_SKILLS_SYNC_INTERVAL_SECS)
949}
950
951fn mark_open_skills_synced(repo_dir: &Path) -> Result<()> {
952    std::fs::write(repo_dir.join(OPEN_SKILLS_SYNC_MARKER), b"synced")?;
953    Ok(())
954}
955
956/// Load a skill from a SKILL.toml manifest
957fn load_skill_toml(path: &Path) -> Result<Skill> {
958    let content = std::fs::read_to_string(path)?;
959    let manifest: SkillManifest = toml::from_str(&content)?;
960
961    // Merge prompts from both locations: inside the [skill] table (natural
962    // location for per-skill prompts) and at the manifest root (historical
963    // location). Previously, prompts placed inside [skill] were silently
964    // dropped because SkillMeta had no `prompts` field.
965    let mut prompts = manifest.skill.prompts;
966    prompts.extend(manifest.prompts);
967
968    Ok(Skill {
969        name: manifest.skill.name,
970        description: manifest.skill.description,
971        version: manifest.skill.version,
972        author: manifest.skill.author,
973        tags: manifest.skill.tags,
974        tools: manifest.tools,
975        prompts,
976        location: Some(path.to_path_buf()),
977    })
978}
979
980/// Load a skill from a SKILL.md file (simpler format)
981fn load_skill_md(path: &Path, dir: &Path) -> Result<Skill> {
982    let content = std::fs::read_to_string(path)?;
983    let parsed = parse_skill_markdown(&content);
984    let name = dir
985        .file_name()
986        .and_then(|n| n.to_str())
987        .unwrap_or("unknown")
988        .to_string();
989
990    Ok(Skill {
991        name: parsed.meta.name.unwrap_or(name),
992        description: parsed
993            .meta
994            .description
995            .filter(|value| !value.trim().is_empty())
996            .unwrap_or_else(|| extract_description(&parsed.body)),
997        version: parsed.meta.version.unwrap_or_else(default_version),
998        author: parsed.meta.author,
999        tags: parsed.meta.tags,
1000        tools: Vec::new(),
1001        prompts: vec![parsed.body],
1002        location: Some(path.to_path_buf()),
1003    })
1004}
1005
1006fn load_open_skill_md(path: &Path) -> Result<Skill> {
1007    let content = std::fs::read_to_string(path)?;
1008    let parsed = parse_skill_markdown(&content);
1009    let file_stem = path
1010        .file_stem()
1011        .and_then(|n| n.to_str())
1012        .unwrap_or("open-skill")
1013        .to_string();
1014    let name = if file_stem.eq_ignore_ascii_case("skill") {
1015        path.parent()
1016            .and_then(|dir| dir.file_name())
1017            .and_then(|name| name.to_str())
1018            .unwrap_or(&file_stem)
1019            .to_string()
1020    } else {
1021        file_stem
1022    };
1023    Ok(finalize_open_skill(Skill {
1024        name: parsed.meta.name.unwrap_or(name),
1025        description: parsed
1026            .meta
1027            .description
1028            .filter(|value| !value.trim().is_empty())
1029            .unwrap_or_else(|| extract_description(&parsed.body)),
1030        version: parsed
1031            .meta
1032            .version
1033            .unwrap_or_else(|| "open-skills".to_string()),
1034        author: parsed
1035            .meta
1036            .author
1037            .or_else(|| Some("besoeasy/open-skills".to_string())),
1038        tags: parsed.meta.tags,
1039        tools: Vec::new(),
1040        prompts: vec![parsed.body],
1041        location: Some(path.to_path_buf()),
1042    }))
1043}
1044
1045struct ParsedSkillMarkdown {
1046    meta: SkillMarkdownMeta,
1047    body: String,
1048}
1049
1050fn parse_skill_markdown(content: &str) -> ParsedSkillMarkdown {
1051    if let Some((frontmatter, body)) = split_skill_frontmatter(content) {
1052        let meta = parse_simple_frontmatter(&frontmatter);
1053        return ParsedSkillMarkdown { meta, body };
1054    }
1055
1056    ParsedSkillMarkdown {
1057        meta: SkillMarkdownMeta::default(),
1058        body: content.to_string(),
1059    }
1060}
1061
1062/// Lightweight YAML-like frontmatter parser for simple `key: value` pairs.
1063/// Replaces `serde_yaml` to avoid pulling in the full YAML parser (~30KB)
1064/// for a struct with only 5 optional string fields.
1065fn parse_simple_frontmatter(s: &str) -> SkillMarkdownMeta {
1066    let mut meta = SkillMarkdownMeta::default();
1067    let mut collecting_tags = false;
1068    let mut collecting_multiline: Option<String> = None;
1069    let mut multiline_parts: Vec<String> = Vec::new();
1070
1071    let flush_multiline = |key: &str, parts: &[String], meta: &mut SkillMarkdownMeta| {
1072        let joined = parts.join(" ");
1073        let val = joined.trim();
1074        if !val.is_empty() {
1075            match key {
1076                "description" => meta.description = Some(val.to_string()),
1077                "name" => meta.name = Some(val.to_string()),
1078                _ => {}
1079            }
1080        }
1081    };
1082
1083    for line in s.lines() {
1084        // Collect indented continuation lines for YAML block scalars (>- or |)
1085        if let Some(ref key) = collecting_multiline {
1086            if line.starts_with(' ') || line.starts_with('\t') {
1087                multiline_parts.push(line.trim().to_string());
1088                continue;
1089            }
1090            flush_multiline(key, &multiline_parts, &mut meta);
1091            collecting_multiline = None;
1092            multiline_parts.clear();
1093        }
1094
1095        // Handle YAML list items under `tags:` (e.g. "  - parser")
1096        if collecting_tags {
1097            let trimmed = line.trim();
1098            if let Some(item) = trimmed.strip_prefix("- ") {
1099                let tag = item.trim().trim_matches('"').trim_matches('\'');
1100                if !tag.is_empty() {
1101                    meta.tags.push(tag.to_string());
1102                }
1103                continue;
1104            }
1105            // Non-list-item line → stop collecting tags
1106            collecting_tags = false;
1107        }
1108        let Some((key, val)) = line.split_once(':') else {
1109            continue;
1110        };
1111        let key = key.trim();
1112        let val = val.trim().trim_matches('"').trim_matches('\'');
1113        // YAML block scalar indicators — collect continuation lines
1114        if val == ">-" || val == ">" || val == "|" || val == "|-" {
1115            collecting_multiline = Some(key.to_string());
1116            multiline_parts.clear();
1117            continue;
1118        }
1119        match key {
1120            "name" => meta.name = Some(val.to_string()),
1121            "description" => meta.description = Some(val.to_string()),
1122            "version" => meta.version = Some(val.to_string()),
1123            "author" => meta.author = Some(val.to_string()),
1124            "tags" => {
1125                if val.is_empty() {
1126                    // YAML block list follows on subsequent lines
1127                    collecting_tags = true;
1128                } else {
1129                    // Inline: [a, b, c] or comma-separated
1130                    let val = val.trim_start_matches('[').trim_end_matches(']');
1131                    meta.tags = val
1132                        .split(',')
1133                        .map(|t| t.trim().trim_matches('"').trim_matches('\'').to_string())
1134                        .filter(|t| !t.is_empty())
1135                        .collect();
1136                }
1137            }
1138            _ => {}
1139        }
1140    }
1141    if let Some(ref key) = collecting_multiline {
1142        flush_multiline(key, &multiline_parts, &mut meta);
1143    }
1144    meta
1145}
1146
1147fn split_skill_frontmatter(content: &str) -> Option<(String, String)> {
1148    let normalized = content.replace("\r\n", "\n");
1149    let rest = normalized.strip_prefix("---\n")?;
1150    if let Some(idx) = rest.find("\n---\n") {
1151        let frontmatter = rest[..idx].to_string();
1152        let body = rest[idx + 5..].to_string();
1153        return Some((frontmatter, body));
1154    }
1155    if let Some(frontmatter) = rest.strip_suffix("\n---") {
1156        return Some((frontmatter.to_string(), String::new()));
1157    }
1158    None
1159}
1160
1161fn extract_description(content: &str) -> String {
1162    content
1163        .lines()
1164        .find(|line| !line.starts_with('#') && !line.trim().is_empty())
1165        .unwrap_or("No description")
1166        .trim()
1167        .to_string()
1168}
1169
1170fn append_xml_escaped(out: &mut String, text: &str) {
1171    for ch in text.chars() {
1172        match ch {
1173            '&' => out.push_str("&amp;"),
1174            '<' => out.push_str("&lt;"),
1175            '>' => out.push_str("&gt;"),
1176            '"' => out.push_str("&quot;"),
1177            '\'' => out.push_str("&apos;"),
1178            _ => out.push(ch),
1179        }
1180    }
1181}
1182
1183fn write_xml_text_element(out: &mut String, indent: usize, tag: &str, value: &str) {
1184    for _ in 0..indent {
1185        out.push(' ');
1186    }
1187    out.push('<');
1188    out.push_str(tag);
1189    out.push('>');
1190    append_xml_escaped(out, value);
1191    out.push_str("</");
1192    out.push_str(tag);
1193    out.push_str(">\n");
1194}
1195
1196fn resolve_skill_location(skill: &Skill, workspace_dir: &Path) -> PathBuf {
1197    skill.location.clone().unwrap_or_else(|| {
1198        workspace_dir
1199            .join("skills")
1200            .join(&skill.name)
1201            .join("SKILL.md")
1202    })
1203}
1204
1205fn render_skill_location(skill: &Skill, workspace_dir: &Path, prefer_relative: bool) -> String {
1206    let location = resolve_skill_location(skill, workspace_dir);
1207    if prefer_relative && let Ok(relative) = location.strip_prefix(workspace_dir) {
1208        return relative.display().to_string();
1209    }
1210    location.display().to_string()
1211}
1212
1213/// Build the "Available Skills" system prompt section with full skill instructions.
1214pub fn skills_to_prompt(skills: &[Skill], workspace_dir: &Path) -> String {
1215    skills_to_prompt_with_mode(
1216        skills,
1217        workspace_dir,
1218        zeroclaw_config::schema::SkillsPromptInjectionMode::Full,
1219    )
1220}
1221
1222/// Build the "Available Skills" system prompt section with configurable verbosity.
1223pub fn skills_to_prompt_with_mode(
1224    skills: &[Skill],
1225    workspace_dir: &Path,
1226    mode: zeroclaw_config::schema::SkillsPromptInjectionMode,
1227) -> String {
1228    use std::fmt::Write;
1229
1230    if skills.is_empty() {
1231        return String::new();
1232    }
1233
1234    let mut prompt = match mode {
1235        zeroclaw_config::schema::SkillsPromptInjectionMode::Full => String::from(
1236            "## Available Skills\n\n\
1237             Skill instructions and tool metadata are preloaded below.\n\
1238             Follow these instructions directly; do not read skill files at runtime unless the user asks.\n\n\
1239             <available_skills>\n",
1240        ),
1241        zeroclaw_config::schema::SkillsPromptInjectionMode::Compact => String::from(
1242            "## Available Skills\n\n\
1243             Skill summaries are preloaded below to keep context compact.\n\
1244             Skill instructions are loaded on demand: call `read_skill(name)` with the skill's `<name>` when you need the full skill file.\n\
1245             The `location` field is included for reference.\n\n\
1246             <available_skills>\n",
1247        ),
1248    };
1249
1250    for skill in skills {
1251        let _ = writeln!(prompt, "  <skill>");
1252        write_xml_text_element(&mut prompt, 4, "name", &skill.name);
1253        write_xml_text_element(&mut prompt, 4, "description", &skill.description);
1254        let location = render_skill_location(
1255            skill,
1256            workspace_dir,
1257            matches!(
1258                mode,
1259                zeroclaw_config::schema::SkillsPromptInjectionMode::Compact
1260            ),
1261        );
1262        write_xml_text_element(&mut prompt, 4, "location", &location);
1263
1264        // In Full mode, inline both instructions and tools.
1265        // In Compact mode, skip instructions (loaded on demand) but keep tools
1266        // so the LLM knows which skill tools are available.
1267        if matches!(
1268            mode,
1269            zeroclaw_config::schema::SkillsPromptInjectionMode::Full
1270        ) && !skill.prompts.is_empty()
1271        {
1272            let _ = writeln!(prompt, "    <instructions>");
1273            for instruction in &skill.prompts {
1274                write_xml_text_element(&mut prompt, 6, "instruction", instruction);
1275            }
1276            let _ = writeln!(prompt, "    </instructions>");
1277        }
1278
1279        if !skill.tools.is_empty() {
1280            // Tools with known kinds (shell, script, http) are registered as
1281            // callable tool specs and can be invoked directly via function calling.
1282            // We note them here for context but mark them as callable.
1283            let registered: Vec<_> = skill
1284                .tools
1285                .iter()
1286                .filter(|t| matches!(t.kind.as_str(), "shell" | "script" | "http" | "builtin"))
1287                .collect();
1288            let unregistered: Vec<_> = skill
1289                .tools
1290                .iter()
1291                .filter(|t| !matches!(t.kind.as_str(), "shell" | "script" | "http" | "builtin"))
1292                .collect();
1293
1294            if !registered.is_empty() {
1295                let _ = writeln!(
1296                    prompt,
1297                    "    <callable_tools hint=\"These are registered as callable tool specs. Invoke them directly by name ({{}}__{{}}) instead of using shell.\">"
1298                );
1299                for tool in &registered {
1300                    let _ = writeln!(prompt, "      <tool>");
1301                    write_xml_text_element(
1302                        &mut prompt,
1303                        8,
1304                        "name",
1305                        &format!("{}__{}", skill.name, tool.name),
1306                    );
1307                    write_xml_text_element(&mut prompt, 8, "description", &tool.description);
1308                    let _ = writeln!(prompt, "      </tool>");
1309                }
1310                let _ = writeln!(prompt, "    </callable_tools>");
1311            }
1312
1313            if !unregistered.is_empty() {
1314                let _ = writeln!(prompt, "    <tools>");
1315                for tool in &unregistered {
1316                    let _ = writeln!(prompt, "      <tool>");
1317                    write_xml_text_element(&mut prompt, 8, "name", &tool.name);
1318                    write_xml_text_element(&mut prompt, 8, "description", &tool.description);
1319                    write_xml_text_element(&mut prompt, 8, "kind", &tool.kind);
1320                    let _ = writeln!(prompt, "      </tool>");
1321                }
1322                let _ = writeln!(prompt, "    </tools>");
1323            }
1324        }
1325
1326        let _ = writeln!(prompt, "  </skill>");
1327    }
1328
1329    prompt.push_str("</available_skills>");
1330    prompt
1331}
1332
1333/// Convert skill tools into callable `Tool` trait objects.
1334///
1335/// Each skill's `[[tools]]` entries are converted to either `SkillShellTool`
1336/// (for `shell`/`script` kinds), `SkillHttpTool` (for `http` kind), or
1337/// `SkillBuiltinTool` (for `builtin` kind), enabling them to appear as
1338/// first-class callable tool specs rather than only as XML in the system
1339/// prompt.
1340///
1341/// The `builtin` kind requires the unfiltered tool registry. Use
1342/// [`skills_to_tools_with_context`] to register that kind.
1343pub fn skills_to_tools(
1344    skills: &[Skill],
1345    security: std::sync::Arc<crate::security::SecurityPolicy>,
1346) -> Vec<Box<dyn zeroclaw_api::tool::Tool>> {
1347    skills_to_tools_with_context(skills, security, &[])
1348}
1349
1350/// Convert skill tools into callable `Tool` trait objects with full context.
1351///
1352/// `unfiltered_registry` provides the pre-policy tool list for `builtin`
1353/// delegation.
1354/// Resolve a skill elevation tool (`kind = "builtin"` or `kind = "mcp"`).
1355///
1356/// Both kinds delegate to a tool resolved by name from `resolution_registry`
1357/// (built-in tools + MCP tool wrappers). The only difference is `kind_label`,
1358/// used for diagnostics. Returns `None` (after a WARN) when the `target` is
1359/// missing or not resolvable, so a misconfigured manifest is skipped, never
1360/// fatal.
1361fn resolve_elevated_tool(
1362    skill_name: &str,
1363    tool: &SkillTool,
1364    kind_label: &str,
1365    resolution_registry: &[std::sync::Arc<dyn zeroclaw_api::tool::Tool>],
1366) -> Option<Box<dyn zeroclaw_api::tool::Tool>> {
1367    let Some(target_name) = tool.target.as_deref() else {
1368        ::zeroclaw_log::record!(
1369            WARN,
1370            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1371                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1372            &format!(
1373                "Skill tool {}.{} has kind='{}' but no 'target' field, skipping",
1374                skill_name, tool.name, kind_label
1375            )
1376        );
1377        return None;
1378    };
1379    match resolution_registry.iter().find(|t| t.name() == target_name) {
1380        Some(target) => Some(Box::new(crate::skills::skill_tool::SkillBuiltinTool::new(
1381            skill_name,
1382            tool,
1383            std::sync::Arc::clone(target),
1384            tool.locked_args.clone(),
1385        ))),
1386        None => {
1387            ::zeroclaw_log::record!(
1388                WARN,
1389                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1390                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1391                &format!(
1392                    "Skill tool {}.{} targets {} '{}' which was not found in the \
1393                     resolution registry (for MCP, use the prefixed name \
1394                     '{{server}}__{{tool}}' and ensure the server is connected), skipping",
1395                    skill_name, tool.name, kind_label, target_name
1396                )
1397            );
1398            None
1399        }
1400    }
1401}
1402
1403pub fn skills_to_tools_with_context(
1404    skills: &[Skill],
1405    security: std::sync::Arc<crate::security::SecurityPolicy>,
1406    unfiltered_registry: &[std::sync::Arc<dyn zeroclaw_api::tool::Tool>],
1407) -> Vec<Box<dyn zeroclaw_api::tool::Tool>> {
1408    let mut tools: Vec<Box<dyn zeroclaw_api::tool::Tool>> = Vec::new();
1409    for skill in skills {
1410        for tool in &skill.tools {
1411            match tool.kind.as_str() {
1412                "shell" | "script" => {
1413                    let inner = crate::skills::skill_tool::SkillShellTool::new(
1414                        &skill.name,
1415                        tool,
1416                        security.clone(),
1417                    );
1418                    tools.push(Box::new(zeroclaw_tools::wrappers::RateLimitedTool::new(
1419                        inner,
1420                        security.clone(),
1421                    )));
1422                }
1423                "http" => {
1424                    tools.push(Box::new(crate::skills::skill_http::SkillHttpTool::new(
1425                        &skill.name,
1426                        tool,
1427                    )));
1428                }
1429                "builtin" => {
1430                    if let Some(t) =
1431                        resolve_elevated_tool(&skill.name, tool, "builtin", unfiltered_registry)
1432                    {
1433                        tools.push(t);
1434                    }
1435                }
1436                "mcp" => {
1437                    if let Some(t) =
1438                        resolve_elevated_tool(&skill.name, tool, "MCP", unfiltered_registry)
1439                    {
1440                        tools.push(t);
1441                    }
1442                }
1443                other => {
1444                    ::zeroclaw_log::record!(
1445                        WARN,
1446                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1447                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1448                        &format!(
1449                            "Unknown skill tool kind '{}' for {}.{}, skipping",
1450                            other, skill.name, tool.name
1451                        )
1452                    );
1453                }
1454            }
1455        }
1456    }
1457    tools
1458}
1459
1460/// Get the skills directory path
1461pub fn skills_dir(workspace_dir: &Path) -> PathBuf {
1462    workspace_dir.join("skills")
1463}
1464
1465/// Initialize the skills directory with a README
1466pub fn init_skills_dir(workspace_dir: &Path) -> Result<()> {
1467    let dir = skills_dir(workspace_dir);
1468    std::fs::create_dir_all(&dir)?;
1469
1470    let readme = dir.join("README.md");
1471    if !readme.exists() {
1472        std::fs::write(
1473            &readme,
1474            "# ZeroClaw Skills\n\n\
1475             Each subdirectory is a skill. Create a `SKILL.toml` or `SKILL.md` file inside.\n\n\
1476             ## SKILL.toml format\n\n\
1477             ```toml\n\
1478             [skill]\n\
1479             name = \"my-skill\"\n\
1480             description = \"What this skill does\"\n\
1481             version = \"0.1.0\"\n\
1482             author = \"your-name\"\n\
1483             tags = [\"productivity\", \"automation\"]\n\n\
1484             [[tools]]\n\
1485             name = \"my_tool\"\n\
1486             description = \"What this tool does\"\n\
1487             kind = \"shell\"\n\
1488             command = \"echo hello\"\n\
1489             ```\n\n\
1490             ## SKILL.md format (simpler)\n\n\
1491             Just write a markdown file with instructions for the agent.\n\
1492             Optional YAML frontmatter is supported for `name`, `description`, `version`, `author`, and `tags`.\n\
1493             The agent will read it and follow the instructions.\n\n\
1494             ## Installing community skills\n\n\
1495             ```bash\n\
1496             zeroclaw skills install <source>\n\
1497             zeroclaw skills list\n\
1498             ```\n",
1499        )?;
1500    }
1501
1502    Ok(())
1503}
1504
1505fn is_clawhub_host(host: &str) -> bool {
1506    host.eq_ignore_ascii_case(CLAWHUB_DOMAIN) || host.eq_ignore_ascii_case(CLAWHUB_WWW_DOMAIN)
1507}
1508
1509fn parse_clawhub_url(source: &str) -> Option<Url> {
1510    let parsed = Url::parse(source).ok()?;
1511    match parsed.scheme() {
1512        "https" | "http" => {}
1513        _ => return None,
1514    }
1515
1516    if !parsed.host_str().is_some_and(is_clawhub_host) {
1517        return None;
1518    }
1519
1520    Some(parsed)
1521}
1522
1523pub fn is_clawhub_source(source: &str) -> bool {
1524    if source.starts_with("clawhub:") {
1525        return true;
1526    }
1527    parse_clawhub_url(source).is_some()
1528}
1529
1530fn clawhub_download_url(source: &str) -> Result<String> {
1531    // Short prefix: clawhub:<slug>
1532    if let Some(slug) = source.strip_prefix("clawhub:") {
1533        let slug = slug.trim().trim_end_matches('/');
1534        if slug.is_empty() || slug.contains('/') {
1535            anyhow::bail!(
1536                "invalid clawhub source '{}': expected 'clawhub:<slug>' (no slashes in slug)",
1537                source
1538            );
1539        }
1540        return Ok(format!("{CLAWHUB_DOWNLOAD_API}?slug={slug}"));
1541    }
1542
1543    // Profile URL: https://clawhub.ai/<owner>/<slug> or https://www.clawhub.ai/<slug>
1544    if let Some(parsed) = parse_clawhub_url(source) {
1545        let path = parsed
1546            .path_segments()
1547            .into_iter()
1548            .flatten()
1549            .collect::<Vec<_>>()
1550            .join("/");
1551
1552        if path.is_empty() {
1553            anyhow::bail!("could not extract slug from ClawhHub URL: {source}");
1554        }
1555
1556        return Ok(format!("{CLAWHUB_DOWNLOAD_API}?slug={path}"));
1557    }
1558
1559    anyhow::bail!("unrecognised ClawhHub source format: {source}")
1560}
1561
1562fn normalize_skill_name(s: &str) -> String {
1563    s.to_lowercase()
1564        .chars()
1565        .map(|c| if c == '-' { '_' } else { c })
1566        .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
1567        .collect()
1568}
1569
1570fn clawhub_skill_dir_name(source: &str) -> Result<String> {
1571    if let Some(slug) = source.strip_prefix("clawhub:") {
1572        let slug = slug.trim().trim_end_matches('/');
1573        let base = slug.rsplit('/').next().unwrap_or(slug);
1574        let name = normalize_skill_name(base);
1575        return Ok(if name.is_empty() {
1576            "skill".to_string()
1577        } else {
1578            name
1579        });
1580    }
1581
1582    let parsed = parse_clawhub_url(source).ok_or_else(|| {
1583        ::zeroclaw_log::record!(
1584            WARN,
1585            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1586                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1587                .with_attrs(::serde_json::json!({"source": source})),
1588            "skill install rejected: invalid clawhub URL"
1589        );
1590        anyhow::Error::msg(format!("invalid clawhub URL: {source}"))
1591    })?;
1592
1593    let path = parsed
1594        .path_segments()
1595        .into_iter()
1596        .flatten()
1597        .collect::<Vec<_>>();
1598
1599    let base = path.last().copied().unwrap_or("skill");
1600    let name = normalize_skill_name(base);
1601    Ok(if name.is_empty() {
1602        "skill".to_string()
1603    } else {
1604        name
1605    })
1606}
1607
1608pub fn is_git_source(source: &str) -> bool {
1609    // ClawHub URLs look like https:// but are not git repos
1610    if is_clawhub_source(source) {
1611        return false;
1612    }
1613    is_git_scheme_source(source, "https://")
1614        || is_git_scheme_source(source, "http://")
1615        || is_git_scheme_source(source, "ssh://")
1616        || is_git_scheme_source(source, "git://")
1617        || is_git_scp_source(source)
1618}
1619
1620fn is_git_scheme_source(source: &str, scheme: &str) -> bool {
1621    let Some(rest) = source.strip_prefix(scheme) else {
1622        return false;
1623    };
1624    if rest.is_empty() || rest.starts_with('/') {
1625        return false;
1626    }
1627
1628    let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
1629    !host.is_empty()
1630}
1631
1632fn is_git_scp_source(source: &str) -> bool {
1633    // SCP-like syntax accepted by git, e.g. git@host:owner/repo.git
1634    // Keep this strict enough to avoid treating local paths as git remotes.
1635    let Some((user_host, remote_path)) = source.split_once(':') else {
1636        return false;
1637    };
1638    if remote_path.is_empty() {
1639        return false;
1640    }
1641    if source.contains("://") {
1642        return false;
1643    }
1644
1645    let Some((user, host)) = user_host.split_once('@') else {
1646        return false;
1647    };
1648    !user.is_empty()
1649        && !host.is_empty()
1650        && !user.contains('/')
1651        && !user.contains('\\')
1652        && !host.contains('/')
1653        && !host.contains('\\')
1654}
1655
1656fn snapshot_skill_children(skills_path: &Path) -> Result<HashSet<PathBuf>> {
1657    let mut paths = HashSet::new();
1658    for entry in std::fs::read_dir(skills_path)? {
1659        let entry = entry?;
1660        paths.insert(entry.path());
1661    }
1662    Ok(paths)
1663}
1664
1665fn detect_newly_installed_directory(
1666    skills_path: &Path,
1667    before: &HashSet<PathBuf>,
1668) -> Result<PathBuf> {
1669    let mut created = Vec::new();
1670    for entry in std::fs::read_dir(skills_path)? {
1671        let entry = entry?;
1672        let path = entry.path();
1673        if !before.contains(&path) && path.is_dir() {
1674            created.push(path);
1675        }
1676    }
1677
1678    match created.len() {
1679        1 => Ok(created.remove(0)),
1680        0 => anyhow::bail!(
1681            "Unable to determine installed skill directory after clone (no new directory found)"
1682        ),
1683        _ => anyhow::bail!(
1684            "Unable to determine installed skill directory after clone (multiple new directories found)"
1685        ),
1686    }
1687}
1688
1689fn enforce_skill_security_audit(
1690    skill_path: &Path,
1691    allow_scripts: bool,
1692) -> Result<audit::SkillAuditReport> {
1693    let report = audit::audit_skill_directory_with_options(
1694        skill_path,
1695        audit::SkillAuditOptions { allow_scripts },
1696    )?;
1697    if report.is_clean() {
1698        return Ok(report);
1699    }
1700
1701    anyhow::bail!("Skill security audit failed: {}", report.summary());
1702}
1703
1704fn remove_git_metadata(skill_path: &Path) -> Result<()> {
1705    let git_dir = skill_path.join(".git");
1706    if git_dir.exists() {
1707        std::fs::remove_dir_all(&git_dir)
1708            .with_context(|| format!("failed to remove {}", git_dir.display().to_string()))?;
1709    }
1710    Ok(())
1711}
1712
1713fn copy_dir_recursive_secure(src: &Path, dest: &Path) -> Result<()> {
1714    let src_meta = std::fs::symlink_metadata(src)
1715        .with_context(|| format!("failed to read metadata for {}", src.display().to_string()))?;
1716    if src_meta.file_type().is_symlink() {
1717        anyhow::bail!(
1718            "Refusing to copy symlinked skill source path: {}",
1719            src.display()
1720        );
1721    }
1722    if !src_meta.is_dir() {
1723        anyhow::bail!(
1724            "Skill source must be a directory: {}",
1725            src.display().to_string()
1726        );
1727    }
1728
1729    std::fs::create_dir_all(dest).with_context(|| {
1730        format!(
1731            "failed to create destination {}",
1732            dest.display().to_string()
1733        )
1734    })?;
1735    for entry in std::fs::read_dir(src)? {
1736        let entry = entry?;
1737        let src_path = entry.path();
1738        let dest_path = dest.join(entry.file_name());
1739        let metadata = std::fs::symlink_metadata(&src_path).with_context(|| {
1740            format!(
1741                "failed to read metadata for {}",
1742                src_path.display().to_string()
1743            )
1744        })?;
1745
1746        if metadata.file_type().is_symlink() {
1747            anyhow::bail!(
1748                "Refusing to copy symlink within skill source: {}",
1749                src_path.display()
1750            );
1751        }
1752
1753        if metadata.is_dir() {
1754            copy_dir_recursive_secure(&src_path, &dest_path)?;
1755        } else if metadata.is_file() {
1756            std::fs::copy(&src_path, &dest_path).with_context(|| {
1757                format!(
1758                    "failed to copy skill file from {} to {}",
1759                    src_path.display().to_string(),
1760                    dest_path.display()
1761                )
1762            })?;
1763        }
1764    }
1765
1766    Ok(())
1767}
1768
1769pub fn install_local_skill_source(
1770    source: &str,
1771    skills_path: &Path,
1772    allow_scripts: bool,
1773) -> Result<(PathBuf, usize)> {
1774    let source_path = PathBuf::from(source);
1775    if !source_path.exists() {
1776        anyhow::bail!("Source path does not exist: {source}");
1777    }
1778
1779    let source_path = source_path
1780        .canonicalize()
1781        .with_context(|| format!("failed to canonicalize source path {source}"))?;
1782    let _ = enforce_skill_security_audit(&source_path, allow_scripts)?;
1783
1784    let name = source_path
1785        .file_name()
1786        .context("Source path must include a directory name")?;
1787    let dest = skills_path.join(name);
1788    if dest.exists() {
1789        anyhow::bail!(
1790            "Destination skill already exists: {}",
1791            dest.display().to_string()
1792        );
1793    }
1794
1795    if let Err(err) = copy_dir_recursive_secure(&source_path, &dest) {
1796        let _ = std::fs::remove_dir_all(&dest);
1797        return Err(err);
1798    }
1799
1800    match enforce_skill_security_audit(&dest, allow_scripts) {
1801        Ok(report) => Ok((dest, report.files_scanned)),
1802        Err(err) => {
1803            let _ = std::fs::remove_dir_all(&dest);
1804            Err(err)
1805        }
1806    }
1807}
1808
1809pub fn install_git_skill_source(
1810    source: &str,
1811    skills_path: &Path,
1812    allow_scripts: bool,
1813) -> Result<(PathBuf, usize)> {
1814    let before = snapshot_skill_children(skills_path)?;
1815    let output = std::process::Command::new("git")
1816        .args(["clone", "--depth", "1", source])
1817        .current_dir(skills_path)
1818        .output()?;
1819    if !output.status.success() {
1820        let stderr = String::from_utf8_lossy(&output.stderr);
1821        anyhow::bail!("Git clone failed: {stderr}");
1822    }
1823
1824    let installed_dir = detect_newly_installed_directory(skills_path, &before)?;
1825    remove_git_metadata(&installed_dir)?;
1826    match enforce_skill_security_audit(&installed_dir, allow_scripts) {
1827        Ok(report) => Ok((installed_dir, report.files_scanned)),
1828        Err(err) => {
1829            let _ = std::fs::remove_dir_all(&installed_dir);
1830            Err(err)
1831        }
1832    }
1833}
1834
1835pub async fn install_clawhub_skill_source(
1836    source: &str,
1837    skills_path: &Path,
1838    allow_scripts: bool,
1839) -> Result<(PathBuf, usize)> {
1840    let download_url = clawhub_download_url(source)
1841        .with_context(|| format!("invalid ClawhHub source: {source}"))?;
1842    let skill_dir_name = clawhub_skill_dir_name(source)?;
1843    let installed_dir = skills_path.join(&skill_dir_name);
1844    if installed_dir.exists() {
1845        anyhow::bail!(
1846            "Destination skill already exists: {}",
1847            installed_dir.display()
1848        );
1849    }
1850
1851    let client = reqwest::Client::builder()
1852        .timeout(Duration::from_secs(30))
1853        .build()?;
1854
1855    let resp = client
1856        .get(&download_url)
1857        .send()
1858        .await
1859        .with_context(|| format!("failed to fetch zip from {download_url}"))?;
1860
1861    if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
1862        anyhow::bail!("ClawhHub rate limit reached (HTTP 429). Wait a moment and retry.");
1863    }
1864    if !resp.status().is_success() {
1865        anyhow::bail!("ClawhHub download failed (HTTP {})", resp.status());
1866    }
1867
1868    let bytes = resp.bytes().await?.to_vec();
1869    if bytes.len() as u64 > MAX_CLAWHUB_ZIP_BYTES {
1870        anyhow::bail!(
1871            "ClawhHub zip rejected: too large ({} bytes > {})",
1872            bytes.len(),
1873            MAX_CLAWHUB_ZIP_BYTES
1874        );
1875    }
1876
1877    std::fs::create_dir_all(&installed_dir)?;
1878
1879    let cursor = Cursor::new(bytes);
1880    let mut archive = ZipArchive::new(cursor).context("downloaded content is not a valid zip")?;
1881
1882    for i in 0..archive.len() {
1883        let mut entry = archive.by_index(i)?;
1884        let raw_name = entry.name().to_string();
1885
1886        if raw_name.is_empty()
1887            || raw_name.contains("..")
1888            || raw_name.starts_with('/')
1889            || raw_name.contains('\\')
1890            || raw_name.contains(':')
1891        {
1892            let _ = std::fs::remove_dir_all(&installed_dir);
1893            anyhow::bail!("zip entry contains unsafe path: {raw_name}");
1894        }
1895
1896        let out_path = installed_dir.join(&raw_name);
1897        if entry.is_dir() {
1898            std::fs::create_dir_all(&out_path)?;
1899            continue;
1900        }
1901
1902        if let Some(parent) = out_path.parent() {
1903            std::fs::create_dir_all(parent)?;
1904        }
1905
1906        let mut out_file = std::fs::File::create(&out_path).with_context(|| {
1907            format!(
1908                "failed to create extracted file: {}",
1909                out_path.display().to_string()
1910            )
1911        })?;
1912        std::io::copy(&mut entry, &mut out_file)?;
1913    }
1914
1915    let has_manifest = installed_dir.join("SKILL.md").exists()
1916        || installed_dir.join("SKILL.toml").exists()
1917        || installed_dir.join("manifest.toml").exists();
1918    if !has_manifest {
1919        std::fs::write(
1920            installed_dir.join("SKILL.toml"),
1921            format!(
1922                "[skill]\nname = \"{}\"\ndescription = \"ClawhHub installed skill\"\nversion = \"0.1.0\"\n",
1923                skill_dir_name
1924            ),
1925        )?;
1926    }
1927
1928    match enforce_skill_security_audit(&installed_dir, allow_scripts) {
1929        Ok(report) => Ok((installed_dir, report.files_scanned)),
1930        Err(err) => {
1931            let _ = std::fs::remove_dir_all(&installed_dir);
1932            Err(err)
1933        }
1934    }
1935}
1936
1937// ─── Skills registry resolution ───────────────────────────────────────────────
1938
1939pub fn is_registry_source(source: &str) -> bool {
1940    if source.is_empty() {
1941        return false;
1942    }
1943    if source.contains('/') || source.contains('\\') || source.contains("..") {
1944        return false;
1945    }
1946    if source.contains("://") || source.contains(':') {
1947        return false;
1948    }
1949    if source.starts_with('.') || source.starts_with('~') {
1950        return false;
1951    }
1952    source
1953        .bytes()
1954        .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
1955}
1956
1957fn clone_skills_registry(registry_dir: &Path, repo_url: &str) -> Result<()> {
1958    if let Some(parent) = registry_dir.parent() {
1959        std::fs::create_dir_all(parent).with_context(|| {
1960            format!(
1961                "failed to create registry parent: {}",
1962                parent.display().to_string()
1963            )
1964        })?;
1965    }
1966
1967    let output = Command::new("git")
1968        .args(["clone", "--depth", "1", repo_url])
1969        .arg(registry_dir)
1970        .output()
1971        .context("failed to run git clone for skills registry")?;
1972
1973    if !output.status.success() {
1974        let stderr = String::from_utf8_lossy(&output.stderr);
1975        anyhow::bail!("failed to clone skills registry: {stderr}");
1976    }
1977
1978    ::zeroclaw_log::record!(
1979        INFO,
1980        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1981        &format!(
1982            "cloned skills registry to {}",
1983            registry_dir.display().to_string()
1984        )
1985    );
1986    mark_skills_registry_synced(registry_dir)?;
1987    Ok(())
1988}
1989
1990fn pull_skills_registry(registry_dir: &Path) -> bool {
1991    if !registry_dir.join(".git").exists() {
1992        return true;
1993    }
1994
1995    let output = Command::new("git")
1996        .arg("-C")
1997        .arg(registry_dir)
1998        .args(["pull", "--ff-only"])
1999        .output();
2000
2001    match output {
2002        Ok(result) if result.status.success() => true,
2003        Ok(result) => {
2004            let stderr = String::from_utf8_lossy(&result.stderr);
2005            ::zeroclaw_log::record!(
2006                WARN,
2007                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2008                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2009                    .with_attrs(::serde_json::json!({"stderr": stderr})),
2010                "failed to pull skills registry updates: "
2011            );
2012            false
2013        }
2014        Err(err) => {
2015            ::zeroclaw_log::record!(
2016                WARN,
2017                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2018                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2019                    .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
2020                "failed to run git pull for skills registry"
2021            );
2022            false
2023        }
2024    }
2025}
2026
2027fn should_sync_skills_registry(registry_dir: &Path) -> bool {
2028    let marker = registry_dir.join(SKILLS_REGISTRY_SYNC_MARKER);
2029    let Ok(metadata) = std::fs::metadata(marker) else {
2030        return true;
2031    };
2032    let Ok(modified_at) = metadata.modified() else {
2033        return true;
2034    };
2035    let Ok(age) = SystemTime::now().duration_since(modified_at) else {
2036        return true;
2037    };
2038    age >= Duration::from_secs(SKILLS_REGISTRY_SYNC_INTERVAL_SECS)
2039}
2040
2041fn mark_skills_registry_synced(registry_dir: &Path) -> Result<()> {
2042    std::fs::write(registry_dir.join(SKILLS_REGISTRY_SYNC_MARKER), b"synced")?;
2043    Ok(())
2044}
2045
2046fn ensure_skills_registry(workspace_dir: &Path, registry_url: Option<&str>) -> Result<PathBuf> {
2047    let registry_dir = workspace_dir.join(SKILLS_REGISTRY_DIR_NAME);
2048    let repo_url = registry_url.unwrap_or(SKILLS_REGISTRY_REPO_URL);
2049
2050    if !registry_dir.exists() {
2051        clone_skills_registry(&registry_dir, repo_url)?;
2052        return Ok(registry_dir);
2053    }
2054
2055    if should_sync_skills_registry(&registry_dir) {
2056        if pull_skills_registry(&registry_dir) {
2057            let _ = mark_skills_registry_synced(&registry_dir);
2058        } else {
2059            ::zeroclaw_log::record!(
2060                WARN,
2061                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2062                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
2063                &format!(
2064                    "skills registry update failed; using local copy from {}",
2065                    registry_dir.display().to_string()
2066                )
2067            );
2068        }
2069    }
2070
2071    Ok(registry_dir)
2072}
2073
2074fn list_registry_skill_names(registry_dir: &Path) -> Vec<String> {
2075    let skills_parent = registry_dir.join("skills");
2076    let Ok(entries) = std::fs::read_dir(&skills_parent) else {
2077        return vec![];
2078    };
2079    let mut names: Vec<String> = entries
2080        .filter_map(|e| e.ok())
2081        .filter(|e| e.path().is_dir())
2082        .filter_map(|e| e.file_name().into_string().ok())
2083        .collect();
2084    names.sort();
2085    names
2086}
2087
2088pub fn install_registry_skill_source(
2089    source: &str,
2090    skills_path: &Path,
2091    allow_scripts: bool,
2092    workspace_dir: &Path,
2093    registry_url: Option<&str>,
2094    suppress_tier_banner: bool,
2095) -> Result<(PathBuf, usize)> {
2096    let registry_dir = ensure_skills_registry(workspace_dir, registry_url)?;
2097    let skill_dir = registry_dir.join("skills").join(source);
2098
2099    if !skill_dir.is_dir() {
2100        let available = list_registry_skill_names(&registry_dir);
2101        if available.is_empty() {
2102            anyhow::bail!("skill '{source}' not found in the registry and no skills are available");
2103        }
2104        anyhow::bail!(
2105            "skill '{source}' not found in the registry.\nAvailable skills: {}",
2106            available.join(", ")
2107        );
2108    }
2109
2110    if !suppress_tier_banner {
2111        let (tier, version) = lookup_registry_skill_tier(&registry_dir, source);
2112        print_install_tier_banner(source, version.as_deref(), tier);
2113    }
2114
2115    install_local_skill_source(
2116        skill_dir.to_str().with_context(|| {
2117            format!(
2118                "registry path is not valid UTF-8: {}",
2119                skill_dir.display().to_string()
2120            )
2121        })?,
2122        skills_path,
2123        allow_scripts,
2124    )
2125}
2126
2127// ─── Plugin-shipped skills (plugins-wasm only) ───────────────────────────────
2128
2129/// Load skills from skill-capable plugins discovered by the plugin host.
2130///
2131/// Each plugin's `skills/` directory is fed to the existing skill loader, and
2132/// every loaded skill is renamed to `plugin:<plugin>/<skill>` to avoid
2133/// collisions with user-authored skills and between bundles. The `plugin:<name>`
2134/// tag is also added so prompts can distinguish plugin skills.
2135#[cfg(feature = "plugins-wasm")]
2136pub fn load_plugin_skills_from_config(config: &zeroclaw_config::schema::Config) -> Vec<Skill> {
2137    if !config.plugins.enabled {
2138        return Vec::new();
2139    }
2140
2141    let plugins_dir = expand_plugins_dir(&config.plugins.plugins_dir);
2142    let parent = match plugins_dir.parent() {
2143        Some(p) => p.to_path_buf(),
2144        None => return Vec::new(),
2145    };
2146
2147    let signature_mode = zeroclaw_plugins::host::PluginHost::parse_signature_mode(
2148        &config.plugins.security.signature_mode,
2149    );
2150    let trusted_keys = config.plugins.security.trusted_publisher_keys.clone();
2151
2152    let host = match zeroclaw_plugins::host::PluginHost::with_security(
2153        &parent,
2154        signature_mode,
2155        trusted_keys,
2156    ) {
2157        Ok(host) => host,
2158        Err(err) => {
2159            ::zeroclaw_log::record!(
2160                WARN,
2161                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2162                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2163                    .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
2164                "failed to discover plugin skills"
2165            );
2166            return Vec::new();
2167        }
2168    };
2169
2170    let allow_scripts = config.skills.allow_scripts;
2171    let mut skills = Vec::new();
2172    for (manifest, skills_dir) in host.skill_plugin_details() {
2173        for raw in load_skills_from_directory(&skills_dir, allow_scripts) {
2174            skills.push(namespace_plugin_skill(&manifest.name, raw));
2175        }
2176    }
2177    skills
2178}
2179
2180#[cfg(feature = "plugins-wasm")]
2181fn expand_plugins_dir(plugins_dir: &str) -> PathBuf {
2182    if let Some(rest) = plugins_dir.strip_prefix("~/")
2183        && let Some(dirs) = UserDirs::new()
2184    {
2185        return dirs.home_dir().join(rest);
2186    }
2187    PathBuf::from(plugins_dir)
2188}
2189
2190#[cfg(feature = "plugins-wasm")]
2191fn namespace_plugin_skill(plugin_name: &str, mut skill: Skill) -> Skill {
2192    let qualified = format!("plugin:{}/{}", plugin_name, skill.name);
2193    skill.name = qualified;
2194    let plugin_tag = format!("plugin:{plugin_name}");
2195    if !skill.tags.iter().any(|t| t == &plugin_tag) {
2196        skill.tags.push(plugin_tag);
2197    }
2198    skill
2199}
2200
2201#[cfg(test)]
2202mod registry_tests {
2203    use super::*;
2204
2205    #[test]
2206    fn test_is_registry_source_accepts_bare_names() {
2207        assert!(is_registry_source("auto-coder"));
2208        assert!(is_registry_source("web-researcher"));
2209        assert!(is_registry_source("telegram-assistant"));
2210        assert!(is_registry_source("data_analyst"));
2211        assert!(is_registry_source("ci-helper"));
2212        assert!(is_registry_source("selfimproving"));
2213    }
2214
2215    #[test]
2216    fn test_is_registry_source_rejects_empty() {
2217        assert!(!is_registry_source(""));
2218    }
2219
2220    #[test]
2221    fn test_is_registry_source_rejects_paths() {
2222        assert!(!is_registry_source("./my-skill"));
2223        assert!(!is_registry_source("../my-skill"));
2224        assert!(!is_registry_source("/abs/path"));
2225        assert!(!is_registry_source("skills/auto-coder"));
2226        assert!(!is_registry_source("some\\path"));
2227        assert!(!is_registry_source("~/.zeroclaw/skills/foo"));
2228    }
2229
2230    #[test]
2231    fn test_is_registry_source_rejects_urls() {
2232        assert!(!is_registry_source("https://github.com/foo/bar"));
2233        assert!(!is_registry_source("http://example.com"));
2234        assert!(!is_registry_source("ssh://git@host/repo"));
2235        assert!(!is_registry_source("git://host/repo"));
2236        assert!(!is_registry_source("git@github.com:user/repo"));
2237    }
2238
2239    #[test]
2240    fn test_is_registry_source_rejects_clawhub() {
2241        assert!(!is_registry_source("clawhub:my-skill"));
2242    }
2243
2244    #[test]
2245    fn test_is_registry_source_rejects_traversal() {
2246        assert!(!is_registry_source(".."));
2247        assert!(!is_registry_source("foo..bar"));
2248    }
2249
2250    #[test]
2251    fn test_is_registry_source_rejects_special_chars() {
2252        assert!(!is_registry_source(".hidden"));
2253        assert!(!is_registry_source("~tilde"));
2254    }
2255
2256    #[test]
2257    fn tier_from_tags_recognizes_official() {
2258        assert_eq!(
2259            tier_from_tags(&["Official".into(), "Featured".into()]),
2260            SkillTier::Official
2261        );
2262        // Case-insensitive match.
2263        assert_eq!(tier_from_tags(&["official".into()]), SkillTier::Official);
2264    }
2265
2266    #[test]
2267    fn tier_from_tags_recognizes_community() {
2268        assert_eq!(tier_from_tags(&["Community".into()]), SkillTier::Community);
2269    }
2270
2271    #[test]
2272    fn tier_from_tags_recognizes_featured_only() {
2273        assert_eq!(tier_from_tags(&["Featured".into()]), SkillTier::Featured);
2274    }
2275
2276    #[test]
2277    fn tier_from_tags_falls_back_to_unknown_when_no_tier_tag() {
2278        assert_eq!(tier_from_tags(&[]), SkillTier::Unknown);
2279        assert_eq!(
2280            tier_from_tags(&["productivity".into(), "automation".into()]),
2281            SkillTier::Unknown
2282        );
2283    }
2284
2285    #[test]
2286    fn build_install_tier_banner_official_is_single_line() {
2287        let banner = build_install_tier_banner("auto-coder", Some("0.3.0"), SkillTier::Official);
2288        assert!(banner.contains("Official (zeroclaw-labs maintained)"));
2289        assert!(banner.contains("Installing auto-coder v0.3.0"));
2290        assert!(!banner.contains("not audited"));
2291        // One trailing newline, no warn block.
2292        assert_eq!(banner.lines().count(), 1);
2293    }
2294
2295    #[test]
2296    fn build_install_tier_banner_community_warns() {
2297        let banner =
2298            build_install_tier_banner("discord-moderator", Some("0.1.2"), SkillTier::Community);
2299        assert!(banner.contains("Community submission"));
2300        assert!(banner.contains("not audited by ZeroClaw"));
2301        assert!(banner.contains("zeroclaw skills audit discord-moderator"));
2302    }
2303
2304    #[test]
2305    fn build_install_tier_banner_featured_uses_community_warning() {
2306        let banner = build_install_tier_banner("hand-picked", Some("1.0"), SkillTier::Featured);
2307        assert!(banner.contains("Community submission"));
2308        assert!(banner.contains("not audited by ZeroClaw"));
2309    }
2310
2311    #[test]
2312    fn build_install_tier_banner_unknown_falls_back_to_community() {
2313        let banner = build_install_tier_banner("legacy", None, SkillTier::Unknown);
2314        assert!(banner.contains("Community submission"));
2315        assert!(banner.contains("not audited by ZeroClaw"));
2316        // Missing version is rendered as `v?` rather than panicking.
2317        assert!(banner.contains("v?"));
2318    }
2319
2320    #[test]
2321    fn lookup_registry_skill_tier_resolves_from_registry_json() {
2322        let tmp = tempfile::TempDir::new().unwrap();
2323        let json = r#"{
2324            "version": 1,
2325            "skills": [
2326                { "name": "auto-coder", "version": "0.3.0", "tags": ["Official", "Featured"] },
2327                { "name": "discord-moderator", "version": "0.1.2", "tags": ["Community"] },
2328                { "name": "hand-picked", "version": "1.0.0", "tags": ["Featured"] },
2329                { "name": "untagged", "version": "0.0.1", "tags": ["productivity"] }
2330            ]
2331        }"#;
2332        std::fs::write(tmp.path().join("registry.json"), json).unwrap();
2333
2334        assert_eq!(
2335            lookup_registry_skill_tier(tmp.path(), "auto-coder"),
2336            (SkillTier::Official, Some("0.3.0".to_string()))
2337        );
2338        assert_eq!(
2339            lookup_registry_skill_tier(tmp.path(), "discord-moderator"),
2340            (SkillTier::Community, Some("0.1.2".to_string()))
2341        );
2342        assert_eq!(
2343            lookup_registry_skill_tier(tmp.path(), "hand-picked"),
2344            (SkillTier::Featured, Some("1.0.0".to_string()))
2345        );
2346        // Skill present but no tier tag → Unknown (treated as Community by the banner).
2347        assert_eq!(
2348            lookup_registry_skill_tier(tmp.path(), "untagged"),
2349            (SkillTier::Unknown, Some("0.0.1".to_string()))
2350        );
2351        // Skill not in registry.json at all → Unknown with no version.
2352        assert_eq!(
2353            lookup_registry_skill_tier(tmp.path(), "missing"),
2354            (SkillTier::Unknown, None)
2355        );
2356    }
2357
2358    #[test]
2359    fn lookup_registry_skill_tier_handles_missing_index() {
2360        let tmp = tempfile::TempDir::new().unwrap();
2361        assert_eq!(
2362            lookup_registry_skill_tier(tmp.path(), "anything"),
2363            (SkillTier::Unknown, None)
2364        );
2365    }
2366
2367    #[test]
2368    fn lookup_registry_skill_tier_handles_malformed_json() {
2369        let tmp = tempfile::TempDir::new().unwrap();
2370        std::fs::write(tmp.path().join("registry.json"), "{ not json").unwrap();
2371        assert_eq!(
2372            lookup_registry_skill_tier(tmp.path(), "anything"),
2373            (SkillTier::Unknown, None)
2374        );
2375    }
2376}
2377
2378#[cfg(test)]
2379mod prompts_section_tests {
2380    use super::*;
2381    use tempfile::TempDir;
2382
2383    fn write_manifest(dir: &Path, toml: &str) -> std::path::PathBuf {
2384        let p = dir.join("SKILL.toml");
2385        std::fs::write(&p, toml).unwrap();
2386        p
2387    }
2388
2389    #[test]
2390    fn prompts_inside_skill_section_are_loaded() {
2391        let tmp = TempDir::new().unwrap();
2392        let path = write_manifest(
2393            tmp.path(),
2394            r#"
2395[skill]
2396name = "probe"
2397description = "test"
2398version = "0.1.0"
2399prompts = ["If asked about XYZZY, respond YES"]
2400"#,
2401        );
2402        let skill = load_skill_toml(&path).unwrap();
2403        assert_eq!(
2404            skill.prompts,
2405            vec!["If asked about XYZZY, respond YES".to_string()]
2406        );
2407    }
2408
2409    #[test]
2410    fn prompts_at_root_level_still_work() {
2411        let tmp = TempDir::new().unwrap();
2412        let path = write_manifest(
2413            tmp.path(),
2414            r#"
2415[skill]
2416name = "probe"
2417description = "test"
2418version = "0.1.0"
2419
2420prompts = ["legacy root-level prompt"]
2421"#,
2422        );
2423        let skill = load_skill_toml(&path).unwrap();
2424        assert_eq!(skill.prompts, vec!["legacy root-level prompt".to_string()]);
2425    }
2426
2427    #[test]
2428    fn prompts_in_both_locations_are_merged_skill_first() {
2429        // Root-level prompts must precede the [skill] header in TOML.
2430        // Per the fix, [skill]-section prompts appear first in the merged
2431        // list, with root-level prompts appended after.
2432        let tmp = TempDir::new().unwrap();
2433        let path = write_manifest(
2434            tmp.path(),
2435            r#"
2436prompts = ["from-root"]
2437
2438[skill]
2439name = "probe"
2440description = "test"
2441version = "0.1.0"
2442prompts = ["from-skill-section"]
2443"#,
2444        );
2445        let skill = load_skill_toml(&path).unwrap();
2446        assert_eq!(
2447            skill.prompts,
2448            vec!["from-skill-section".to_string(), "from-root".to_string(),]
2449        );
2450    }
2451}
2452
2453#[cfg(test)]
2454mod skill_manifest_tests {
2455    use super::*;
2456
2457    #[test]
2458    fn parses_valid_skill_manifest() {
2459        let toml_str = r#"
2460[skill]
2461name = "x"
2462description = "y"
2463"#;
2464        let manifest: SkillManifest =
2465            toml::from_str(toml_str).expect("valid manifest should parse");
2466        assert_eq!(manifest.skill.name, "x");
2467        assert_eq!(manifest.skill.description, "y");
2468        assert_eq!(manifest.skill.version, "0.1.0");
2469        assert!(manifest.tools.is_empty());
2470        assert!(manifest.prompts.is_empty());
2471    }
2472
2473    #[test]
2474    fn rejects_unknown_field_in_skill_block() {
2475        let toml_str = r#"
2476[skill]
2477name = "x"
2478description = "y"
2479descriptin = "oops"
2480"#;
2481        let err = toml::from_str::<SkillManifest>(toml_str)
2482            .expect_err("unknown field in [skill] should be rejected");
2483        let msg = err.to_string();
2484        assert!(
2485            msg.contains("descriptin"),
2486            "error should mention the unknown field 'descriptin'; got: {msg}"
2487        );
2488    }
2489
2490    /// Positive control covering the new field × strictness intersection:
2491    /// after the rebase onto master (which added `prompts: Vec<String>`
2492    /// to `SkillMeta` per #5972), the field must continue to parse cleanly
2493    /// under `#[serde(deny_unknown_fields)]`.
2494    #[test]
2495    fn accepts_prompts_in_skill_block_with_strictness() {
2496        let toml_str = r#"
2497[skill]
2498name = "x"
2499description = "y"
2500prompts = ["one", "two"]
2501"#;
2502        let manifest: SkillManifest = toml::from_str(toml_str)
2503            .expect("manifest with prompts in [skill] should parse under deny_unknown_fields");
2504        assert_eq!(
2505            manifest.skill.prompts,
2506            vec!["one".to_string(), "two".to_string()]
2507        );
2508    }
2509
2510    /// Hand-authored skills that don't carry SkillForge provenance must parse
2511    /// without error — `forge` is `Option<ForgeMetadata>` with `default`.
2512    #[test]
2513    fn parses_skill_without_forge_block() {
2514        let toml_str = r#"
2515[skill]
2516name = "hand-authored"
2517description = "no forge block"
2518"#;
2519        let manifest: SkillManifest =
2520            toml::from_str(toml_str).expect("manifest without [forge] should parse cleanly");
2521        assert!(
2522            manifest.forge.is_none(),
2523            "forge should be None when [forge] is absent"
2524        );
2525        assert_eq!(manifest.skill.name, "hand-authored");
2526    }
2527
2528    /// Happy path: a SkillForge-emitted manifest with a fully populated
2529    /// `[forge]` table, including the nested `[forge.requirements]` and
2530    /// `[forge.metadata]` sub-tables.
2531    #[test]
2532    fn parses_skill_with_forge_block() {
2533        let toml_str = r#"
2534[skill]
2535name = "auto-integrated"
2536description = "from skillforge"
2537
2538[forge]
2539source = "https://github.com/user/auto-integrated"
2540owner = "user"
2541language = "Rust"
2542license = true
2543stars = 42
2544updated_at = "2026-04-30"
2545
2546[forge.requirements]
2547runtime = "zeroclaw >= 0.1"
2548
2549[forge.metadata]
2550auto_integrated = true
2551forge_timestamp = "2026-04-30T12:00:00Z"
2552"#;
2553        let manifest: SkillManifest =
2554            toml::from_str(toml_str).expect("manifest with [forge] block should parse cleanly");
2555        let forge = manifest
2556            .forge
2557            .expect("forge should be Some when [forge] is present");
2558        assert_eq!(
2559            forge.source.as_deref(),
2560            Some("https://github.com/user/auto-integrated")
2561        );
2562        assert_eq!(forge.owner.as_deref(), Some("user"));
2563        assert_eq!(forge.language.as_deref(), Some("Rust"));
2564        assert_eq!(forge.license, Some(true));
2565        assert_eq!(forge.stars, Some(42));
2566        assert_eq!(forge.updated_at.as_deref(), Some("2026-04-30"));
2567        assert_eq!(
2568            forge.requirements.get("runtime").and_then(|v| v.as_str()),
2569            Some("zeroclaw >= 0.1"),
2570        );
2571        assert_eq!(
2572            forge
2573                .metadata
2574                .get("auto_integrated")
2575                .and_then(|v| v.as_bool()),
2576            Some(true),
2577        );
2578    }
2579
2580    /// `ForgeMetadata` carries `#[serde(deny_unknown_fields)]` — a typo at
2581    /// the `[forge]` level (e.g. `licence` next to `license`) must surface
2582    /// loudly the same way a typo in `[skill]` does.
2583    #[test]
2584    fn rejects_unknown_field_in_forge_block() {
2585        let toml_str = r#"
2586[skill]
2587name = "x"
2588description = "y"
2589
2590[forge]
2591source = "https://github.com/user/x"
2592licence = true
2593"#;
2594        let err = toml::from_str::<SkillManifest>(toml_str)
2595            .expect_err("unknown field in [forge] should be rejected");
2596        let msg = err.to_string();
2597        assert!(
2598            msg.contains("licence"),
2599            "error should mention the unknown field 'licence'; got: {msg}"
2600        );
2601    }
2602
2603    /// Round-trip guard: the SkillForge integrator must emit `[forge]` keys
2604    /// at the top level (sibling to `[skill]`), not inside `[skill]`. If a
2605    /// future refactor moves these back, this test fails because the parsed
2606    /// manifest's `forge` field would be `None` (and `SkillMeta` would
2607    /// reject the unknown keys via `deny_unknown_fields`).
2608    #[test]
2609    fn integrate_round_trip_emits_top_level_forge() {
2610        use crate::skillforge::scout::{ScoutResult, ScoutSource};
2611        use chrono::Utc;
2612        let candidate = ScoutResult {
2613            name: "round-trip".into(),
2614            url: "https://github.com/user/round-trip".into(),
2615            description: "round-trip test".into(),
2616            stars: 7,
2617            language: Some("Rust".into()),
2618            updated_at: Some(Utc::now()),
2619            source: ScoutSource::GitHub,
2620            owner: "user".into(),
2621            has_license: true,
2622        };
2623
2624        // Generate the TOML the integrator would write and parse it back.
2625        let tmp = tempfile::TempDir::new().unwrap();
2626        let integrator = crate::skillforge::integrate::Integrator::new(
2627            tmp.path().to_string_lossy().into_owned(),
2628        );
2629        let skill_dir = integrator.integrate(&candidate).unwrap();
2630        let toml_str = std::fs::read_to_string(skill_dir.join("SKILL.toml")).unwrap();
2631
2632        let manifest: SkillManifest = toml::from_str(&toml_str).unwrap_or_else(|e| {
2633            panic!(
2634                "integrator output must parse against SkillManifest with strict SkillMeta + ForgeMetadata; \
2635                 got error: {e}\n--- toml ---\n{toml_str}"
2636            )
2637        });
2638        let forge = manifest
2639            .forge
2640            .expect("integrator must emit a [forge] table");
2641        assert_eq!(forge.owner.as_deref(), Some("user"));
2642        assert_eq!(forge.stars, Some(7));
2643        assert_eq!(forge.license, Some(true));
2644        assert!(
2645            forge
2646                .source
2647                .as_deref()
2648                .is_some_and(|s| s.contains("round-trip")),
2649            "forge.source should carry the upstream URL"
2650        );
2651        // Crucial guard: none of the provenance keys leaked into [skill].
2652        // A failure here means generate_toml regressed and is putting forge
2653        // keys back inside `[skill]` — `deny_unknown_fields` on `SkillMeta`
2654        // would have caught that already as a parse error, but assert
2655        // explicitly so the failure is unambiguous in CI output.
2656        assert_eq!(manifest.skill.name, "round-trip");
2657        assert_eq!(manifest.skill.description, "round-trip test");
2658    }
2659
2660    /// Behavioral assertion for the swallow-site fix: a SKILL.toml whose
2661    /// `[skill]` block has a typo causes `load_skill_toml` to return `Err`,
2662    /// and `load_skills_from_directory` skips it without panicking and
2663    /// without including it in the loaded set. The accompanying
2664    /// `tracing::warn!` call (with structured `path` and `err` fields) is
2665    /// verified by source inspection — the codebase does not currently
2666    /// pull in a `tracing-subscriber` test harness, and adding one purely
2667    /// for this assertion would violate the AGENTS.md anti-pattern of
2668    /// adding dependencies for minor convenience.
2669    #[test]
2670    fn workspace_swallow_site_skips_invalid_toml_without_panicking() {
2671        use tempfile::TempDir;
2672        let tmp = TempDir::new().unwrap();
2673        let skills_dir = tmp.path().join("skills");
2674        std::fs::create_dir_all(&skills_dir).unwrap();
2675
2676        // Bad skill: typo in [skill] — rejected by deny_unknown_fields.
2677        let bad_dir = skills_dir.join("bad-skill");
2678        std::fs::create_dir_all(&bad_dir).unwrap();
2679        std::fs::write(
2680            bad_dir.join("SKILL.toml"),
2681            r#"
2682[skill]
2683name = "bad"
2684description = "has a typo"
2685descriptin = "oops"
2686"#,
2687        )
2688        .unwrap();
2689
2690        // Good skill: parses cleanly — must still load.
2691        let good_dir = skills_dir.join("good-skill");
2692        std::fs::create_dir_all(&good_dir).unwrap();
2693        std::fs::write(
2694            good_dir.join("SKILL.toml"),
2695            r#"
2696[skill]
2697name = "good"
2698description = "fine"
2699"#,
2700        )
2701        .unwrap();
2702
2703        let skills = load_skills_from_directory(&skills_dir, false);
2704        // The bad skill is skipped (not panicked-on). The good skill loads.
2705        let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
2706        assert!(
2707            names.contains(&"good"),
2708            "good skill must load; got: {names:?}"
2709        );
2710        assert!(
2711            !names.contains(&"bad"),
2712            "bad skill must be skipped, not silently accepted; got: {names:?}"
2713        );
2714    }
2715
2716    /// Behavioral assertion for the open-skills swallow-site fix.
2717    /// Same shape as the workspace test above; covers `load_open_skills_from_directory`.
2718    #[test]
2719    fn open_skills_swallow_site_skips_invalid_toml_without_panicking() {
2720        use tempfile::TempDir;
2721        let tmp = TempDir::new().unwrap();
2722        let skills_dir = tmp.path().join("open-skills");
2723        std::fs::create_dir_all(&skills_dir).unwrap();
2724
2725        let bad_dir = skills_dir.join("bad-open-skill");
2726        std::fs::create_dir_all(&bad_dir).unwrap();
2727        std::fs::write(
2728            bad_dir.join("SKILL.toml"),
2729            r#"
2730[skill]
2731name = "bad-open"
2732description = "has a typo"
2733autor = "oops"
2734"#,
2735        )
2736        .unwrap();
2737
2738        let good_dir = skills_dir.join("good-open-skill");
2739        std::fs::create_dir_all(&good_dir).unwrap();
2740        std::fs::write(
2741            good_dir.join("SKILL.toml"),
2742            r#"
2743[skill]
2744name = "good-open"
2745description = "fine"
2746"#,
2747        )
2748        .unwrap();
2749
2750        let skills = load_open_skills_from_directory(&skills_dir, false);
2751        let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
2752        assert!(
2753            names.contains(&"good-open"),
2754            "good open-skill must load; got: {names:?}"
2755        );
2756        assert!(
2757            !names.contains(&"bad-open"),
2758            "bad open-skill must be skipped, not silently accepted; got: {names:?}"
2759        );
2760    }
2761}