Skip to main content

supercode_harness/
skills_control.rs

1//! Controlled-tier skills (Domain 11, concept 15) — `install` and `remove`,
2//! each through the door the harness itself publishes.
3//!
4//! Charter (`docs/plans/orchestration-domain-11-2026-09-02.md` §0.4):
5//! supercode never runs a skill registry, never resolves a slug, never
6//! unpacks an archive. Every mutation is the harness's own door:
7//!
8//! * **Hermes** — `hermes skills install <identifier> --yes` /
9//!   `hermes skills uninstall <name> --yes`, with `HERMES_HOME` in the
10//!   environment. The pinned help (`parity/fixtures/hermes-help.txt`,
11//!   re-checked against the installed 0.21.0) says the positional is a
12//!   registry identifier or a direct HTTP(S) URL to a `SKILL.md`; hermes
13//!   publishes NO local-directory install form, so a local path is refused
14//!   rather than handed to a verb that cannot take it.
15//! * **OpenClaw** — `openclaw skills install <skill-ref>` (`@owner/slug`,
16//!   `git:<repo>`, or a local skill directory; `--global` for the shared
17//!   managed directory, `--as <slug>` to name it). At the pin there is NO
18//!   `openclaw skills remove`, so `remove` is refused with
19//!   `UnsupportedAction` — supercode does not delete files behind the
20//!   harness's back.
21//! * **Claude Code, Codex, opencode, pi** — the door IS the directory. These
22//!   four have no skills CLI at all; a skill is installed by placing its
23//!   package at the root the harness's own loader reads
24//!   (`crate::skills::writable_skill_roots`, transcribed from the same
25//!   inventories ORCH-11 reads), and removed by deleting that directory.
26//!   Both operations are confined to those roots: a name that would escape
27//!   one, or a package the loader does not recognize, is refused.
28//! * **supercode itself** — refused: it has no skills root of its own
29//!   (`crate::skills::SKILL_HARNESSES` names the six harnesses it reads).
30//!
31//! The three ORCH-18 rules are inherited verbatim:
32//!
33//! 1. **The harness's answer is the answer.** After the door succeeds the row
34//!    is re-read through the ORCH-11 loader ([`crate::skills::list_skills`])
35//!    and returned; a `remove` that leaves the row behind is a failure. Note
36//!    that `hermes skills install` exits 0 on a fetch failure, so the re-read
37//!    — not the exit status — is what decides.
38//! 2. **The door is narrated.** Every outcome carries `ran`: the harness
39//!    command that was executed, or the directory operation in its shell
40//!    spelling (`cp -R <source> <dest>`, `rm -r <dest>`).
41//! 3. **A door the harness does not have is refused**
42//!    ([`SkillControlError::Unsupported`] → `UnsupportedAction`), never a
43//!    silent no-op and never a file supercode writes on its own authority.
44
45use std::collections::BTreeSet;
46use std::path::{Path, PathBuf};
47
48use serde::{Deserialize, Serialize};
49
50use crate::jobs_control::{harness_program, shell_quote, HarnessCommand, JobControlError};
51use crate::skills::{
52    declared_skill_name, list_skills, skill_roots, writable_skill_roots, SkillHomes, SkillRow,
53    SkillScope, SkillsQuery, SKILL_HARNESSES,
54};
55use crate::HarnessId;
56
57/// Harnesses whose installed skills supercode can MUTATE through a door the
58/// harness publishes. Identical to [`SKILL_HARNESSES`] today: the two CLI
59/// harnesses have a verb, the core four have their loader's directory.
60pub const CONTROLLED_SKILL_HARNESSES: &[&str] = SKILL_HARNESSES;
61
62/// Why supercode refuses to install a skill into itself.
63pub const SUPERCODE_REFUSAL: &str =
64    "supercode has no skills root of its own: its skill surface is the SIX harnesses it reads \
65     (`skills.list`), so there is nothing here to install into. Name the harness whose root the \
66     package belongs in";
67
68/// Why `remove` is refused on OpenClaw at the pinned version.
69pub const OPENCLAW_REMOVE_REFUSAL: &str =
70    "openclaw 2026.7.1-2 publishes no `skills remove` verb (`openclaw skills` has \
71     search|install|update|verify|curator|workshop|list|info|check). supercode refuses rather \
72     than deleting files out of the harness's managed directory behind its back";
73
74/// One uniform mutating verb.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum SkillVerb {
78    /// Place a skill package where the harness's loader reads it.
79    Install,
80    /// Take a skill package back out.
81    Remove,
82}
83
84impl SkillVerb {
85    /// Uniform spelling used in the RPC method and in outcomes.
86    pub const fn as_str(self) -> &'static str {
87        match self {
88            Self::Install => "install",
89            Self::Remove => "remove",
90        }
91    }
92}
93
94/// One mutating request, in the uniform Domain 11 vocabulary.
95#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(default)]
97pub struct SkillMutation {
98    /// Harness whose root the package belongs in.
99    pub harness: String,
100    /// Skill name. Required by `remove`; on `install` it overrides the name
101    /// the package declares (`hermes --name`, `openclaw --as`, the directory
102    /// name for the core four).
103    pub name: Option<String>,
104    /// What to install: a local skill directory, or — where the harness's own
105    /// verb accepts one — its registry identifier / URL.
106    pub source: Option<String>,
107    /// Which root class to act in. `user` (the default) or `project`.
108    pub scope: Option<SkillScope>,
109    /// Working tree whose project roots are addressed. Defaults to the
110    /// process working directory, exactly as `skills.list` does.
111    pub cwd: Option<PathBuf>,
112    /// Config homes, so an isolated home is addressed the same way the read
113    /// side addresses it.
114    pub homes: SkillHomes,
115}
116
117/// What one mutation did, with the harness's own row read back afterwards.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct SkillMutationOutcome {
120    /// Harness that owns the root.
121    pub harness: String,
122    /// Uniform verb that was asked for.
123    pub verb: String,
124    /// The harness command, or the directory operation, that was performed.
125    pub ran: String,
126    /// Affected skill name.
127    pub name: String,
128    /// The skill as the ORCH-11 loader reports it AFTER the verb. Absent for
129    /// `remove`.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub skill: Option<SkillRow>,
132    /// `true` on a successful `remove`.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub removed: Option<bool>,
135}
136
137/// Why a mutation could not be performed.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum SkillControlError {
140    /// The harness has no door for what was asked (refused, never faked).
141    Unsupported(String),
142    /// The request itself is incoherent.
143    Invalid(String),
144    /// The door was opened and failed; the message carries what it said.
145    Failed(String),
146}
147
148impl std::fmt::Display for SkillControlError {
149    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        match self {
151            Self::Unsupported(message) | Self::Invalid(message) | Self::Failed(message) => {
152                formatter.write_str(message)
153            }
154        }
155    }
156}
157
158impl std::error::Error for SkillControlError {}
159
160impl From<JobControlError> for SkillControlError {
161    fn from(error: JobControlError) -> Self {
162        match error {
163            JobControlError::Unsupported(message) => Self::Unsupported(message),
164            JobControlError::Invalid(message) => Self::Invalid(message),
165            JobControlError::Failed(message) => Self::Failed(message),
166        }
167    }
168}
169
170type Result<T> = std::result::Result<T, SkillControlError>;
171
172/// Whether `harness` has any skills door supercode can drive.
173pub fn supports_skill_control(harness: &str) -> bool {
174    CONTROLLED_SKILL_HARNESSES.contains(&harness)
175}
176
177fn unsupported_harness(harness: &str) -> String {
178    if harness == HarnessId::SUPERCODE {
179        return SUPERCODE_REFUSAL.to_string();
180    }
181    format!(
182        "`{harness}` has no skills root supercode reads; skills verbs are supported for: {}",
183        CONTROLLED_SKILL_HARNESSES.join(", ")
184    )
185}
186
187/// Perform one mutation through the harness's own door, then re-read the row.
188pub fn mutate_skill(verb: SkillVerb, mutation: &SkillMutation) -> Result<SkillMutationOutcome> {
189    if !supports_skill_control(&mutation.harness) {
190        return Err(SkillControlError::Unsupported(unsupported_harness(
191            &mutation.harness,
192        )));
193    }
194    let scope = mutation.scope.unwrap_or(SkillScope::User);
195    if !matches!(scope, SkillScope::User | SkillScope::Project) {
196        return Err(SkillControlError::Invalid(format!(
197            "`{}` is a root the harness owns, not one a client may write; use user or project",
198            scope.as_str()
199        )));
200    }
201    match mutation.harness.as_str() {
202        HarnessId::HERMES => hermes(verb, mutation, scope),
203        HarnessId::OPENCLAW => openclaw(verb, mutation, scope),
204        _ => directory(verb, mutation, scope),
205    }
206}
207
208// ---------------------------------------------------------------------------
209// Shared: re-reading through the ORCH-11 loader
210// ---------------------------------------------------------------------------
211
212fn cwd_of(mutation: &SkillMutation) -> PathBuf {
213    mutation
214        .cwd
215        .clone()
216        .or_else(|| std::env::current_dir().ok())
217        .unwrap_or_else(|| PathBuf::from("."))
218}
219
220/// Every skill the harness's own loader reports right now.
221fn read_rows(mutation: &SkillMutation) -> Vec<SkillRow> {
222    list_skills(&SkillsQuery {
223        harness: Some(mutation.harness.clone()),
224        scope: None,
225        cwd: Some(cwd_of(mutation)),
226        homes: mutation.homes.clone(),
227    })
228}
229
230fn read_names(mutation: &SkillMutation) -> BTreeSet<String> {
231    read_rows(mutation)
232        .into_iter()
233        .map(|row| row.name)
234        .collect()
235}
236
237fn find_by_name(mutation: &SkillMutation, name: &str) -> Option<SkillRow> {
238    read_rows(mutation).into_iter().find(|row| row.name == name)
239}
240
241fn find_at(mutation: &SkillMutation, location: &Path) -> Option<SkillRow> {
242    read_rows(mutation)
243        .into_iter()
244        .find(|row| row.location == location)
245}
246
247fn require_source(mutation: &SkillMutation) -> Result<&str> {
248    mutation
249        .source
250        .as_deref()
251        .map(str::trim)
252        .filter(|value| !value.is_empty())
253        .ok_or_else(|| {
254            SkillControlError::Invalid(
255                "`skills.install` needs a `source`: a local skill directory, or the identifier \
256                 the harness's own install verb accepts"
257                    .into(),
258            )
259        })
260}
261
262fn require_name(mutation: &SkillMutation) -> Result<&str> {
263    mutation
264        .name
265        .as_deref()
266        .map(str::trim)
267        .filter(|value| !value.is_empty())
268        .ok_or_else(|| {
269            SkillControlError::Invalid("`skills.remove` needs the skill `name` to remove".into())
270        })
271}
272
273/// A skill name that can only ever address one directory INSIDE a root.
274///
275/// This is the containment gate for the directory door: the name comes from a
276/// package's own frontmatter, so `../…`, an absolute path, or a separator in
277/// it would otherwise write outside the root the harness reads.
278fn validate_name(name: &str) -> Result<&str> {
279    let trimmed = name.trim();
280    let rejected = trimmed.is_empty()
281        || trimmed == "."
282        || trimmed == ".."
283        || trimmed.starts_with('.')
284        || trimmed.contains('/')
285        || trimmed.contains('\\')
286        || trimmed.contains('\0')
287        || Path::new(trimmed).components().count() != 1;
288    if rejected {
289        return Err(SkillControlError::Invalid(format!(
290            "`{name}` is not a skill name: a skill is one directory inside the harness's own \
291             root, so a name may not be empty, hidden, or contain a path separator"
292        )));
293    }
294    Ok(trimmed)
295}
296
297// ---------------------------------------------------------------------------
298// Hermes — `hermes skills install | uninstall` over HERMES_HOME
299// ---------------------------------------------------------------------------
300
301/// Hermes's own argv for one verb, ready to run and ready to narrate.
302fn hermes_command(
303    verb: SkillVerb,
304    mutation: &SkillMutation,
305    scope: SkillScope,
306) -> Result<HarnessCommand> {
307    if scope != SkillScope::User {
308        return Err(SkillControlError::Unsupported(
309            "hermes keeps skills in one root per HERMES_HOME (`<HERMES_HOME>/skills`, and a \
310             profile IS a HERMES_HOME); it has no project-scoped skills root, so supercode \
311             refuses rather than inventing one"
312                .into(),
313        ));
314    }
315    let mut command = HarnessCommand::new(harness_program(HarnessId::HERMES)?);
316    command.env("HERMES_HOME", mutation.homes.hermes.to_string_lossy());
317    command.arg("skills");
318    match verb {
319        SkillVerb::Install => {
320            let source = require_source(mutation)?;
321            if Path::new(source).is_dir() {
322                return Err(SkillControlError::Unsupported(format!(
323                    "`hermes skills install` takes a registry identifier (`owner/repo/skills/x`) \
324                     or a direct HTTP(S) URL to a SKILL.md — its pinned help enumerates no \
325                     local-directory form, so `{source}` cannot be handed to it. Serve the \
326                     package's SKILL.md over HTTP, or install it into a harness whose door is the \
327                     directory"
328                )));
329            }
330            command.args(["install", "--yes"]);
331            if let Some(name) = trimmed_name(mutation) {
332                command.args(["--name", name]);
333            }
334            command.arg(source);
335        }
336        SkillVerb::Remove => {
337            command.args(["uninstall", require_name(mutation)?, "--yes"]);
338        }
339    }
340    Ok(command)
341}
342
343fn hermes(
344    verb: SkillVerb,
345    mutation: &SkillMutation,
346    scope: SkillScope,
347) -> Result<SkillMutationOutcome> {
348    let command = hermes_command(verb, mutation, scope)?;
349    run_and_reread(verb, mutation, command)
350}
351
352/// Run one CLI door, then let the ORCH-11 loader answer for what it did.
353///
354/// `hermes skills install` prints a failed fetch and still exits 0, so the
355/// harness's own root — never the exit status alone — decides.
356fn run_and_reread(
357    verb: SkillVerb,
358    mutation: &SkillMutation,
359    command: HarnessCommand,
360) -> Result<SkillMutationOutcome> {
361    let ran = command.narrate();
362    match verb {
363        SkillVerb::Install => {
364            let before = read_names(mutation);
365            command.run().map_err(SkillControlError::Failed)?;
366            let name = installed_name(mutation, &before, trimmed_name(mutation), &ran)?;
367            let skill = find_by_name(mutation, &name).ok_or_else(|| {
368                SkillControlError::Failed(format!(
369                    "`{ran}` exited 0 but {}'s skills roots hold no `{name}` afterwards",
370                    mutation.harness
371                ))
372            })?;
373            Ok(SkillMutationOutcome {
374                harness: mutation.harness.clone(),
375                verb: verb.as_str().to_string(),
376                ran,
377                name,
378                skill: Some(skill),
379                removed: None,
380            })
381        }
382        SkillVerb::Remove => {
383            let name = require_name(mutation)?.to_string();
384            command.run().map_err(SkillControlError::Failed)?;
385            refuse_if_still_present(mutation, &name, &ran)?;
386            Ok(SkillMutationOutcome {
387                harness: mutation.harness.clone(),
388                verb: verb.as_str().to_string(),
389                ran,
390                name,
391                skill: None,
392                removed: Some(true),
393            })
394        }
395    }
396}
397
398fn trimmed_name(mutation: &SkillMutation) -> Option<&str> {
399    mutation
400        .name
401        .as_deref()
402        .map(str::trim)
403        .filter(|name| !name.is_empty())
404}
405
406/// Which skill a CLI install verb actually landed: the name the harness's own
407/// root GAINED. A caller-supplied name decides between several.
408fn installed_name(
409    mutation: &SkillMutation,
410    before: &BTreeSet<String>,
411    requested: Option<&str>,
412    ran: &str,
413) -> Result<String> {
414    let after = read_names(mutation);
415    let mut fresh: Vec<String> = after.difference(before).cloned().collect();
416    if fresh.len() == 1 {
417        return Ok(fresh.remove(0));
418    }
419    if let Some(name) = requested {
420        if after.contains(name) {
421            return Ok(name.to_string());
422        }
423    }
424    Err(SkillControlError::Failed(format!(
425        "`{ran}` exited 0 but {}'s skills root gained {} skill(s), so the installed skill cannot \
426         be identified — pass `name` to say which one it should be",
427        mutation.harness,
428        fresh.len()
429    )))
430}
431
432fn refuse_if_still_present(mutation: &SkillMutation, name: &str, ran: &str) -> Result<()> {
433    match find_by_name(mutation, name) {
434        Some(row) => Err(SkillControlError::Failed(format!(
435            "`{ran}` reported success but `{name}` is still installed at {}",
436            row.location.display()
437        ))),
438        None => Ok(()),
439    }
440}
441
442// ---------------------------------------------------------------------------
443// OpenClaw — `openclaw skills install`; no remove verb at the pin
444// ---------------------------------------------------------------------------
445
446/// OpenClaw's own argv. `remove` never reaches the runner: the pin has no verb.
447fn openclaw_command(
448    verb: SkillVerb,
449    mutation: &SkillMutation,
450    scope: SkillScope,
451) -> Result<HarnessCommand> {
452    if matches!(verb, SkillVerb::Remove) {
453        return Err(SkillControlError::Unsupported(
454            OPENCLAW_REMOVE_REFUSAL.to_string(),
455        ));
456    }
457    let source = require_source(mutation)?;
458    let mut command = HarnessCommand::new(harness_program(HarnessId::OPENCLAW)?);
459    // The same environment contract the read side and `jobs_control` use:
460    // `OPENCLAW_STATE_DIR` names the state dir, `OPENCLAW_CONFIG_PATH` the
461    // config file inside it, so an isolated home stays isolated.
462    command.env(
463        "OPENCLAW_STATE_DIR",
464        mutation.homes.openclaw.to_string_lossy(),
465    );
466    command.env(
467        "OPENCLAW_CONFIG_PATH",
468        mutation
469            .homes
470            .openclaw
471            .join("openclaw.json")
472            .to_string_lossy(),
473    );
474    command.args(["skills", "install", source]);
475    // OpenClaw's own two destinations: the shared managed directory, or the
476    // agent workspace. `user` is the shared one; `project` is the workspace
477    // the CLI infers, which is also where the ORCH-11 loader reads it from.
478    if scope == SkillScope::User {
479        command.arg("--global");
480    }
481    if let Some(name) = trimmed_name(mutation) {
482        command.args(["--as", name]);
483    }
484    Ok(command)
485}
486
487fn openclaw(
488    verb: SkillVerb,
489    mutation: &SkillMutation,
490    scope: SkillScope,
491) -> Result<SkillMutationOutcome> {
492    let command = openclaw_command(verb, mutation, scope)?;
493    run_and_reread(verb, mutation, command)
494}
495
496// ---------------------------------------------------------------------------
497// Claude Code, Codex, opencode, pi — the door IS the directory
498// ---------------------------------------------------------------------------
499
500fn directory(
501    verb: SkillVerb,
502    mutation: &SkillMutation,
503    scope: SkillScope,
504) -> Result<SkillMutationOutcome> {
505    let cwd = cwd_of(mutation);
506    let roots = writable_skill_roots(&mutation.harness, scope, &mutation.homes, &cwd);
507    if roots.is_empty() {
508        return Err(SkillControlError::Unsupported(format!(
509            "`{}` has no {} skills root supercode may write; its inventory names none",
510            mutation.harness,
511            scope.as_str()
512        )));
513    }
514    match verb {
515        SkillVerb::Install => directory_install(mutation, scope, &roots),
516        SkillVerb::Remove => directory_remove(mutation, scope, &roots, &cwd),
517    }
518}
519
520fn directory_install(
521    mutation: &SkillMutation,
522    scope: SkillScope,
523    roots: &[PathBuf],
524) -> Result<SkillMutationOutcome> {
525    let source = PathBuf::from(require_source(mutation)?);
526    if !source.is_dir() {
527        return Err(SkillControlError::Invalid(format!(
528            "`{}` is not a directory: `{}`'s skills door is its loader's own root, so the source \
529             must be the skill PACKAGE — a directory holding SKILL.md",
530            source.display(),
531            mutation.harness
532        )));
533    }
534    let declared = declared_skill_name(&source).ok_or_else(|| {
535        SkillControlError::Invalid(format!(
536            "`{}` holds no SKILL.md, so it is not a skill package the harness's loader would \
537             read",
538            source.display()
539        ))
540    })?;
541    let requested = mutation
542        .name
543        .as_deref()
544        .map(str::trim)
545        .filter(|name| !name.is_empty())
546        .unwrap_or(declared.as_str());
547    let name = validate_name(requested)?.to_string();
548    let root = &roots[0];
549    let destination = root.join(&name);
550    if destination.exists() {
551        return Err(SkillControlError::Invalid(format!(
552            "`{name}` is already installed at {}; remove it first",
553            destination.display()
554        )));
555    }
556    std::fs::create_dir_all(root).map_err(|error| {
557        SkillControlError::Failed(format!(
558            "{}'s {} skills root {} could not be created: {error}",
559            mutation.harness,
560            scope.as_str(),
561            root.display()
562        ))
563    })?;
564    contained_in(&destination, std::slice::from_ref(root))?;
565    let ran = format!(
566        "cp -R {} {}",
567        shell_quote(&source.to_string_lossy()),
568        shell_quote(&destination.to_string_lossy())
569    );
570    if let Err(error) = copy_package(&source, &destination) {
571        // Never leave half a package where the loader would read it.
572        let _ = std::fs::remove_dir_all(&destination);
573        return Err(error);
574    }
575    let skill = find_at(mutation, &destination).ok_or_else(|| {
576        SkillControlError::Failed(format!(
577            "`{ran}` succeeded but {}'s loader does not report a skill at {}",
578            mutation.harness,
579            destination.display()
580        ))
581    })?;
582    Ok(SkillMutationOutcome {
583        harness: mutation.harness.clone(),
584        verb: SkillVerb::Install.as_str().to_string(),
585        ran,
586        name: skill.name.clone(),
587        skill: Some(skill),
588        removed: None,
589    })
590}
591
592fn directory_remove(
593    mutation: &SkillMutation,
594    scope: SkillScope,
595    writable: &[PathBuf],
596    cwd: &Path,
597) -> Result<SkillMutationOutcome> {
598    let name = validate_name(require_name(mutation)?)?.to_string();
599    let matches: Vec<SkillRow> = read_rows(mutation)
600        .into_iter()
601        .filter(|row| row.name == name && row.scope == scope)
602        .collect();
603    let row = match matches.len() {
604        0 => {
605            return Err(SkillControlError::Invalid(format!(
606                "`{}` has no {} skill `{name}`",
607                mutation.harness,
608                scope.as_str()
609            )))
610        }
611        1 => matches.into_iter().next().expect("one match"),
612        _ => {
613            return Err(SkillControlError::Invalid(format!(
614                "`{}` reports {} skills named `{name}` in its {} roots ({}); supercode refuses to \
615                 guess which one to delete",
616                mutation.harness,
617                matches.len(),
618                scope.as_str(),
619                matches
620                    .iter()
621                    .map(|row| row.location.display().to_string())
622                    .collect::<Vec<_>>()
623                    .join(", ")
624            )))
625        }
626    };
627    // Containment: the package must sit directly under a root this harness's
628    // own loader consults, writable or already-existing.
629    let mut recognized: Vec<PathBuf> = writable.to_vec();
630    recognized.extend(
631        skill_roots(&mutation.harness, &mutation.homes, cwd)
632            .into_iter()
633            .filter(|(found, _)| *found == scope)
634            .map(|(_, root)| root),
635    );
636    contained_in(&row.location, &recognized)?;
637    if !row.location.join("SKILL.md").is_file() {
638        return Err(SkillControlError::Invalid(format!(
639            "{} holds no SKILL.md; supercode removes skill PACKAGES, never a directory it cannot \
640             identify as one",
641            row.location.display()
642        )));
643    }
644    let ran = format!("rm -r {}", shell_quote(&row.location.to_string_lossy()));
645    std::fs::remove_dir_all(&row.location)
646        .map_err(|error| SkillControlError::Failed(format!("`{ran}` failed: {error}")))?;
647    refuse_if_still_present(mutation, &name, &ran)?;
648    Ok(SkillMutationOutcome {
649        harness: mutation.harness.clone(),
650        verb: SkillVerb::Remove.as_str().to_string(),
651        ran,
652        name,
653        skill: None,
654        removed: Some(true),
655    })
656}
657
658/// Refuse any path that is not a DIRECT child of one of `roots`.
659///
660/// Both sides are canonicalized as far as they exist, so a symlinked home
661/// (`/tmp` → `/private/tmp` on macOS) and a `..` inside the path are compared
662/// the same way the filesystem would resolve them.
663fn contained_in(path: &Path, roots: &[PathBuf]) -> Result<()> {
664    let resolved = resolve(path);
665    for root in roots {
666        let root = resolve(root);
667        if resolved.parent() == Some(root.as_path()) {
668            return Ok(());
669        }
670    }
671    Err(SkillControlError::Invalid(format!(
672        "{} is outside the skills roots supercode recognizes ({}); every install and removal \
673         stays inside the harness's own root",
674        path.display(),
675        roots
676            .iter()
677            .map(|root| root.display().to_string())
678            .collect::<Vec<_>>()
679            .join(", ")
680    )))
681}
682
683/// Canonicalize the longest existing prefix and re-attach the rest, so a
684/// destination that does not exist yet still compares against a real root.
685fn resolve(path: &Path) -> PathBuf {
686    if let Ok(canonical) = path.canonicalize() {
687        return canonical;
688    }
689    match (path.parent(), path.file_name()) {
690        (Some(parent), Some(name)) => resolve(parent).join(name),
691        _ => path.to_path_buf(),
692    }
693}
694
695/// Copy a skill package. Regular files and directories only: a symlink could
696/// point anywhere, so it is refused rather than followed or silently dropped.
697fn copy_package(source: &Path, destination: &Path) -> Result<()> {
698    std::fs::create_dir_all(destination).map_err(|error| {
699        SkillControlError::Failed(format!(
700            "{} could not be created: {error}",
701            destination.display()
702        ))
703    })?;
704    let entries = std::fs::read_dir(source).map_err(|error| {
705        SkillControlError::Failed(format!("{} could not be read: {error}", source.display()))
706    })?;
707    for entry in entries {
708        let entry = entry.map_err(|error| {
709            SkillControlError::Failed(format!("{} could not be read: {error}", source.display()))
710        })?;
711        let from = entry.path();
712        let kind = std::fs::symlink_metadata(&from).map_err(|error| {
713            SkillControlError::Failed(format!("{} could not be read: {error}", from.display()))
714        })?;
715        let to = destination.join(entry.file_name());
716        if kind.is_symlink() {
717            return Err(SkillControlError::Invalid(format!(
718                "{} is a symlink; supercode copies a skill package's own files only, so a link \
719                 that could point outside it is refused",
720                from.display()
721            )));
722        }
723        if kind.is_dir() {
724            copy_package(&from, &to)?;
725        } else if kind.is_file() {
726            std::fs::copy(&from, &to).map_err(|error| {
727                SkillControlError::Failed(format!(
728                    "{} could not be copied to {}: {error}",
729                    from.display(),
730                    to.display()
731                ))
732            })?;
733        } else {
734            return Err(SkillControlError::Invalid(format!(
735                "{} is neither a file nor a directory; a skill package holds only its own files",
736                from.display()
737            )));
738        }
739    }
740    Ok(())
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746
747    fn scratch(tag: &str) -> PathBuf {
748        let dir = std::env::temp_dir().join(format!(
749            "supercode-orch22-{tag}-{}-{}",
750            std::process::id(),
751            std::time::SystemTime::now()
752                .duration_since(std::time::UNIX_EPOCH)
753                .unwrap()
754                .as_nanos()
755        ));
756        std::fs::create_dir_all(&dir).unwrap();
757        dir
758    }
759
760    /// Every home pinned at an absent path, so a test can never reach a real
761    /// harness install.
762    fn homes(root: &Path) -> SkillHomes {
763        let void = root.join("__absent__");
764        SkillHomes {
765            claude_code: void.clone(),
766            codex: void.clone(),
767            opencode: void.clone(),
768            pi: void.clone(),
769            hermes: void.clone(),
770            openclaw: void.clone(),
771            agents: void,
772        }
773    }
774
775    fn write_package(root: &Path, dir_name: &str, front_name: &str) -> PathBuf {
776        let dir = root.join(dir_name);
777        std::fs::create_dir_all(&dir).unwrap();
778        std::fs::write(
779            dir.join("SKILL.md"),
780            format!("---\nname: {front_name}\ndescription: a probe skill\nversion: 0.1.0\n---\n\nbody\n"),
781        )
782        .unwrap();
783        dir
784    }
785
786    fn claude_mutation(root: &Path, cwd: &Path) -> SkillMutation {
787        let mut homes = homes(root);
788        homes.claude_code = root.join("claude_home");
789        SkillMutation {
790            harness: HarnessId::CLAUDE_CODE.into(),
791            cwd: Some(cwd.to_path_buf()),
792            homes,
793            ..SkillMutation::default()
794        }
795    }
796
797    #[test]
798    fn the_directory_door_installs_and_removes_in_the_user_root() {
799        let root = scratch("cc-user");
800        let cwd = root.join("tree");
801        std::fs::create_dir_all(&cwd).unwrap();
802        let source = write_package(&root, "probe-src", "orch22-probe");
803
804        let mut mutation = claude_mutation(&root, &cwd);
805        mutation.source = Some(source.to_string_lossy().into_owned());
806        let installed = mutate_skill(SkillVerb::Install, &mutation).unwrap();
807        assert_eq!(installed.name, "orch22-probe");
808        let expected = root.join("claude_home/skills/orch22-probe");
809        assert_eq!(
810            installed.ran,
811            format!("cp -R {} {}", source.display(), expected.display())
812        );
813        let row = installed.skill.expect("the loader's own row is returned");
814        assert_eq!(row.scope, SkillScope::User);
815        assert_eq!(row.location, expected);
816        assert_eq!(row.version.as_deref(), Some("0.1.0"));
817        assert!(expected.join("SKILL.md").is_file());
818
819        let mut removal = claude_mutation(&root, &cwd);
820        removal.name = Some("orch22-probe".into());
821        let removed = mutate_skill(SkillVerb::Remove, &removal).unwrap();
822        assert_eq!(removed.removed, Some(true));
823        assert_eq!(removed.ran, format!("rm -r {}", expected.display()));
824        assert!(!expected.exists());
825        std::fs::remove_dir_all(&root).ok();
826    }
827
828    #[test]
829    fn the_project_scope_writes_the_working_tree_root() {
830        let root = scratch("cc-project");
831        let cwd = root.join("tree");
832        std::fs::create_dir_all(&cwd).unwrap();
833        let source = write_package(&root, "probe-src", "tree-skill");
834
835        let mut mutation = claude_mutation(&root, &cwd);
836        mutation.source = Some(source.to_string_lossy().into_owned());
837        mutation.scope = Some(SkillScope::Project);
838        let installed = mutate_skill(SkillVerb::Install, &mutation).unwrap();
839        let row = installed.skill.expect("row");
840        assert_eq!(row.scope, SkillScope::Project);
841        assert_eq!(row.location, cwd.join(".claude/skills/tree-skill"));
842
843        let mut removal = claude_mutation(&root, &cwd);
844        removal.name = Some("tree-skill".into());
845        removal.scope = Some(SkillScope::Project);
846        assert_eq!(
847            mutate_skill(SkillVerb::Remove, &removal).unwrap().removed,
848            Some(true)
849        );
850        assert!(!cwd.join(".claude/skills/tree-skill").exists());
851        std::fs::remove_dir_all(&root).ok();
852    }
853
854    /// A package whose own frontmatter names a path is the escape this door
855    /// has to refuse — the name comes from data, not from the caller.
856    #[test]
857    fn a_name_that_escapes_the_root_is_refused() {
858        let root = scratch("escape");
859        let cwd = root.join("tree");
860        std::fs::create_dir_all(&cwd).unwrap();
861        let source = write_package(&root, "probe-src", "../../escaped");
862
863        let mut mutation = claude_mutation(&root, &cwd);
864        mutation.source = Some(source.to_string_lossy().into_owned());
865        let error = mutate_skill(SkillVerb::Install, &mutation).unwrap_err();
866        assert!(matches!(error, SkillControlError::Invalid(_)), "{error}");
867        assert!(error.to_string().contains("path separator"), "{error}");
868        assert!(!root.join("claude_home").exists());
869        std::fs::remove_dir_all(&root).ok();
870    }
871
872    #[test]
873    fn a_source_without_a_manifest_is_refused() {
874        let root = scratch("no-manifest");
875        let cwd = root.join("tree");
876        std::fs::create_dir_all(&cwd).unwrap();
877        let source = root.join("not-a-skill");
878        std::fs::create_dir_all(&source).unwrap();
879        std::fs::write(source.join("README.md"), "no frontmatter here").unwrap();
880
881        let mut mutation = claude_mutation(&root, &cwd);
882        mutation.source = Some(source.to_string_lossy().into_owned());
883        let error = mutate_skill(SkillVerb::Install, &mutation).unwrap_err();
884        assert!(matches!(error, SkillControlError::Invalid(_)), "{error}");
885        assert!(error.to_string().contains("SKILL.md"), "{error}");
886        std::fs::remove_dir_all(&root).ok();
887    }
888
889    #[test]
890    fn a_missing_source_directory_is_refused() {
891        let root = scratch("missing");
892        let cwd = root.join("tree");
893        std::fs::create_dir_all(&cwd).unwrap();
894        let mut mutation = claude_mutation(&root, &cwd);
895        mutation.source = Some(root.join("nowhere").to_string_lossy().into_owned());
896        let error = mutate_skill(SkillVerb::Install, &mutation).unwrap_err();
897        assert!(matches!(error, SkillControlError::Invalid(_)), "{error}");
898        assert!(error.to_string().contains("not a directory"), "{error}");
899        std::fs::remove_dir_all(&root).ok();
900    }
901
902    #[test]
903    fn removing_a_skill_the_loader_does_not_report_is_refused() {
904        let root = scratch("absent-row");
905        let cwd = root.join("tree");
906        std::fs::create_dir_all(&cwd).unwrap();
907        let mut removal = claude_mutation(&root, &cwd);
908        removal.name = Some("never-installed".into());
909        let error = mutate_skill(SkillVerb::Remove, &removal).unwrap_err();
910        assert!(matches!(error, SkillControlError::Invalid(_)), "{error}");
911        std::fs::remove_dir_all(&root).ok();
912    }
913
914    /// The pin's asymmetry, modeled rather than papered over.
915    #[test]
916    fn openclaw_refuses_remove_at_the_pin() {
917        let error = mutate_skill(
918            SkillVerb::Remove,
919            &SkillMutation {
920                harness: HarnessId::OPENCLAW.into(),
921                name: Some("clawhub-demo".into()),
922                ..SkillMutation::default()
923            },
924        )
925        .unwrap_err();
926        assert!(
927            matches!(error, SkillControlError::Unsupported(_)),
928            "{error}"
929        );
930        assert!(
931            error.to_string().contains("no `skills remove` verb"),
932            "{error}"
933        );
934    }
935
936    #[test]
937    fn supercode_has_no_skills_root_of_its_own() {
938        let error = mutate_skill(
939            SkillVerb::Install,
940            &SkillMutation {
941                harness: HarnessId::SUPERCODE.into(),
942                source: Some("/tmp/whatever".into()),
943                ..SkillMutation::default()
944            },
945        )
946        .unwrap_err();
947        assert!(
948            matches!(error, SkillControlError::Unsupported(_)),
949            "{error}"
950        );
951        assert!(error.to_string().contains("no skills root"), "{error}");
952    }
953
954    #[test]
955    fn hermes_refuses_a_local_directory_and_a_project_scope() {
956        let root = scratch("hermes-refusals");
957        let source = write_package(&root, "probe-src", "local-only");
958        let mut mutation = SkillMutation {
959            harness: HarnessId::HERMES.into(),
960            source: Some(source.to_string_lossy().into_owned()),
961            homes: homes(&root),
962            ..SkillMutation::default()
963        };
964        let error = mutate_skill(SkillVerb::Install, &mutation).unwrap_err();
965        assert!(
966            matches!(error, SkillControlError::Unsupported(_)),
967            "{error}"
968        );
969        assert!(error.to_string().contains("registry identifier"), "{error}");
970
971        mutation.scope = Some(SkillScope::Project);
972        let error = mutate_skill(SkillVerb::Install, &mutation).unwrap_err();
973        assert!(
974            matches!(error, SkillControlError::Unsupported(_)),
975            "{error}"
976        );
977        assert!(error.to_string().contains("project-scoped"), "{error}");
978        std::fs::remove_dir_all(&root).ok();
979    }
980
981    /// The narration is the harness's own argv, and HERMES_HOME points at the
982    /// home the caller addressed — never this machine's real one.
983    #[test]
984    fn hermes_translates_onto_its_own_verb() {
985        let root = scratch("hermes-argv");
986        let mut homes = homes(&root);
987        homes.hermes = root.join("hermes_home");
988        let mutation = SkillMutation {
989            harness: HarnessId::HERMES.into(),
990            name: Some("arxiv-search".into()),
991            source: Some("openai/skills/arxiv-search".into()),
992            homes,
993            ..SkillMutation::default()
994        };
995        let install = hermes_command(SkillVerb::Install, &mutation, SkillScope::User).unwrap();
996        assert_eq!(
997            install.narrate(),
998            "hermes skills install --yes --name arxiv-search openai/skills/arxiv-search"
999        );
1000        assert_eq!(
1001            install.env,
1002            vec![(
1003                "HERMES_HOME".to_string(),
1004                root.join("hermes_home").to_string_lossy().into_owned()
1005            )]
1006        );
1007        let remove = hermes_command(SkillVerb::Remove, &mutation, SkillScope::User).unwrap();
1008        assert_eq!(
1009            remove.narrate(),
1010            "hermes skills uninstall arxiv-search --yes"
1011        );
1012        std::fs::remove_dir_all(&root).ok();
1013    }
1014
1015    /// OpenClaw's install argv, with the isolated state dir it must never
1016    /// step outside of.
1017    #[test]
1018    fn openclaw_translates_onto_its_own_verb() {
1019        let root = scratch("openclaw-argv");
1020        let mut homes = homes(&root);
1021        homes.openclaw = root.join("openclaw_home");
1022        let mutation = SkillMutation {
1023            harness: HarnessId::OPENCLAW.into(),
1024            source: Some(root.join("probe-src").to_string_lossy().into_owned()),
1025            homes,
1026            ..SkillMutation::default()
1027        };
1028        let global = openclaw_command(SkillVerb::Install, &mutation, SkillScope::User).unwrap();
1029        assert_eq!(
1030            global.narrate(),
1031            format!(
1032                "openclaw skills install {} --global",
1033                root.join("probe-src").display()
1034            )
1035        );
1036        assert_eq!(
1037            global.env,
1038            vec![
1039                (
1040                    "OPENCLAW_STATE_DIR".to_string(),
1041                    root.join("openclaw_home").to_string_lossy().into_owned()
1042                ),
1043                (
1044                    "OPENCLAW_CONFIG_PATH".to_string(),
1045                    root.join("openclaw_home/openclaw.json")
1046                        .to_string_lossy()
1047                        .into_owned()
1048                ),
1049            ]
1050        );
1051        let workspace =
1052            openclaw_command(SkillVerb::Install, &mutation, SkillScope::Project).unwrap();
1053        assert!(!workspace.narrate().contains("--global"), "{workspace:?}");
1054        std::fs::remove_dir_all(&root).ok();
1055    }
1056}