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