1use 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
57pub const CONTROLLED_SKILL_HARNESSES: &[&str] = SKILL_HARNESSES;
61
62pub 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
68pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum SkillVerb {
78 Install,
80 Remove,
82}
83
84impl SkillVerb {
85 pub const fn as_str(self) -> &'static str {
87 match self {
88 Self::Install => "install",
89 Self::Remove => "remove",
90 }
91 }
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(default)]
97pub struct SkillMutation {
98 pub harness: String,
100 pub name: Option<String>,
104 pub source: Option<String>,
107 pub scope: Option<SkillScope>,
109 pub cwd: Option<PathBuf>,
112 pub homes: SkillHomes,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct SkillMutationOutcome {
120 pub harness: String,
122 pub verb: String,
124 pub ran: String,
126 pub name: String,
128 #[serde(skip_serializing_if = "Option::is_none")]
131 pub skill: Option<SkillRow>,
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub removed: Option<bool>,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum SkillControlError {
140 Unsupported(String),
142 Invalid(String),
144 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
172pub 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
187pub 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
208fn 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
220fn 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
273fn 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
297fn 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
352fn 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
406fn 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
442fn 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 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 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
496fn 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 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 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
658fn 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
683fn 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
695fn 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 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 #[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 #[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 #[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 #[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}