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