Skip to main content

supercode_harness/
skills.rs

1//! ORCH-11 (observed tier): read-only enumeration of the skill packages each
2//! harness has installed.
3//!
4//! supercode never installs, removes, or edits a skill here — it opens the
5//! directories the harness's own loader opens and reports what is there. The
6//! roots below are transcribed from each harness's documented/primary source:
7//!
8//! * Claude Code — enterprise (managed) > personal `~/.claude/skills/` >
9//!   project `.claude/skills/`, nested `.claude/skills/` in subdirectories,
10//!   plugin skills namespaced `plugin:skill`
11//!   (`docs/composable-harness/inventory/claude-code.md` "Skill locations &
12//!   precedence"; `docs:skills#where-skills-live`).
13//! * Codex — repo `.agents/skills` from cwd to the repo root, user
14//!   `~/.agents/skills` (plus the deprecated `$CODEX_HOME/skills`), admin
15//!   `/etc/codex/skills`, and the bundled cache `$CODEX_HOME/skills/.system`
16//!   (`inventory/codex.md` §7 Skills; `codex-rs/core-skills/src/loader.rs`).
17//!   `[skills]` in `$CODEX_HOME/config.toml` is an enable/disable overlay
18//!   (`SkillConfig { path, name, enabled }`), not an extra root, so it is
19//!   read for `enabled` only (`codex-rs/config/src/skills_config.rs:12-36`
20//!   at the pinned commit `1f0566d3`).
21//! * opencode — `{skill,skills}/**/SKILL.md` under every `.opencode` dir plus
22//!   the global config dir (`inventory/opencode.md` §7 Skills,
23//!   `packages/opencode/src/skill/index.ts:23-25`).
24//! * pi — `~/.pi/agent/skills/`, `~/.agents/skills/`, project `.pi/skills/`
25//!   and `.agents/skills/` in cwd and its ancestors (`inventory/pi.md` §2
26//!   Skills, `src:core/skills.ts`).
27//! * Hermes 0.21.0 — `HERMES_HOME/skills` (`get_skills_dir()` =
28//!   `get_hermes_home() / "skills"`, `hermes_constants.py:1195-1197`), and
29//!   because profile mode sets `HERMES_HOME` to `<root>/profiles/<name>`
30//!   (`hermes_constants.py:160-190`), `<root>/profiles/<name>/skills` too.
31//!   Hermes groups skills by category, so a root is walked, not listed.
32//! * OpenClaw 2026.7.1-2 — managed `<config>/skills`, plugin
33//!   `<config>/plugin-skills`, workspace `<workspace>/skills` and
34//!   `<workspace>/.agents/skills`, personal `~/.agents/skills`
35//!   (`src/skills/loading/workspace.ts:1155-1215` at tag `v2026.7.1-2`).
36//!
37//! `enabled` is `None` wherever the harness's own source does not say; only
38//! Codex's `[skills]` overlay and a skill's own frontmatter produce a bool.
39
40use std::collections::BTreeSet;
41use std::path::{Path, PathBuf};
42
43use serde::{Deserialize, Serialize};
44
45use crate::HarnessId;
46
47/// Where a skill package was found, in the vocabulary shared by all six
48/// harnesses.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum SkillScope {
52    /// Enterprise / admin / harness-managed directory.
53    Managed,
54    /// The user's own config home.
55    User,
56    /// A directory under the working tree.
57    Project,
58    /// Contributed by an installed plugin bundle.
59    Plugin,
60    /// Shipped with the harness itself.
61    Bundled,
62}
63
64impl SkillScope {
65    /// Stable wire spelling, also accepted by `--scope`.
66    pub const fn as_str(self) -> &'static str {
67        match self {
68            Self::Managed => "managed",
69            Self::User => "user",
70            Self::Project => "project",
71            Self::Plugin => "plugin",
72            Self::Bundled => "bundled",
73        }
74    }
75
76    /// Parse one wire spelling.
77    pub fn parse(value: &str) -> Option<Self> {
78        match value {
79            "managed" => Some(Self::Managed),
80            "user" => Some(Self::User),
81            "project" => Some(Self::Project),
82            "plugin" => Some(Self::Plugin),
83            "bundled" => Some(Self::Bundled),
84            _ => None,
85        }
86    }
87}
88
89/// One installed skill package, as one harness holds it.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct SkillRow {
92    /// Frontmatter `name` when present, else the directory name.
93    pub name: String,
94    /// Which harness's root this was read from.
95    pub harness: HarnessId,
96    /// Precedence class of the root.
97    pub scope: SkillScope,
98    /// Absolute path of the skill's own directory.
99    pub location: PathBuf,
100    /// Frontmatter `description`, trimmed to one line.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub description: Option<String>,
103    /// Frontmatter `version`.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub version: Option<String>,
106    /// `None` when the harness's own source does not express enablement.
107    pub enabled: Option<bool>,
108}
109
110/// Config homes the skill roots hang off. Defaults follow each harness's own
111/// environment contract; a caller may override any of them (tests, probes).
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(default)]
114pub struct SkillHomes {
115    /// `CLAUDE_CONFIG_DIR` or `~/.claude`.
116    pub claude_code: PathBuf,
117    /// `CODEX_HOME` or `~/.codex`.
118    pub codex: PathBuf,
119    /// opencode's global config dir (`$OPENCODE_CONFIG_DIR`, else
120    /// `$XDG_CONFIG_HOME/opencode`, else `~/.config/opencode`).
121    pub opencode: PathBuf,
122    /// `PI_CODING_AGENT_DIR` or `~/.pi/agent`.
123    pub pi: PathBuf,
124    /// `HERMES_HOME` or `~/.hermes`.
125    pub hermes: PathBuf,
126    /// OpenClaw's `CONFIG_DIR` (`OPENCLAW_STATE_DIR`, else
127    /// `$OPENCLAW_HOME/.openclaw`, else `~/.openclaw`).
128    pub openclaw: PathBuf,
129    /// The cross-harness Agent Skills personal root, `~/.agents`.
130    pub agents: PathBuf,
131}
132
133fn home_dir() -> PathBuf {
134    supercode_interchange::user_home()
135        .map(std::path::PathBuf::into_os_string)
136        .map(PathBuf::from)
137        .unwrap_or_else(|| PathBuf::from("."))
138}
139
140impl Default for SkillHomes {
141    fn default() -> Self {
142        let home = home_dir();
143        Self {
144            claude_code: std::env::var_os("CLAUDE_CONFIG_DIR")
145                .map(PathBuf::from)
146                .unwrap_or_else(|| home.join(".claude")),
147            codex: std::env::var_os("CODEX_HOME")
148                .map(PathBuf::from)
149                .unwrap_or_else(|| home.join(".codex")),
150            opencode: std::env::var_os("OPENCODE_CONFIG_DIR")
151                .map(PathBuf::from)
152                .unwrap_or_else(|| {
153                    std::env::var_os("XDG_CONFIG_HOME")
154                        .map(PathBuf::from)
155                        .unwrap_or_else(|| home.join(".config"))
156                        .join("opencode")
157                }),
158            pi: std::env::var_os("PI_CODING_AGENT_DIR")
159                .map(PathBuf::from)
160                .unwrap_or_else(|| home.join(".pi").join("agent")),
161            hermes: std::env::var_os("HERMES_HOME")
162                .map(PathBuf::from)
163                .unwrap_or_else(|| home.join(".hermes")),
164            openclaw: std::env::var_os("OPENCLAW_STATE_DIR")
165                .map(PathBuf::from)
166                .or_else(|| {
167                    std::env::var_os("OPENCLAW_HOME")
168                        .map(|root| PathBuf::from(root).join(".openclaw"))
169                })
170                .unwrap_or_else(|| home.join(".openclaw")),
171            agents: home.join(".agents"),
172        }
173    }
174}
175
176/// `harness.v1.skills.list` request.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
178#[serde(default)]
179pub struct SkillsQuery {
180    /// Only this harness id. `None` lists every harness.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub harness: Option<String>,
183    /// Only this precedence class.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub scope: Option<SkillScope>,
186    /// Working tree whose project roots are scanned. Defaults to the process
187    /// working directory.
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub cwd: Option<PathBuf>,
190    /// Config homes to read.
191    pub homes: SkillHomes,
192}
193
194/// Every harness that has a skills root, in product order.
195pub const SKILL_HARNESSES: &[&str] = &[
196    HarnessId::CLAUDE_CODE,
197    HarnessId::CODEX,
198    HarnessId::OPENCODE,
199    HarnessId::PI,
200    HarnessId::HERMES,
201    HarnessId::OPENCLAW,
202];
203
204/// How deep a grouped skills root is walked. Hermes groups by category
205/// (`skills/<category>/<skill>/SKILL.md`) and OpenClaw allows one grouping
206/// level, so three is a whole category tree plus slack.
207const MAX_GROUP_DEPTH: usize = 3;
208/// How far up from `cwd` project roots are looked for.
209const MAX_ANCESTORS: usize = 32;
210/// Bytes of a `SKILL.md` read to find its frontmatter.
211const FRONTMATTER_READ_BYTES: usize = 8 * 1024;
212/// Ceiling on rows from one root, so a mistaken root cannot hang a listing.
213const MAX_ROWS_PER_ROOT: usize = 512;
214
215/// Directory names never treated as a skill or walked into.
216const SKIPPED_DIRS: &[&str] = &["node_modules", "target", ".git", "scripts", "references"];
217
218/// List every installed skill package the query selects.
219///
220/// Read-only: nothing here creates, writes, or removes a path.
221pub fn list_skills(query: &SkillsQuery) -> Vec<SkillRow> {
222    let cwd = query
223        .cwd
224        .clone()
225        .or_else(|| std::env::current_dir().ok())
226        .unwrap_or_else(|| PathBuf::from("."));
227    let mut rows = Vec::new();
228    let mut seen: BTreeSet<(String, PathBuf)> = BTreeSet::new();
229    for harness in SKILL_HARNESSES {
230        if let Some(wanted) = query.harness.as_deref() {
231            if wanted != *harness {
232                continue;
233            }
234        }
235        let id = HarnessId::new(*harness);
236        for (scope, root) in skill_roots(*harness, &query.homes, &cwd) {
237            if query.scope.is_some_and(|wanted| wanted != scope) {
238                continue;
239            }
240            let mut found = Vec::new();
241            collect_root(&id, scope, &root, 0, &mut found);
242            for row in found {
243                if seen.insert((row.harness.as_str().to_string(), row.location.clone())) {
244                    rows.push(row);
245                }
246            }
247        }
248    }
249    apply_codex_enablement(&query.homes, &mut rows);
250    rows.sort_by(|a, b| {
251        a.harness
252            .as_str()
253            .cmp(b.harness.as_str())
254            .then(a.scope.cmp(&b.scope))
255            .then(a.name.cmp(&b.name))
256            .then(a.location.cmp(&b.location))
257    });
258    rows
259}
260
261/// Every `(scope, root)` a harness's own loader would consult, restricted to
262/// the roots that exist right now.
263pub fn skill_roots(harness: &str, homes: &SkillHomes, cwd: &Path) -> Vec<(SkillScope, PathBuf)> {
264    let mut roots: Vec<(SkillScope, PathBuf)> = Vec::new();
265    match harness {
266        HarnessId::CLAUDE_CODE => {
267            for managed in claude_managed_roots() {
268                roots.push((SkillScope::Managed, managed));
269            }
270            roots.push((SkillScope::User, homes.claude_code.join("skills")));
271            for plugin in claude_plugin_roots(&homes.claude_code) {
272                roots.push((SkillScope::Plugin, plugin));
273            }
274            for project in project_roots(cwd, &[&[".claude", "skills"]]) {
275                roots.push((SkillScope::Project, project));
276            }
277        }
278        HarnessId::CODEX => {
279            roots.push((SkillScope::Managed, PathBuf::from("/etc/codex/skills")));
280            roots.push((
281                SkillScope::Bundled,
282                homes.codex.join("skills").join(".system"),
283            ));
284            roots.push((SkillScope::User, homes.agents.join("skills")));
285            roots.push((SkillScope::User, homes.codex.join("skills")));
286            for project in project_roots(cwd, &[&[".agents", "skills"]]) {
287                roots.push((SkillScope::Project, project));
288            }
289        }
290        HarnessId::OPENCODE => {
291            roots.push((SkillScope::User, homes.opencode.join("skill")));
292            roots.push((SkillScope::User, homes.opencode.join("skills")));
293            for project in project_roots(cwd, &[&[".opencode", "skill"], &[".opencode", "skills"]])
294            {
295                roots.push((SkillScope::Project, project));
296            }
297        }
298        HarnessId::PI => {
299            roots.push((SkillScope::User, homes.pi.join("skills")));
300            roots.push((SkillScope::User, homes.agents.join("skills")));
301            for project in project_roots(cwd, &[&[".pi", "skills"], &[".agents", "skills"]]) {
302                roots.push((SkillScope::Project, project));
303            }
304        }
305        HarnessId::HERMES => {
306            roots.push((SkillScope::User, homes.hermes.join("skills")));
307            for profile in hermes_profile_roots(&homes.hermes) {
308                roots.push((SkillScope::User, profile));
309            }
310        }
311        HarnessId::OPENCLAW => {
312            roots.push((SkillScope::Managed, homes.openclaw.join("skills")));
313            roots.push((SkillScope::Plugin, homes.openclaw.join("plugin-skills")));
314            roots.push((SkillScope::User, homes.agents.join("skills")));
315            let workspace = homes.openclaw.join("workspace");
316            roots.push((SkillScope::Project, workspace.join("skills")));
317            roots.push((
318                SkillScope::Project,
319                workspace.join(".agents").join("skills"),
320            ));
321        }
322        _ => {}
323    }
324    roots.retain(|(_, root)| root.is_dir());
325    roots
326}
327
328/// The roots supercode may WRITE a skill package into, in the harness's own
329/// precedence order — the same table [`skill_roots`] reads, narrowed to the
330/// two scopes a client may address and NOT filtered by existence (an install
331/// creates the root the harness's loader would then read).
332///
333/// Empty means "no writable root": every scope a harness owns rather than the
334/// user (`managed`, `plugin`, `bundled`), Hermes's and OpenClaw's roots (whose
335/// door is their own CLI verb, never a directory supercode writes behind their
336/// back), and every harness with no skills root at all.
337pub fn writable_skill_roots(
338    harness: &str,
339    scope: SkillScope,
340    homes: &SkillHomes,
341    cwd: &Path,
342) -> Vec<PathBuf> {
343    if !matches!(scope, SkillScope::User | SkillScope::Project) {
344        return Vec::new();
345    }
346    let project = |markers: &[&[&str]]| -> Vec<PathBuf> {
347        markers
348            .iter()
349            .map(|marker| {
350                let mut root = cwd.to_path_buf();
351                for segment in *marker {
352                    root = root.join(segment);
353                }
354                root
355            })
356            .collect()
357    };
358    match (harness, scope) {
359        (HarnessId::CLAUDE_CODE, SkillScope::User) => vec![homes.claude_code.join("skills")],
360        (HarnessId::CLAUDE_CODE, SkillScope::Project) => project(&[&[".claude", "skills"]]),
361        (HarnessId::CODEX, SkillScope::User) => {
362            vec![homes.agents.join("skills"), homes.codex.join("skills")]
363        }
364        (HarnessId::CODEX, SkillScope::Project) => project(&[&[".agents", "skills"]]),
365        (HarnessId::OPENCODE, SkillScope::User) => {
366            vec![homes.opencode.join("skill"), homes.opencode.join("skills")]
367        }
368        (HarnessId::OPENCODE, SkillScope::Project) => {
369            project(&[&[".opencode", "skill"], &[".opencode", "skills"]])
370        }
371        (HarnessId::PI, SkillScope::User) => {
372            vec![homes.pi.join("skills"), homes.agents.join("skills")]
373        }
374        (HarnessId::PI, SkillScope::Project) => {
375            project(&[&[".pi", "skills"], &[".agents", "skills"]])
376        }
377        _ => Vec::new(),
378    }
379}
380
381/// A skill package's own declared name: `SKILL.md` frontmatter `name`, else
382/// the directory's own name — exactly the rule [`list_skills`] applies, so a
383/// row installed here is found again by the name the loader will report.
384///
385/// `None` when the directory holds no `SKILL.md` at all.
386pub fn declared_skill_name(dir: &Path) -> Option<String> {
387    let manifest = dir.join("SKILL.md");
388    if !manifest.is_file() {
389        return None;
390    }
391    let front = read_frontmatter(&manifest);
392    front
393        .get("name")
394        .map(String::as_str)
395        .map(str::trim)
396        .filter(|value| !value.is_empty())
397        .map(str::to_string)
398        .or_else(|| {
399            dir.file_name()
400                .and_then(|name| name.to_str())
401                .map(str::to_string)
402        })
403}
404
405/// Claude Code's enterprise-managed skill directory, per platform.
406fn claude_managed_roots() -> Vec<PathBuf> {
407    #[cfg(target_os = "macos")]
408    {
409        vec![PathBuf::from(
410            "/Library/Application Support/ClaudeCode/skills",
411        )]
412    }
413    #[cfg(not(target_os = "macos"))]
414    {
415        vec![PathBuf::from("/etc/claude-code/skills")]
416    }
417}
418
419/// `<claude home>/plugins/cache/<marketplace>/<plugin>/<version>/skills` — the
420/// installed, materialized plugin bundles. Fixed depth, so this stays cheap.
421fn claude_plugin_roots(claude_home: &Path) -> Vec<PathBuf> {
422    let cache = claude_home.join("plugins").join("cache");
423    let mut roots = Vec::new();
424    for marketplace in child_dirs(&cache) {
425        for plugin in child_dirs(&marketplace) {
426            for version in child_dirs(&plugin) {
427                let skills = version.join("skills");
428                if skills.is_dir() {
429                    roots.push(skills);
430                }
431            }
432        }
433    }
434    roots
435}
436
437/// `<HERMES_HOME>/profiles/<name>/skills` — profile mode points `HERMES_HOME`
438/// at `<root>/profiles/<name>`, so both layouts are read from one root.
439fn hermes_profile_roots(hermes_home: &Path) -> Vec<PathBuf> {
440    child_dirs(&hermes_home.join("profiles"))
441        .into_iter()
442        .map(|profile| profile.join("skills"))
443        .filter(|root| root.is_dir())
444        .collect()
445}
446
447fn child_dirs(dir: &Path) -> Vec<PathBuf> {
448    let Ok(entries) = std::fs::read_dir(dir) else {
449        return Vec::new();
450    };
451    let mut out: Vec<PathBuf> = entries
452        .flatten()
453        .map(|entry| entry.path())
454        .filter(|path| path.is_dir())
455        .collect();
456    out.sort();
457    out
458}
459
460/// Project roots under `cwd` and its ancestors, for each relative marker.
461///
462/// The walk stops at the enclosing repository (the first ancestor holding
463/// `.git`, inclusive) — Codex and opencode both bound their own project scan
464/// that way ("every dir cwd→repo-root", "cwd→worktree root") — and at
465/// [`MAX_ANCESTORS`] otherwise.
466fn project_roots(cwd: &Path, markers: &[&[&str]]) -> Vec<PathBuf> {
467    let mut roots = Vec::new();
468    let mut seen = BTreeSet::new();
469    for ancestor in cwd.ancestors().take(MAX_ANCESTORS) {
470        for marker in markers {
471            let mut root = ancestor.to_path_buf();
472            for segment in *marker {
473                root = root.join(segment);
474            }
475            if root.is_dir() && seen.insert(root.clone()) {
476                roots.push(root);
477            }
478        }
479        if ancestor.join(".git").exists() {
480            break;
481        }
482    }
483    roots
484}
485
486/// Walk one root. A directory holding `SKILL.md` is a skill; a directory that
487/// only groups other skills (Hermes categories, OpenClaw groups) is walked
488/// through; a leaf directory with neither still lists, by its own name.
489fn collect_root(
490    harness: &HarnessId,
491    scope: SkillScope,
492    root: &Path,
493    depth: usize,
494    out: &mut Vec<SkillRow>,
495) {
496    if out.len() >= MAX_ROWS_PER_ROOT {
497        return;
498    }
499    for dir in child_dirs(root) {
500        if out.len() >= MAX_ROWS_PER_ROOT {
501            return;
502        }
503        let Some(name) = dir.file_name().and_then(|name| name.to_str()) else {
504            continue;
505        };
506        if SKIPPED_DIRS.contains(&name) || name.starts_with('.') {
507            continue;
508        }
509        let manifest = dir.join("SKILL.md");
510        if manifest.is_file() {
511            out.push(read_skill(harness, scope, &dir, name, &manifest));
512            continue;
513        }
514        let before = out.len();
515        if depth + 1 < MAX_GROUP_DEPTH {
516            collect_root(harness, scope, &dir, depth + 1, out);
517        }
518        if out.len() == before {
519            // A directory with no manifest and no skills under it is still an
520            // installed package by name — the harness names it the same way.
521            out.push(SkillRow {
522                name: name.to_string(),
523                harness: harness.clone(),
524                scope,
525                location: dir.clone(),
526                description: None,
527                version: None,
528                enabled: None,
529            });
530        }
531    }
532}
533
534fn read_skill(
535    harness: &HarnessId,
536    scope: SkillScope,
537    dir: &Path,
538    dir_name: &str,
539    manifest: &Path,
540) -> SkillRow {
541    let front = read_frontmatter(manifest);
542    SkillRow {
543        name: front
544            .get("name")
545            .map(String::as_str)
546            .map(str::trim)
547            .filter(|value| !value.is_empty())
548            .unwrap_or(dir_name)
549            .to_string(),
550        harness: harness.clone(),
551        scope,
552        location: dir.to_path_buf(),
553        description: front.get("description").map(|value| one_line(value)),
554        version: front
555            .get("version")
556            .map(|value| value.trim().to_string())
557            .filter(|value| !value.is_empty()),
558        enabled: frontmatter_enabled(&front),
559    }
560}
561
562/// A skill's own frontmatter is the only per-skill enablement statement the
563/// SKILL.md standard makes: `enabled: false`, or pi's
564/// `disable-model-invocation: true` (`inventory/pi.md` §2).
565fn frontmatter_enabled(front: &std::collections::BTreeMap<String, String>) -> Option<bool> {
566    if let Some(value) = front.get("enabled") {
567        return parse_bool(value);
568    }
569    if let Some(value) = front.get("disable-model-invocation") {
570        return parse_bool(value).map(|disabled| !disabled);
571    }
572    None
573}
574
575fn parse_bool(value: &str) -> Option<bool> {
576    match value
577        .trim()
578        .trim_matches(['"', '\''])
579        .to_ascii_lowercase()
580        .as_str()
581    {
582        "true" | "yes" | "on" => Some(true),
583        "false" | "no" | "off" => Some(false),
584        _ => None,
585    }
586}
587
588fn one_line(value: &str) -> String {
589    value.split_whitespace().collect::<Vec<_>>().join(" ")
590}
591
592/// Lenient YAML-frontmatter scan: a leading `---` fence, then top-level
593/// `key: value` lines until the closing fence. Indented lines, list items,
594/// and anything unparseable are skipped rather than failing the skill —
595/// every harness's own loader is lenient here too.
596pub(crate) fn read_frontmatter(manifest: &Path) -> std::collections::BTreeMap<String, String> {
597    let mut out = std::collections::BTreeMap::new();
598    let Ok(text) = std::fs::read_to_string(manifest) else {
599        return out;
600    };
601    let head: String = text.chars().take(FRONTMATTER_READ_BYTES).collect();
602    let mut lines = head.lines();
603    match lines.next().map(str::trim) {
604        Some("---") => {}
605        _ => return out,
606    }
607    // BP-5: a key whose value is empty opens a YAML BLOCK SEQUENCE — the
608    // shape `paths:`/`allowed-tools:` are usually written in (`  - src/**`).
609    // Its items are collected into the same comma-joined single-line form an
610    // inline list (`paths: [a, b]`) already produces, so every consumer reads
611    // one spelling through [`frontmatter_list`].
612    let mut pending_block: Option<String> = None;
613    for line in lines {
614        let trimmed = line.trim_end();
615        if trimmed.trim() == "---" || trimmed.trim() == "..." {
616            break;
617        }
618        if trimmed.is_empty() || trimmed.trim_start().starts_with('#') {
619            continue;
620        }
621        if trimmed.starts_with(char::is_whitespace) {
622            let item = trimmed.trim();
623            if let (Some(key), Some(item)) = (pending_block.as_ref(), item.strip_prefix("- ")) {
624                let item = item.trim().trim_matches(['"', '\'']).trim().to_string();
625                if !item.is_empty() {
626                    out.entry(key.clone())
627                        .and_modify(|v| {
628                            if !v.is_empty() {
629                                v.push_str(", ");
630                            }
631                            v.push_str(&item);
632                        })
633                        .or_insert(item);
634                }
635            }
636            continue;
637        }
638        pending_block = None;
639        let Some((key, value)) = trimmed.split_once(':') else {
640            continue;
641        };
642        let key = key.trim().to_ascii_lowercase();
643        let value = value.trim().trim_matches(['"', '\'']).trim().to_string();
644        if key.is_empty() {
645            continue;
646        }
647        if value.is_empty() {
648            pending_block = Some(key);
649            continue;
650        }
651        out.entry(key).or_insert(value);
652    }
653    out
654}
655
656/// BP-5: one frontmatter value read as a LIST — the inline form
657/// (`paths: [a, b]`, `allowed-tools: Bash(git status:*), Read`) and the
658/// block form [`read_frontmatter`] flattens into it. Splitting is on commas
659/// only, because a rule/tool pattern legitimately contains spaces
660/// (`Bash(git status:*)`).
661pub(crate) fn frontmatter_list(value: &str) -> Vec<String> {
662    value
663        .trim()
664        .trim_start_matches('[')
665        .trim_end_matches(']')
666        .split(',')
667        .map(|item| item.trim().trim_matches(['"', '\'']).trim().to_string())
668        .filter(|item| !item.is_empty())
669        .collect()
670}
671
672/// Codex's `[skills]` block is an enable/disable overlay keyed by name or by
673/// absolute path (`codex-rs/config/src/skills_config.rs` at pin `1f0566d3`),
674/// plus `[skills.bundled] enabled` for the bundled cache. Apply it to the
675/// Codex rows; every other harness keeps `enabled: None`.
676fn apply_codex_enablement(homes: &SkillHomes, rows: &mut [SkillRow]) {
677    let config = homes.codex.join("config.toml");
678    let Ok(text) = std::fs::read_to_string(&config) else {
679        return;
680    };
681    let Ok(doc) = text.parse::<toml::Value>() else {
682        return;
683    };
684    let Some(skills) = doc.get("skills") else {
685        return;
686    };
687    let bundled = skills
688        .get("bundled")
689        .and_then(|value| value.get("enabled"))
690        .and_then(toml::Value::as_bool);
691    let entries: Vec<(Option<String>, Option<PathBuf>, bool)> = skills
692        .get("config")
693        .and_then(toml::Value::as_array)
694        .map(|array| {
695            array
696                .iter()
697                .filter_map(|entry| {
698                    let enabled = entry.get("enabled").and_then(toml::Value::as_bool)?;
699                    let name = entry
700                        .get("name")
701                        .and_then(toml::Value::as_str)
702                        .map(str::to_string);
703                    let path = entry
704                        .get("path")
705                        .and_then(toml::Value::as_str)
706                        .map(PathBuf::from);
707                    Some((name, path, enabled))
708                })
709                .collect()
710        })
711        .unwrap_or_default();
712    for row in rows.iter_mut() {
713        if row.harness.as_str() != HarnessId::CODEX {
714            continue;
715        }
716        if row.scope == SkillScope::Bundled {
717            if let Some(enabled) = bundled {
718                row.enabled = Some(enabled);
719            }
720        }
721        for (name, path, enabled) in &entries {
722            let matches_name = name.as_deref() == Some(row.name.as_str());
723            let matches_path = path.as_deref() == Some(row.location.as_path());
724            if matches_name || matches_path {
725                row.enabled = Some(*enabled);
726            }
727        }
728    }
729}
730
731// ---------------------------------------------------------------------------
732// BP-6: the LOOP's own skill set (catalog D1 "Skill-invocation surface",
733// D2 "Skills (progressive-disclosure packages)", D7 "Skill discovery from
734// multiple roots").
735//
736// ORCH-11 above answers "what has this OTHER harness installed?" — a
737// read-only observation. Everything below answers "what will supercode's own
738// agent loop load?", and it answers it by reusing exactly the same root table
739// and the same `SKILL.md` frontmatter reader, so the loop can never discover a
740// set `supercode skills list` disagrees with.
741//
742// The preset NAMES whose root table to read (`[core.skills] harness`), so
743// `cc-parity` discovers skills the way Claude Code documents
744// (enterprise/managed > `~/.claude/skills` > plugins > project
745// `.claude/skills`, plus nested subdirectory skills as `dir:skill`) and
746// `cx-parity` the way Codex documents (`/etc/codex/skills`, the bundled
747// `.system` cache, `~/.agents/skills` + `$CODEX_HOME/skills`, repo
748// `.agents/skills` from cwd to the repo root).
749// ---------------------------------------------------------------------------
750
751/// How far BELOW `cwd` nested project skill roots are looked for (Claude
752/// Code's "nested `.claude/skills/` in subdirectories", `dir:skill`).
753const MAX_NESTED_DEPTH: usize = 3;
754
755/// Ceiling on directories visited by the nested scan, so a huge working tree
756/// cannot make agent construction expensive.
757const MAX_NESTED_DIRS: usize = 400;
758
759/// Ceiling on the bytes of a skill body handed to the model in one load.
760pub const MAX_SKILL_BODY_BYTES: usize = 64 * 1024;
761
762/// The substitution token a skill body uses for the text that followed its
763/// invocation (`docs:skills#available-string-substitutions`).
764const ARGUMENTS_TOKEN: &str = "$ARGUMENTS";
765
766/// One SKILL.md package the supercode loop itself will load.
767#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
768pub struct LoopSkill {
769    /// The name the loop invokes it by — frontmatter `name` (else the
770    /// directory name), qualified `plugin:skill` / `dir:skill` where the
771    /// source harness qualifies it.
772    pub name: String,
773    /// Frontmatter `description`, one line. The INDEX line's whole payload;
774    /// a skill without one is still invocable, just undescribed.
775    #[serde(default, skip_serializing_if = "Option::is_none")]
776    pub description: Option<String>,
777    /// Frontmatter `version`.
778    #[serde(default, skip_serializing_if = "Option::is_none")]
779    pub version: Option<String>,
780    /// Precedence class of the root it came from.
781    pub scope: SkillScope,
782    /// The skill's own directory.
783    pub dir: PathBuf,
784    /// `<dir>/SKILL.md` — where the BODY lives, read only on invocation.
785    pub manifest: PathBuf,
786    /// Whether the model may see it in the prompt index at all. `false` for
787    /// `enabled: false` / `disable-model-invocation: true` frontmatter: the
788    /// skill stays user-invocable by name, it is simply not advertised
789    /// (cc§7 "Invocation control", pi§2).
790    pub model_invocable: bool,
791    /// BP-5 (cc§7 "Invocation control": "pre-approved tools while active"):
792    /// the package's own `allowed-tools` frontmatter, verbatim. Its only
793    /// consumer is [`ShellInjection::expand`], where it pre-approves this
794    /// body's OWN `` !`cmd` `` commands and nothing else — it never widens
795    /// what the model's tool calls are allowed to do.
796    #[serde(default, skip_serializing_if = "Vec::is_empty")]
797    pub allowed_tools: Vec<String>,
798    /// BP-5 (cc§7 "Skill frontmatter": `arguments` (named positional)): the
799    /// package's ARGUMENT SCHEMA — the names its body substitutes as
800    /// `$name`, in positional order. Empty when the body only uses
801    /// `$ARGUMENTS`/`$1`..`$9`.
802    #[serde(default, skip_serializing_if = "Vec::is_empty")]
803    pub argument_names: Vec<String>,
804    /// BP-5 (cc§7 `argument-hint`): the one-line usage hint shown beside
805    /// this package in the prompt index, so a model calling it by name knows
806    /// what the trailing text should be.
807    #[serde(default, skip_serializing_if = "Option::is_none")]
808    pub argument_hint: Option<String>,
809}
810
811impl LoopSkill {
812    /// The prompt-index line: name and description only — never the body.
813    pub fn index_line(&self) -> String {
814        let mut line = match self.description.as_deref() {
815            Some(description) if !description.is_empty() => {
816                format!("- {}: {description}", self.name)
817            }
818            _ => format!("- {}", self.name),
819        };
820        // BP-5: the argument schema travels with the index line, so a caller
821        // knows the shape of the trailing text before loading the body.
822        if let Some(hint) = self.argument_hint.as_deref().filter(|h| !h.is_empty()) {
823            line.push_str(&format!(" (arguments: {hint})"));
824        } else if !self.argument_names.is_empty() {
825            line.push_str(&format!(" (arguments: {})", self.argument_names.join(" ")));
826        }
827        line
828    }
829
830    /// Read this skill's BODY (everything after the frontmatter fence),
831    /// substituting `$ARGUMENTS` / `$1`..`$9` with the invocation's trailing
832    /// text. This is the ONLY function that spends a body's tokens; nothing
833    /// on the discovery path reads past the frontmatter.
834    pub fn body(&self, arguments: &str) -> std::io::Result<String> {
835        let text = std::fs::read_to_string(&self.manifest)?;
836        Ok(substitute_arguments(
837            &strip_frontmatter(&text),
838            arguments,
839            &self.argument_names,
840        ))
841    }
842
843    /// BP-5: [`Self::body`], then the `` !`cmd` `` expansion `shell`
844    /// authorizes (cc§7 "Dynamic context injection"). This is the door every
845    /// invocation surface uses — the `skill` tool, `/name`, `/skill:name`
846    /// and `$slug` — so one body cannot mean two things depending on which
847    /// door loaded it. With shell injection off (the default) this is
848    /// exactly [`Self::body`].
849    pub fn body_with_shell(
850        &self,
851        arguments: &str,
852        shell: &ShellInjection,
853    ) -> std::io::Result<String> {
854        let body = self.body(arguments)?;
855        Ok(shell.expand(&body, &self.allowed_tools))
856    }
857}
858
859/// Everything after a leading `---` frontmatter fence (the whole text when
860/// there is no fence), capped at [`MAX_SKILL_BODY_BYTES`].
861pub(crate) fn strip_frontmatter(text: &str) -> String {
862    let body = match text.strip_prefix("---") {
863        Some(rest) => match rest.split_once("\n---") {
864            Some((_, after)) => after
865                .trim_start_matches(['-', '\r'])
866                .trim_start_matches('\n'),
867            None => text,
868        },
869        None => text,
870    };
871    let body = body.trim();
872    if body.len() <= MAX_SKILL_BODY_BYTES {
873        return body.to_string();
874    }
875    let mut cut = MAX_SKILL_BODY_BYTES;
876    while cut > 0 && !body.is_char_boundary(cut) {
877        cut -= 1;
878    }
879    format!("{}\n\n[skill body truncated]", &body[..cut])
880}
881
882/// `$ARGUMENTS`, `$ARGUMENTS[N]`, `$1`..`$9` and — BP-5 — `$name` for each
883/// name in the package's own `arguments` frontmatter, substituted in
884/// positional order (cc§7 "String substitutions").
885///
886/// Named substitution runs FIRST so a schema name can never be shadowed by
887/// a positional token, and a name with no matching argument substitutes
888/// empty rather than leaving a live `$name` in the model's instructions.
889fn substitute_arguments(body: &str, arguments: &str, argument_names: &[String]) -> String {
890    let positional: Vec<&str> = arguments.split_whitespace().collect();
891    let mut out = body.to_string();
892    for (index, name) in argument_names.iter().enumerate() {
893        let token = format!("${name}");
894        if !out.contains(&token) {
895            continue;
896        }
897        out = out.replace(&token, positional.get(index).copied().unwrap_or(""));
898    }
899    for (index, value) in positional.iter().enumerate() {
900        let token = format!("{ARGUMENTS_TOKEN}[{index}]");
901        if out.contains(&token) {
902            out = out.replace(&token, value);
903        }
904    }
905    out = out.replace(ARGUMENTS_TOKEN, arguments);
906    for index in 1..=9usize {
907        let token = format!("${index}");
908        if !out.contains(&token) {
909            continue;
910        }
911        out = out.replace(&token, positional.get(index - 1).copied().unwrap_or(""));
912    }
913    out
914}
915
916// ---------------------------------------------------------------------------
917// BP-5 (catalog D2 "Shell-output injection in templates/skills", cc§7
918// "Dynamic context injection": "`` !`command` `` inline and ```` ```! ````
919// block shell execution inside skill bodies at load time (disable org-wide
920// with `disableSkillShellExecution`)").
921//
922// The whole gate is the ONE permissions engine (`crate::permissions`): the
923// config's own deny/ask/allow rules, its protected-path floor and its
924// approval default decide every command, exactly as they decide a `bash`
925// tool call. A body's own `allowed-tools` frontmatter contributes to the
926// ALLOW tier only, and only for its own commands — a deny rule still wins
927// first-match, so a body cannot pre-approve itself past a protected path.
928// ---------------------------------------------------------------------------
929
930/// Ceiling on the bytes one command's output contributes to a body.
931const MAX_INJECTED_OUTPUT_BYTES: usize = 8 * 1024;
932
933/// How long one injected command may run before it is killed.
934const SHELL_INJECTION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
935
936/// How many commands one body may run, so a hostile body cannot turn prompt
937/// assembly into an unbounded batch of subprocesses.
938const MAX_INJECTED_COMMANDS: usize = 16;
939
940/// The authorization a `` !`cmd` `` expansion runs under — built from a
941/// resolved [`crate::Config`], never assembled ad hoc at a call site.
942#[derive(Debug, Clone)]
943pub struct ShellInjection {
944    enabled: bool,
945    cwd: PathBuf,
946    rules: crate::permissions::RuleSet,
947    default: crate::permissions::Decision,
948}
949
950impl ShellInjection {
951    /// The policy `config` authorizes. Disabled (the default) makes
952    /// [`Self::expand`] an identity function that spawns nothing.
953    pub fn from_config(config: &crate::Config) -> Self {
954        Self {
955            enabled: config.skills_shell_injection,
956            cwd: config.cwd.clone(),
957            rules: crate::permissions::rules_for_config(config),
958            // The same baseline a `bash` tool call gets under this config —
959            // `bash` is the tool actually being asked for here.
960            default: crate::permissions::default_decision(config, "bash"),
961        }
962    }
963
964    /// A policy that executes nothing — the shape every caller that has no
965    /// config at hand must use.
966    pub fn disabled() -> Self {
967        Self {
968            enabled: false,
969            cwd: PathBuf::from("."),
970            rules: crate::permissions::RuleSet::default(),
971            default: crate::permissions::Decision::Ask,
972        }
973    }
974
975    /// Whether this policy may run anything at all.
976    pub fn is_enabled(&self) -> bool {
977        self.enabled
978    }
979
980    /// Replace every `` !`cmd` `` (and ```` ```! ```` block) in `body` with
981    /// that command's output. `allowed_tools` is the BODY's own
982    /// `allowed-tools` frontmatter, folded into the allow tier for these
983    /// commands only.
984    ///
985    /// A command the engine does not resolve to
986    /// [`crate::permissions::Decision::Allow`] is never run: the token is
987    /// replaced by the refusal and its reason, in place, so the model reads
988    /// what was withheld instead of silently receiving nothing.
989    pub fn expand(&self, body: &str, allowed_tools: &[String]) -> String {
990        if !self.enabled || !(body.contains("!`") || body.contains("```!")) {
991            return body.to_string();
992        }
993        let mut rules = self.rules.clone();
994        rules
995            .allow
996            .extend(allowed_tools_to_allow_rules(allowed_tools));
997        let mut out = String::with_capacity(body.len());
998        let mut rest = body;
999        let mut ran = 0usize;
1000        while let Some((before, command, after, closing)) = next_injection(rest) {
1001            out.push_str(before);
1002            ran += 1;
1003            if ran > MAX_INJECTED_COMMANDS {
1004                out.push_str(&format!(
1005                    "[supercode: shell injection stopped after {MAX_INJECTED_COMMANDS} commands]"
1006                ));
1007                out.push_str(closing);
1008                rest = after;
1009                continue;
1010            }
1011            out.push_str(&self.run_one(&rules, &command));
1012            out.push_str(closing);
1013            rest = after;
1014        }
1015        out.push_str(rest);
1016        out
1017    }
1018
1019    /// One command: gate first, then run. Never the other order.
1020    fn run_one(&self, rules: &crate::permissions::RuleSet, command: &str) -> String {
1021        use crate::permissions::Decision;
1022        let command = command.trim();
1023        if command.is_empty() {
1024            return String::new();
1025        }
1026        let decision = crate::permissions::evaluate_command(rules, "bash", command, self.default);
1027        if decision != Decision::Allow {
1028            return format!(
1029                "[supercode: `{command}` was not run — permissions engine: {decision:?}. \
1030                 Allow it with a permission rule or the body's own `allowed-tools`.]"
1031            );
1032        }
1033        match run_injected_command(&self.cwd, command) {
1034            Ok(text) => text,
1035            Err(e) => format!("[supercode: `{command}` failed: {e}]"),
1036        }
1037    }
1038}
1039
1040/// Translate Claude Code's `allowed-tools` spellings (`Bash(git status:*)`,
1041/// `Read`, `Bash`) into this engine's own rule syntax
1042/// ([`crate::permissions::RuleSet`]): the tool name lowercased, and cc's
1043/// `cmd:*` prefix form rewritten as the `cmd*` glob this engine matches
1044/// canonicalized command text with. An entry that names no recognizable
1045/// tool contributes NOTHING — a frontmatter typo must never widen a rule
1046/// set.
1047fn allowed_tools_to_allow_rules(entries: &[String]) -> Vec<String> {
1048    let mut out = Vec::new();
1049    for entry in entries {
1050        let entry = entry.trim();
1051        if entry.is_empty() {
1052            continue;
1053        }
1054        let (tool, subject) = match entry.split_once('(') {
1055            Some((tool, rest)) => match rest.strip_suffix(')') {
1056                Some(subject) => (tool.trim(), Some(subject.trim())),
1057                None => continue,
1058            },
1059            None => (entry, None),
1060        };
1061        // Only the shell tools matter here: this rule set gates `!`cmd``
1062        // and nothing else, so a `Read`/`Edit` entry is simply not about
1063        // this surface.
1064        let tool = tool.to_ascii_lowercase();
1065        if !matches!(tool.as_str(), "bash" | "shell" | "powershell") {
1066            continue;
1067        }
1068        match subject {
1069            None => out.push("bash".to_string()),
1070            Some(subject) => {
1071                let glob = subject.replace(":*", "*");
1072                out.push(format!("bash({glob})"));
1073            }
1074        }
1075    }
1076    out
1077}
1078
1079/// Find the next `` !`cmd` `` or ```` ```! ```` block in `text`. Returns
1080/// `(text before it, the command, the text after it, the closing text to
1081/// re-emit)`. The block form re-emits nothing of its own — the fence is
1082/// consumed with the command.
1083fn next_injection(text: &str) -> Option<(&str, String, &str, &'static str)> {
1084    let inline = text.find("!`");
1085    let block = text.find("```!");
1086    match (inline, block) {
1087        (Some(i), Some(b)) if b < i => split_block(text, b),
1088        (Some(i), _) => split_inline(text, i),
1089        (None, Some(b)) => split_block(text, b),
1090        (None, None) => None,
1091    }
1092}
1093
1094fn split_inline(text: &str, at: usize) -> Option<(&str, String, &str, &'static str)> {
1095    let after_open = &text[at + 2..];
1096    let end = after_open.find('`')?;
1097    Some((
1098        &text[..at],
1099        after_open[..end].to_string(),
1100        &after_open[end + 1..],
1101        "",
1102    ))
1103}
1104
1105fn split_block(text: &str, at: usize) -> Option<(&str, String, &str, &'static str)> {
1106    let after_open = &text[at + 4..];
1107    let body_start = after_open.find('\n')? + 1;
1108    let body = &after_open[body_start..];
1109    let end = body.find("```")?;
1110    let after = &body[end + 3..];
1111    Some((&text[..at], body[..end].trim().to_string(), after, ""))
1112}
1113
1114/// Run one authorized command and render its output for a prompt: stdout
1115/// (plus stderr when the command failed), trimmed, capped at
1116/// [`MAX_INJECTED_OUTPUT_BYTES`], killed at [`SHELL_INJECTION_TIMEOUT`].
1117///
1118/// Synchronous on purpose: body expansion happens on the prompt-assembly
1119/// path, which is not an async context in every caller (`Agent::expand_prompt`
1120/// is a sync method with sync callers).
1121fn run_injected_command(cwd: &Path, command: &str) -> std::io::Result<String> {
1122    use std::process::{Command, Stdio};
1123    let mut child = Command::new("sh")
1124        .arg("-c")
1125        .arg(command)
1126        .current_dir(cwd)
1127        .stdin(Stdio::null())
1128        .stdout(Stdio::piped())
1129        .stderr(Stdio::piped())
1130        .spawn()?;
1131    let deadline = std::time::Instant::now() + SHELL_INJECTION_TIMEOUT;
1132    loop {
1133        match child.try_wait()? {
1134            Some(_) => break,
1135            None if std::time::Instant::now() >= deadline => {
1136                let _ = child.kill();
1137                let _ = child.wait();
1138                return Ok(format!(
1139                    "[supercode: `{command}` timed out after {}s]",
1140                    SHELL_INJECTION_TIMEOUT.as_secs()
1141                ));
1142            }
1143            None => std::thread::sleep(std::time::Duration::from_millis(10)),
1144        }
1145    }
1146    let output = child.wait_with_output()?;
1147    let mut text = String::from_utf8_lossy(&output.stdout).trim().to_string();
1148    if !output.status.success() {
1149        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
1150        if !err.is_empty() {
1151            if !text.is_empty() {
1152                text.push('\n');
1153            }
1154            text.push_str(&err);
1155        }
1156    }
1157    if text.len() > MAX_INJECTED_OUTPUT_BYTES {
1158        let mut cut = MAX_INJECTED_OUTPUT_BYTES;
1159        while cut > 0 && !text.is_char_boundary(cut) {
1160            cut -= 1;
1161        }
1162        text.truncate(cut);
1163        text.push_str("\n[output truncated]");
1164    }
1165    Ok(text)
1166}
1167
1168/// Resolve one invocation name against a discovered set: exact, then
1169/// case-insensitively, then the unqualified leaf of a `dir:skill` /
1170/// `plugin:skill` name when exactly one skill owns that leaf. A leading `/`
1171/// or `$` sigil is stripped first, so the same resolver serves the slash
1172/// command, the mention, and the `skill` tool — one name, one answer.
1173pub fn find_skill<'a>(skills: &'a [LoopSkill], name: &str) -> Option<&'a LoopSkill> {
1174    let wanted = name.trim().trim_start_matches(['/', '$']).trim();
1175    if wanted.is_empty() {
1176        return None;
1177    }
1178    if let Some(hit) = skills.iter().find(|skill| skill.name == wanted) {
1179        return Some(hit);
1180    }
1181    if let Some(hit) = skills
1182        .iter()
1183        .find(|skill| skill.name.eq_ignore_ascii_case(wanted))
1184    {
1185        return Some(hit);
1186    }
1187    let mut leaves = skills.iter().filter(|skill| {
1188        skill
1189            .name
1190            .rsplit_once(':')
1191            .is_some_and(|(_, leaf)| leaf.eq_ignore_ascii_case(wanted))
1192    });
1193    let first = leaves.next()?;
1194    match leaves.next() {
1195        // Ambiguous leaf: refuse rather than guess — the qualified form is
1196        // exactly what the harnesses require here.
1197        Some(_) => None,
1198        None => Some(first),
1199    }
1200}
1201
1202/// The envelope a loaded body arrives in, identical whichever door invoked
1203/// it (`skill` tool result, `/name` expansion, `$slug` mention), so a
1204/// transcript reads the same way in all three.
1205pub fn render_skill(skill: &LoopSkill, body: &str) -> String {
1206    format!(
1207        "# Skill: {}\n(loaded from {})\n\n{body}",
1208        skill.name,
1209        skill.dir.display()
1210    )
1211}
1212
1213/// Words too common to identify a skill by. Deliberately tiny: the rule
1214/// below already requires TWO distinct hits from one description.
1215const IMPLICIT_STOPWORDS: &[&str] = &[
1216    "about", "after", "again", "their", "there", "these", "those", "which", "while", "would",
1217    "should", "could", "every", "other", "using", "when", "with", "that", "this", "from", "into",
1218];
1219
1220/// BP-6 (cx§7 "implicit (description-matched) invocation"): the single
1221/// best skill a message DESCRIBES, or `None`.
1222///
1223/// Off by default (`[core.skills] implicit_match`), because an implicit
1224/// load spends a body's tokens the user never asked for. The rule is
1225/// deliberately conservative: the skill's own name appearing as a word, or
1226/// TWO distinct significant words from its description. At most one skill
1227/// is ever matched implicitly.
1228pub fn implicit_skill_match<'a>(skills: &'a [LoopSkill], text: &str) -> Option<&'a LoopSkill> {
1229    let haystack: BTreeSet<String> = text
1230        .split(|c: char| !c.is_alphanumeric() && c != '-')
1231        .map(|word| word.to_ascii_lowercase())
1232        .filter(|word| word.len() >= 4)
1233        .collect();
1234    if haystack.is_empty() {
1235        return None;
1236    }
1237    let mut best: Option<(usize, &LoopSkill)> = None;
1238    for skill in skills.iter().filter(|skill| skill.model_invocable) {
1239        let name = skill.name.to_ascii_lowercase();
1240        if haystack.contains(&name) {
1241            return Some(skill);
1242        }
1243        let Some(description) = skill.description.as_deref() else {
1244            continue;
1245        };
1246        let hits = description
1247            .split(|c: char| !c.is_alphanumeric() && c != '-')
1248            .map(|word| word.to_ascii_lowercase())
1249            .filter(|word| word.len() >= 5 && !IMPLICIT_STOPWORDS.contains(&word.as_str()))
1250            .collect::<BTreeSet<String>>()
1251            .into_iter()
1252            .filter(|word| haystack.contains(word))
1253            .count();
1254        if hits >= 2 && best.is_none_or(|(previous, _)| hits > previous) {
1255            best = Some((hits, skill));
1256        }
1257    }
1258    best.map(|(_, skill)| skill)
1259}
1260
1261/// Discover every SKILL.md package the loop will load, in PRECEDENCE order:
1262/// the config's own extra roots first (a root a config names is more
1263/// specific than a discovered one), then the named harness's own documented
1264/// root table in its own order, then — for Claude Code — nested
1265/// `<subdir>/.claude/skills` packages under `cwd`, qualified `dir:skill`.
1266///
1267/// De-duplicated by invocation NAME (first root wins, the collision rule
1268/// every one of these harnesses states) and by location (one directory
1269/// reachable through two roots is one skill).
1270pub fn load_loop_skills(
1271    harness: &str,
1272    homes: &SkillHomes,
1273    cwd: &Path,
1274    extra_dirs: &[PathBuf],
1275) -> Vec<LoopSkill> {
1276    let id = HarnessId::new(harness);
1277    let mut roots: Vec<(SkillScope, PathBuf)> = extra_dirs
1278        .iter()
1279        .filter(|root| root.is_dir())
1280        .map(|root| (SkillScope::Project, root.clone()))
1281        .collect();
1282    roots.extend(skill_roots(harness, homes, cwd));
1283
1284    let mut out: Vec<LoopSkill> = Vec::new();
1285    let mut seen_names: BTreeSet<String> = BTreeSet::new();
1286    let mut seen_dirs: BTreeSet<PathBuf> = BTreeSet::new();
1287    for (scope, root) in roots {
1288        let mut found = Vec::new();
1289        collect_root(&id, scope, &root, 0, &mut found);
1290        let qualifier = plugin_qualifier(scope, &root);
1291        for row in found {
1292            push_loop_skill(
1293                row,
1294                qualifier.as_deref(),
1295                &mut seen_names,
1296                &mut seen_dirs,
1297                &mut out,
1298            );
1299        }
1300    }
1301    if harness == HarnessId::CLAUDE_CODE {
1302        for (qualifier, root) in nested_claude_roots(cwd) {
1303            let mut found = Vec::new();
1304            collect_root(&id, SkillScope::Project, &root, 0, &mut found);
1305            for row in found {
1306                push_loop_skill(
1307                    row,
1308                    Some(qualifier.as_str()),
1309                    &mut seen_names,
1310                    &mut seen_dirs,
1311                    &mut out,
1312                );
1313            }
1314        }
1315        // BP-5 (cc§7 Skills: "custom commands (`.claude/commands/*.md`)
1316        // merged into skills (same engine, `$ARGUMENTS` etc.)"): a command
1317        // file IS a skill in Claude Code — one markdown file rather than a
1318        // directory with a SKILL.md. They are collected LAST, so cc's own
1319        // collision rule ("skills override same-name … commands") falls out
1320        // of the same first-root-wins de-duplication every other root uses.
1321        for (scope, root) in command_roots(homes, cwd) {
1322            collect_command_root(scope, &root, &mut seen_names, &mut out);
1323        }
1324    }
1325    out
1326}
1327
1328/// BP-5: Claude Code's markdown-command roots, personal before project —
1329/// the same precedence its skill roots use (cc§7 "Skill locations &
1330/// precedence").
1331fn command_roots(homes: &SkillHomes, cwd: &Path) -> Vec<(SkillScope, PathBuf)> {
1332    let mut roots = vec![(SkillScope::User, homes.claude_code.join("commands"))];
1333    for root in project_roots(cwd, &[&[".claude", "commands"]]) {
1334        roots.push((SkillScope::Project, root));
1335    }
1336    roots.into_iter().filter(|(_, r)| r.is_dir()).collect()
1337}
1338
1339/// How deep a command root's subdirectories are read. Claude Code namespaces
1340/// a command in a subdirectory as `dir:name`; deeper nesting is not a shape
1341/// this reads.
1342const MAX_COMMAND_DEPTH: usize = 1;
1343
1344/// Collect every `*.md` command file under `root` (plus one level of
1345/// namespacing subdirectories) as a [`LoopSkill`] whose manifest is the
1346/// markdown file itself.
1347fn collect_command_root(
1348    scope: SkillScope,
1349    root: &Path,
1350    seen_names: &mut BTreeSet<String>,
1351    out: &mut Vec<LoopSkill>,
1352) {
1353    collect_command_dir(scope, root, None, 0, seen_names, out);
1354}
1355
1356fn collect_command_dir(
1357    scope: SkillScope,
1358    dir: &Path,
1359    qualifier: Option<&str>,
1360    depth: usize,
1361    seen_names: &mut BTreeSet<String>,
1362    out: &mut Vec<LoopSkill>,
1363) {
1364    let Ok(entries) = std::fs::read_dir(dir) else {
1365        return;
1366    };
1367    let mut files: Vec<PathBuf> = Vec::new();
1368    let mut dirs: Vec<PathBuf> = Vec::new();
1369    for entry in entries.flatten() {
1370        let path = entry.path();
1371        if path.is_dir() {
1372            dirs.push(path);
1373        } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
1374            files.push(path);
1375        }
1376    }
1377    files.sort();
1378    dirs.sort();
1379    for file in files {
1380        push_command_file(scope, &file, qualifier, seen_names, out);
1381    }
1382    if depth >= MAX_COMMAND_DEPTH {
1383        return;
1384    }
1385    for child in dirs {
1386        let Some(label) = child.file_name().and_then(|n| n.to_str()) else {
1387            continue;
1388        };
1389        if label.starts_with('.') {
1390            continue;
1391        }
1392        let label = label.to_string();
1393        collect_command_dir(scope, &child, Some(&label), depth + 1, seen_names, out);
1394    }
1395}
1396
1397/// One `.claude/commands/<name>.md` file as a loop skill: frontmatter
1398/// `name` (else the file stem), qualified `dir:name` inside a namespacing
1399/// subdirectory, body loaded on invocation exactly like a SKILL.md's.
1400fn push_command_file(
1401    scope: SkillScope,
1402    file: &Path,
1403    qualifier: Option<&str>,
1404    seen_names: &mut BTreeSet<String>,
1405    out: &mut Vec<LoopSkill>,
1406) {
1407    let Some(stem) = file.file_stem().and_then(|s| s.to_str()) else {
1408        return;
1409    };
1410    let front = read_frontmatter(file);
1411    let bare = front
1412        .get("name")
1413        .cloned()
1414        .unwrap_or_else(|| stem.to_string());
1415    let name = match qualifier {
1416        Some(prefix) => format!("{prefix}:{bare}"),
1417        None => bare,
1418    };
1419    if !seen_names.insert(name.clone()) {
1420        return;
1421    }
1422    out.push(LoopSkill {
1423        name,
1424        description: front.get("description").map(|d| one_line(d)),
1425        version: front.get("version").cloned(),
1426        scope,
1427        dir: file.parent().unwrap_or(file).to_path_buf(),
1428        manifest: file.to_path_buf(),
1429        model_invocable: frontmatter_enabled(&front).unwrap_or(true),
1430        allowed_tools: front
1431            .get("allowed-tools")
1432            .map(|v| frontmatter_list(v))
1433            .unwrap_or_default(),
1434        argument_names: front
1435            .get("arguments")
1436            .map(|v| frontmatter_list(v))
1437            .unwrap_or_default(),
1438        argument_hint: front.get("argument-hint").cloned(),
1439    });
1440}
1441
1442/// The loop's skill set for a resolved [`crate::Config`] — empty unless
1443/// `[core.skills] enabled` is on AND the config names a harness whose root
1444/// table to read, so a config that says nothing about skills discovers
1445/// nothing (byte-identical to the pre-BP-6 loop).
1446pub fn load_for_config(config: &crate::Config) -> Vec<LoopSkill> {
1447    if !config.skills_enabled {
1448        return Vec::new();
1449    }
1450    let Some(harness) = config.skills_harness.as_deref() else {
1451        return Vec::new();
1452    };
1453    load_loop_skills(
1454        harness,
1455        &SkillHomes::default(),
1456        &config.cwd,
1457        &config.skills_dirs,
1458    )
1459}
1460
1461/// A skill row becomes a loop skill unless it has no `SKILL.md` at all (a
1462/// bare grouping directory lists in ORCH-11's inventory, but there is
1463/// nothing to disclose), it duplicates a directory already taken, or its
1464/// name is already claimed by a higher-precedence root.
1465fn push_loop_skill(
1466    row: SkillRow,
1467    qualifier: Option<&str>,
1468    seen_names: &mut BTreeSet<String>,
1469    seen_dirs: &mut BTreeSet<PathBuf>,
1470    out: &mut Vec<LoopSkill>,
1471) {
1472    let manifest = row.location.join("SKILL.md");
1473    if !manifest.is_file() {
1474        return;
1475    }
1476    let name = match qualifier {
1477        Some(prefix) => format!("{prefix}:{}", row.name),
1478        None => row.name.clone(),
1479    };
1480    if !seen_dirs.insert(row.location.clone()) || !seen_names.insert(name.clone()) {
1481        return;
1482    }
1483    let front = read_frontmatter(&manifest);
1484    out.push(LoopSkill {
1485        name,
1486        description: row.description,
1487        version: row.version,
1488        scope: row.scope,
1489        dir: row.location,
1490        model_invocable: row.enabled.unwrap_or(true),
1491        allowed_tools: front
1492            .get("allowed-tools")
1493            .map(|v| frontmatter_list(v))
1494            .unwrap_or_default(),
1495        argument_names: front
1496            .get("arguments")
1497            .map(|v| frontmatter_list(v))
1498            .unwrap_or_default(),
1499        argument_hint: front.get("argument-hint").cloned(),
1500        manifest,
1501    });
1502}
1503
1504/// `<plugin>` for a Claude Code plugin root
1505/// (`.../plugins/cache/<marketplace>/<plugin>/<version>/skills`), so its
1506/// skills invoke as `plugin:skill` the way Claude Code namespaces them.
1507fn plugin_qualifier(scope: SkillScope, root: &Path) -> Option<String> {
1508    if scope != SkillScope::Plugin {
1509        return None;
1510    }
1511    root.parent()
1512        .and_then(Path::parent)
1513        .and_then(|dir| dir.file_name())
1514        .and_then(|name| name.to_str())
1515        .map(str::to_string)
1516}
1517
1518/// Nested `<subdir>/.claude/skills` roots BELOW `cwd`, each with the
1519/// subdirectory name that qualifies its skills (`dir:skill`, cc§7 "Skill
1520/// locations & precedence"). Bounded by [`MAX_NESTED_DEPTH`] and
1521/// [`MAX_NESTED_DIRS`] so this stays cheap in a large working tree.
1522fn nested_claude_roots(cwd: &Path) -> Vec<(String, PathBuf)> {
1523    let mut out = Vec::new();
1524    let mut visited = 0usize;
1525    let mut frontier: Vec<(String, PathBuf)> = child_dirs(cwd)
1526        .into_iter()
1527        .filter_map(|dir| nested_candidate(&dir))
1528        .collect();
1529    for _ in 0..MAX_NESTED_DEPTH {
1530        let mut next = Vec::new();
1531        for (label, dir) in frontier {
1532            visited += 1;
1533            if visited > MAX_NESTED_DIRS {
1534                return out;
1535            }
1536            let root = dir.join(".claude").join("skills");
1537            if root.is_dir() {
1538                out.push((label.clone(), root));
1539            }
1540            for child in child_dirs(&dir) {
1541                if let Some((_, child_dir)) = nested_candidate(&child) {
1542                    next.push((label.clone(), child_dir));
1543                }
1544            }
1545        }
1546        if next.is_empty() {
1547            break;
1548        }
1549        frontier = next;
1550    }
1551    out
1552}
1553
1554/// A directory the nested scan may descend into, with the label its skills
1555/// are qualified by (its own name).
1556fn nested_candidate(dir: &Path) -> Option<(String, PathBuf)> {
1557    let name = dir.file_name().and_then(|name| name.to_str())?;
1558    if name.starts_with('.') || SKIPPED_DIRS.contains(&name) {
1559        return None;
1560    }
1561    Some((name.to_string(), dir.to_path_buf()))
1562}
1563
1564#[cfg(test)]
1565mod tests {
1566    use super::*;
1567
1568    fn fixtures() -> PathBuf {
1569        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
1570    }
1571
1572    fn empty_homes(root: &Path) -> SkillHomes {
1573        let void = root.join("__absent__");
1574        SkillHomes {
1575            claude_code: void.clone(),
1576            codex: void.clone(),
1577            opencode: void.clone(),
1578            pi: void.clone(),
1579            hermes: void.clone(),
1580            openclaw: void.clone(),
1581            agents: void,
1582        }
1583    }
1584
1585    #[test]
1586    fn hermes_categories_flatten_and_frontmatter_wins() {
1587        let fixtures = fixtures();
1588        let mut homes = empty_homes(&fixtures);
1589        homes.hermes = fixtures.join("hermes_home");
1590        let rows = list_skills(&SkillsQuery {
1591            harness: Some(HarnessId::HERMES.into()),
1592            cwd: Some(fixtures.join("hermes_home")),
1593            homes,
1594            ..SkillsQuery::default()
1595        });
1596        let names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect();
1597        assert!(names.contains(&"arxiv-search"), "{names:?}");
1598        assert!(names.contains(&"bare-skill"), "{names:?}");
1599        let arxiv = rows.iter().find(|row| row.name == "arxiv-search").unwrap();
1600        assert_eq!(arxiv.version.as_deref(), Some("1.4.0"));
1601        assert_eq!(arxiv.scope, SkillScope::User);
1602        assert!(arxiv
1603            .description
1604            .as_deref()
1605            .unwrap_or_default()
1606            .contains("arXiv"));
1607        let bare = rows.iter().find(|row| row.name == "bare-skill").unwrap();
1608        assert_eq!(bare.description, None);
1609        assert_eq!(bare.enabled, None);
1610    }
1611
1612    #[test]
1613    fn openclaw_managed_root_is_read() {
1614        let fixtures = fixtures();
1615        let mut homes = empty_homes(&fixtures);
1616        homes.openclaw = fixtures.join("openclaw_home");
1617        let rows = list_skills(&SkillsQuery {
1618            harness: Some(HarnessId::OPENCLAW.into()),
1619            cwd: Some(fixtures.join("openclaw_home")),
1620            homes,
1621            ..SkillsQuery::default()
1622        });
1623        assert_eq!(rows.len(), 1, "{rows:?}");
1624        assert_eq!(rows[0].name, "clawhub-demo");
1625        assert_eq!(rows[0].scope, SkillScope::Managed);
1626        assert_eq!(rows[0].enabled, Some(false));
1627        assert_eq!(rows[0].version.as_deref(), Some("0.3.1"));
1628    }
1629
1630    #[test]
1631    fn scope_filter_selects_one_class() {
1632        let fixtures = fixtures();
1633        let mut homes = empty_homes(&fixtures);
1634        homes.hermes = fixtures.join("hermes_home");
1635        let base = SkillsQuery {
1636            harness: Some(HarnessId::HERMES.into()),
1637            cwd: Some(fixtures.join("hermes_home")),
1638            homes,
1639            ..SkillsQuery::default()
1640        };
1641        let managed = list_skills(&SkillsQuery {
1642            scope: Some(SkillScope::Managed),
1643            ..base.clone()
1644        });
1645        assert!(managed.is_empty(), "{managed:?}");
1646        let user = list_skills(&SkillsQuery {
1647            scope: Some(SkillScope::User),
1648            ..base
1649        });
1650        assert!(!user.is_empty());
1651        assert!(
1652            user.iter().all(|row| row.scope == SkillScope::User),
1653            "{user:?}"
1654        );
1655    }
1656
1657    /// The unified listing: one call, both fixture harnesses, rows carrying
1658    /// their own harness.
1659    #[test]
1660    fn one_listing_spans_harnesses() {
1661        let fixtures = fixtures();
1662        let mut homes = empty_homes(&fixtures);
1663        homes.hermes = fixtures.join("hermes_home");
1664        homes.openclaw = fixtures.join("openclaw_home");
1665        let rows = list_skills(&SkillsQuery {
1666            cwd: Some(fixtures.join("openclaw_home")),
1667            homes,
1668            ..SkillsQuery::default()
1669        });
1670        let harnesses: BTreeSet<&str> = rows.iter().map(|row| row.harness.as_str()).collect();
1671        assert!(harnesses.contains(HarnessId::HERMES), "{harnesses:?}");
1672        assert!(harnesses.contains(HarnessId::OPENCLAW), "{harnesses:?}");
1673    }
1674}