use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::jobs_control::{harness_program, shell_quote, HarnessCommand, JobControlError};
use crate::skills::{
declared_skill_name, list_skills, skill_roots, writable_skill_roots, SkillHomes, SkillRow,
SkillScope, SkillsQuery, SKILL_HARNESSES,
};
use crate::HarnessId;
pub const CONTROLLED_SKILL_HARNESSES: &[&str] = SKILL_HARNESSES;
pub const SUPERCODE_REFUSAL: &str =
"supercode has no skills root of its own: its skill surface is the SIX harnesses it reads \
(`skills.list`), so there is nothing here to install into. Name the harness whose root the \
package belongs in";
pub const OPENCLAW_REMOVE_REFUSAL: &str =
"openclaw 2026.7.1-2 publishes no `skills remove` verb (`openclaw skills` has \
search|install|update|verify|curator|workshop|list|info|check). supercode refuses rather \
than deleting files out of the harness's managed directory behind its back";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SkillVerb {
Install,
Remove,
}
impl SkillVerb {
pub const fn as_str(self) -> &'static str {
match self {
Self::Install => "install",
Self::Remove => "remove",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SkillMutation {
pub harness: String,
pub name: Option<String>,
pub source: Option<String>,
pub scope: Option<SkillScope>,
pub cwd: Option<PathBuf>,
pub homes: SkillHomes,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillMutationOutcome {
pub harness: String,
pub verb: String,
pub ran: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub skill: Option<SkillRow>,
#[serde(skip_serializing_if = "Option::is_none")]
pub removed: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillControlError {
Unsupported(String),
Invalid(String),
Failed(String),
}
impl std::fmt::Display for SkillControlError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unsupported(message) | Self::Invalid(message) | Self::Failed(message) => {
formatter.write_str(message)
}
}
}
}
impl std::error::Error for SkillControlError {}
impl From<JobControlError> for SkillControlError {
fn from(error: JobControlError) -> Self {
match error {
JobControlError::Unsupported(message) => Self::Unsupported(message),
JobControlError::Invalid(message) => Self::Invalid(message),
JobControlError::Failed(message) => Self::Failed(message),
}
}
}
type Result<T> = std::result::Result<T, SkillControlError>;
pub fn supports_skill_control(harness: &str) -> bool {
CONTROLLED_SKILL_HARNESSES.contains(&harness)
}
fn unsupported_harness(harness: &str) -> String {
if harness == HarnessId::SUPERCODE {
return SUPERCODE_REFUSAL.to_string();
}
format!(
"`{harness}` has no skills root supercode reads; skills verbs are supported for: {}",
CONTROLLED_SKILL_HARNESSES.join(", ")
)
}
pub fn mutate_skill(verb: SkillVerb, mutation: &SkillMutation) -> Result<SkillMutationOutcome> {
if !supports_skill_control(&mutation.harness) {
return Err(SkillControlError::Unsupported(unsupported_harness(
&mutation.harness,
)));
}
let scope = mutation.scope.unwrap_or(SkillScope::User);
if !matches!(scope, SkillScope::User | SkillScope::Project) {
return Err(SkillControlError::Invalid(format!(
"`{}` is a root the harness owns, not one a client may write; use user or project",
scope.as_str()
)));
}
match mutation.harness.as_str() {
HarnessId::HERMES => hermes(verb, mutation, scope),
HarnessId::OPENCLAW => openclaw(verb, mutation, scope),
_ => directory(verb, mutation, scope),
}
}
fn cwd_of(mutation: &SkillMutation) -> PathBuf {
mutation
.cwd
.clone()
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."))
}
fn read_rows(mutation: &SkillMutation) -> Vec<SkillRow> {
list_skills(&SkillsQuery {
harness: Some(mutation.harness.clone()),
scope: None,
cwd: Some(cwd_of(mutation)),
homes: mutation.homes.clone(),
})
}
fn read_names(mutation: &SkillMutation) -> BTreeSet<String> {
read_rows(mutation)
.into_iter()
.map(|row| row.name)
.collect()
}
fn find_by_name(mutation: &SkillMutation, name: &str) -> Option<SkillRow> {
read_rows(mutation).into_iter().find(|row| row.name == name)
}
fn find_at(mutation: &SkillMutation, location: &Path) -> Option<SkillRow> {
read_rows(mutation)
.into_iter()
.find(|row| row.location == location)
}
fn require_source(mutation: &SkillMutation) -> Result<&str> {
mutation
.source
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
SkillControlError::Invalid(
"`skills.install` needs a `source`: a local skill directory, or the identifier \
the harness's own install verb accepts"
.into(),
)
})
}
fn require_name(mutation: &SkillMutation) -> Result<&str> {
mutation
.name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
SkillControlError::Invalid("`skills.remove` needs the skill `name` to remove".into())
})
}
fn validate_name(name: &str) -> Result<&str> {
let trimmed = name.trim();
let rejected = trimmed.is_empty()
|| trimmed == "."
|| trimmed == ".."
|| trimmed.starts_with('.')
|| trimmed.contains('/')
|| trimmed.contains('\\')
|| trimmed.contains('\0')
|| Path::new(trimmed).components().count() != 1;
if rejected {
return Err(SkillControlError::Invalid(format!(
"`{name}` is not a skill name: a skill is one directory inside the harness's own \
root, so a name may not be empty, hidden, or contain a path separator"
)));
}
Ok(trimmed)
}
fn hermes_command(
verb: SkillVerb,
mutation: &SkillMutation,
scope: SkillScope,
) -> Result<HarnessCommand> {
if scope != SkillScope::User {
return Err(SkillControlError::Unsupported(
"hermes keeps skills in one root per HERMES_HOME (`<HERMES_HOME>/skills`, and a \
profile IS a HERMES_HOME); it has no project-scoped skills root, so supercode \
refuses rather than inventing one"
.into(),
));
}
let mut command = HarnessCommand::new(harness_program(HarnessId::HERMES)?);
command.env("HERMES_HOME", mutation.homes.hermes.to_string_lossy());
command.arg("skills");
match verb {
SkillVerb::Install => {
let source = require_source(mutation)?;
if Path::new(source).is_dir() {
return Err(SkillControlError::Unsupported(format!(
"`hermes skills install` takes a registry identifier (`owner/repo/skills/x`) \
or a direct HTTP(S) URL to a SKILL.md — its pinned help enumerates no \
local-directory form, so `{source}` cannot be handed to it. Serve the \
package's SKILL.md over HTTP, or install it into a harness whose door is the \
directory"
)));
}
command.args(["install", "--yes"]);
if let Some(name) = trimmed_name(mutation) {
command.args(["--name", name]);
}
command.arg(source);
}
SkillVerb::Remove => {
command.args(["uninstall", require_name(mutation)?, "--yes"]);
}
}
Ok(command)
}
fn hermes(
verb: SkillVerb,
mutation: &SkillMutation,
scope: SkillScope,
) -> Result<SkillMutationOutcome> {
let command = hermes_command(verb, mutation, scope)?;
run_and_reread(verb, mutation, command)
}
fn run_and_reread(
verb: SkillVerb,
mutation: &SkillMutation,
command: HarnessCommand,
) -> Result<SkillMutationOutcome> {
let ran = command.narrate();
match verb {
SkillVerb::Install => {
let before = read_names(mutation);
command.run().map_err(SkillControlError::Failed)?;
let name = installed_name(mutation, &before, trimmed_name(mutation), &ran)?;
let skill = find_by_name(mutation, &name).ok_or_else(|| {
SkillControlError::Failed(format!(
"`{ran}` exited 0 but {}'s skills roots hold no `{name}` afterwards",
mutation.harness
))
})?;
Ok(SkillMutationOutcome {
harness: mutation.harness.clone(),
verb: verb.as_str().to_string(),
ran,
name,
skill: Some(skill),
removed: None,
})
}
SkillVerb::Remove => {
let name = require_name(mutation)?.to_string();
command.run().map_err(SkillControlError::Failed)?;
refuse_if_still_present(mutation, &name, &ran)?;
Ok(SkillMutationOutcome {
harness: mutation.harness.clone(),
verb: verb.as_str().to_string(),
ran,
name,
skill: None,
removed: Some(true),
})
}
}
}
fn trimmed_name(mutation: &SkillMutation) -> Option<&str> {
mutation
.name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
}
fn installed_name(
mutation: &SkillMutation,
before: &BTreeSet<String>,
requested: Option<&str>,
ran: &str,
) -> Result<String> {
let after = read_names(mutation);
let mut fresh: Vec<String> = after.difference(before).cloned().collect();
if fresh.len() == 1 {
return Ok(fresh.remove(0));
}
if let Some(name) = requested {
if after.contains(name) {
return Ok(name.to_string());
}
}
Err(SkillControlError::Failed(format!(
"`{ran}` exited 0 but {}'s skills root gained {} skill(s), so the installed skill cannot \
be identified — pass `name` to say which one it should be",
mutation.harness,
fresh.len()
)))
}
fn refuse_if_still_present(mutation: &SkillMutation, name: &str, ran: &str) -> Result<()> {
match find_by_name(mutation, name) {
Some(row) => Err(SkillControlError::Failed(format!(
"`{ran}` reported success but `{name}` is still installed at {}",
row.location.display()
))),
None => Ok(()),
}
}
fn openclaw_command(
verb: SkillVerb,
mutation: &SkillMutation,
scope: SkillScope,
) -> Result<HarnessCommand> {
if matches!(verb, SkillVerb::Remove) {
return Err(SkillControlError::Unsupported(
OPENCLAW_REMOVE_REFUSAL.to_string(),
));
}
let source = require_source(mutation)?;
let mut command = HarnessCommand::new(harness_program(HarnessId::OPENCLAW)?);
command.env(
"OPENCLAW_STATE_DIR",
mutation.homes.openclaw.to_string_lossy(),
);
command.env(
"OPENCLAW_CONFIG_PATH",
mutation
.homes
.openclaw
.join("openclaw.json")
.to_string_lossy(),
);
command.args(["skills", "install", source]);
if scope == SkillScope::User {
command.arg("--global");
}
if let Some(name) = trimmed_name(mutation) {
command.args(["--as", name]);
}
Ok(command)
}
fn openclaw(
verb: SkillVerb,
mutation: &SkillMutation,
scope: SkillScope,
) -> Result<SkillMutationOutcome> {
let command = openclaw_command(verb, mutation, scope)?;
run_and_reread(verb, mutation, command)
}
fn directory(
verb: SkillVerb,
mutation: &SkillMutation,
scope: SkillScope,
) -> Result<SkillMutationOutcome> {
let cwd = cwd_of(mutation);
let roots = writable_skill_roots(&mutation.harness, scope, &mutation.homes, &cwd);
if roots.is_empty() {
return Err(SkillControlError::Unsupported(format!(
"`{}` has no {} skills root supercode may write; its inventory names none",
mutation.harness,
scope.as_str()
)));
}
match verb {
SkillVerb::Install => directory_install(mutation, scope, &roots),
SkillVerb::Remove => directory_remove(mutation, scope, &roots, &cwd),
}
}
fn directory_install(
mutation: &SkillMutation,
scope: SkillScope,
roots: &[PathBuf],
) -> Result<SkillMutationOutcome> {
let source = PathBuf::from(require_source(mutation)?);
if !source.is_dir() {
return Err(SkillControlError::Invalid(format!(
"`{}` is not a directory: `{}`'s skills door is its loader's own root, so the source \
must be the skill PACKAGE — a directory holding SKILL.md",
source.display(),
mutation.harness
)));
}
let declared = declared_skill_name(&source).ok_or_else(|| {
SkillControlError::Invalid(format!(
"`{}` holds no SKILL.md, so it is not a skill package the harness's loader would \
read",
source.display()
))
})?;
let requested = mutation
.name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
.unwrap_or(declared.as_str());
let name = validate_name(requested)?.to_string();
let root = &roots[0];
let destination = root.join(&name);
if destination.exists() {
return Err(SkillControlError::Invalid(format!(
"`{name}` is already installed at {}; remove it first",
destination.display()
)));
}
std::fs::create_dir_all(root).map_err(|error| {
SkillControlError::Failed(format!(
"{}'s {} skills root {} could not be created: {error}",
mutation.harness,
scope.as_str(),
root.display()
))
})?;
contained_in(&destination, std::slice::from_ref(root))?;
let ran = format!(
"cp -R {} {}",
shell_quote(&source.to_string_lossy()),
shell_quote(&destination.to_string_lossy())
);
if let Err(error) = copy_package(&source, &destination) {
let _ = std::fs::remove_dir_all(&destination);
return Err(error);
}
let skill = find_at(mutation, &destination).ok_or_else(|| {
SkillControlError::Failed(format!(
"`{ran}` succeeded but {}'s loader does not report a skill at {}",
mutation.harness,
destination.display()
))
})?;
Ok(SkillMutationOutcome {
harness: mutation.harness.clone(),
verb: SkillVerb::Install.as_str().to_string(),
ran,
name: skill.name.clone(),
skill: Some(skill),
removed: None,
})
}
fn directory_remove(
mutation: &SkillMutation,
scope: SkillScope,
writable: &[PathBuf],
cwd: &Path,
) -> Result<SkillMutationOutcome> {
let name = validate_name(require_name(mutation)?)?.to_string();
let matches: Vec<SkillRow> = read_rows(mutation)
.into_iter()
.filter(|row| row.name == name && row.scope == scope)
.collect();
let row = match matches.len() {
0 => {
return Err(SkillControlError::Invalid(format!(
"`{}` has no {} skill `{name}`",
mutation.harness,
scope.as_str()
)))
}
1 => matches.into_iter().next().expect("one match"),
_ => {
return Err(SkillControlError::Invalid(format!(
"`{}` reports {} skills named `{name}` in its {} roots ({}); supercode refuses to \
guess which one to delete",
mutation.harness,
matches.len(),
scope.as_str(),
matches
.iter()
.map(|row| row.location.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)))
}
};
let mut recognized: Vec<PathBuf> = writable.to_vec();
recognized.extend(
skill_roots(&mutation.harness, &mutation.homes, cwd)
.into_iter()
.filter(|(found, _)| *found == scope)
.map(|(_, root)| root),
);
contained_in(&row.location, &recognized)?;
if !row.location.join("SKILL.md").is_file() {
return Err(SkillControlError::Invalid(format!(
"{} holds no SKILL.md; supercode removes skill PACKAGES, never a directory it cannot \
identify as one",
row.location.display()
)));
}
let ran = format!("rm -r {}", shell_quote(&row.location.to_string_lossy()));
std::fs::remove_dir_all(&row.location)
.map_err(|error| SkillControlError::Failed(format!("`{ran}` failed: {error}")))?;
refuse_if_still_present(mutation, &name, &ran)?;
Ok(SkillMutationOutcome {
harness: mutation.harness.clone(),
verb: SkillVerb::Remove.as_str().to_string(),
ran,
name,
skill: None,
removed: Some(true),
})
}
fn contained_in(path: &Path, roots: &[PathBuf]) -> Result<()> {
let resolved = resolve(path);
for root in roots {
let root = resolve(root);
if resolved.parent() == Some(root.as_path()) {
return Ok(());
}
}
Err(SkillControlError::Invalid(format!(
"{} is outside the skills roots supercode recognizes ({}); every install and removal \
stays inside the harness's own root",
path.display(),
roots
.iter()
.map(|root| root.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)))
}
fn resolve(path: &Path) -> PathBuf {
if let Ok(canonical) = path.canonicalize() {
return canonical;
}
match (path.parent(), path.file_name()) {
(Some(parent), Some(name)) => resolve(parent).join(name),
_ => path.to_path_buf(),
}
}
fn copy_package(source: &Path, destination: &Path) -> Result<()> {
std::fs::create_dir_all(destination).map_err(|error| {
SkillControlError::Failed(format!(
"{} could not be created: {error}",
destination.display()
))
})?;
let entries = std::fs::read_dir(source).map_err(|error| {
SkillControlError::Failed(format!("{} could not be read: {error}", source.display()))
})?;
for entry in entries {
let entry = entry.map_err(|error| {
SkillControlError::Failed(format!("{} could not be read: {error}", source.display()))
})?;
let from = entry.path();
let kind = std::fs::symlink_metadata(&from).map_err(|error| {
SkillControlError::Failed(format!("{} could not be read: {error}", from.display()))
})?;
let to = destination.join(entry.file_name());
if kind.is_symlink() {
return Err(SkillControlError::Invalid(format!(
"{} is a symlink; supercode copies a skill package's own files only, so a link \
that could point outside it is refused",
from.display()
)));
}
if kind.is_dir() {
copy_package(&from, &to)?;
} else if kind.is_file() {
std::fs::copy(&from, &to).map_err(|error| {
SkillControlError::Failed(format!(
"{} could not be copied to {}: {error}",
from.display(),
to.display()
))
})?;
} else {
return Err(SkillControlError::Invalid(format!(
"{} is neither a file nor a directory; a skill package holds only its own files",
from.display()
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"supercode-orch22-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn homes(root: &Path) -> SkillHomes {
let void = root.join("__absent__");
SkillHomes {
claude_code: void.clone(),
codex: void.clone(),
opencode: void.clone(),
pi: void.clone(),
hermes: void.clone(),
openclaw: void.clone(),
agents: void,
}
}
fn write_package(root: &Path, dir_name: &str, front_name: &str) -> PathBuf {
let dir = root.join(dir_name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("SKILL.md"),
format!("---\nname: {front_name}\ndescription: a probe skill\nversion: 0.1.0\n---\n\nbody\n"),
)
.unwrap();
dir
}
fn claude_mutation(root: &Path, cwd: &Path) -> SkillMutation {
let mut homes = homes(root);
homes.claude_code = root.join("claude_home");
SkillMutation {
harness: HarnessId::CLAUDE_CODE.into(),
cwd: Some(cwd.to_path_buf()),
homes,
..SkillMutation::default()
}
}
#[test]
fn the_directory_door_installs_and_removes_in_the_user_root() {
let root = scratch("cc-user");
let cwd = root.join("tree");
std::fs::create_dir_all(&cwd).unwrap();
let source = write_package(&root, "probe-src", "orch22-probe");
let mut mutation = claude_mutation(&root, &cwd);
mutation.source = Some(source.to_string_lossy().into_owned());
let installed = mutate_skill(SkillVerb::Install, &mutation).unwrap();
assert_eq!(installed.name, "orch22-probe");
let expected = root.join("claude_home/skills/orch22-probe");
assert_eq!(
installed.ran,
format!("cp -R {} {}", source.display(), expected.display())
);
let row = installed.skill.expect("the loader's own row is returned");
assert_eq!(row.scope, SkillScope::User);
assert_eq!(row.location, expected);
assert_eq!(row.version.as_deref(), Some("0.1.0"));
assert!(expected.join("SKILL.md").is_file());
let mut removal = claude_mutation(&root, &cwd);
removal.name = Some("orch22-probe".into());
let removed = mutate_skill(SkillVerb::Remove, &removal).unwrap();
assert_eq!(removed.removed, Some(true));
assert_eq!(removed.ran, format!("rm -r {}", expected.display()));
assert!(!expected.exists());
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn the_project_scope_writes_the_working_tree_root() {
let root = scratch("cc-project");
let cwd = root.join("tree");
std::fs::create_dir_all(&cwd).unwrap();
let source = write_package(&root, "probe-src", "tree-skill");
let mut mutation = claude_mutation(&root, &cwd);
mutation.source = Some(source.to_string_lossy().into_owned());
mutation.scope = Some(SkillScope::Project);
let installed = mutate_skill(SkillVerb::Install, &mutation).unwrap();
let row = installed.skill.expect("row");
assert_eq!(row.scope, SkillScope::Project);
assert_eq!(row.location, cwd.join(".claude/skills/tree-skill"));
let mut removal = claude_mutation(&root, &cwd);
removal.name = Some("tree-skill".into());
removal.scope = Some(SkillScope::Project);
assert_eq!(
mutate_skill(SkillVerb::Remove, &removal).unwrap().removed,
Some(true)
);
assert!(!cwd.join(".claude/skills/tree-skill").exists());
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_name_that_escapes_the_root_is_refused() {
let root = scratch("escape");
let cwd = root.join("tree");
std::fs::create_dir_all(&cwd).unwrap();
let source = write_package(&root, "probe-src", "../../escaped");
let mut mutation = claude_mutation(&root, &cwd);
mutation.source = Some(source.to_string_lossy().into_owned());
let error = mutate_skill(SkillVerb::Install, &mutation).unwrap_err();
assert!(matches!(error, SkillControlError::Invalid(_)), "{error}");
assert!(error.to_string().contains("path separator"), "{error}");
assert!(!root.join("claude_home").exists());
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_source_without_a_manifest_is_refused() {
let root = scratch("no-manifest");
let cwd = root.join("tree");
std::fs::create_dir_all(&cwd).unwrap();
let source = root.join("not-a-skill");
std::fs::create_dir_all(&source).unwrap();
std::fs::write(source.join("README.md"), "no frontmatter here").unwrap();
let mut mutation = claude_mutation(&root, &cwd);
mutation.source = Some(source.to_string_lossy().into_owned());
let error = mutate_skill(SkillVerb::Install, &mutation).unwrap_err();
assert!(matches!(error, SkillControlError::Invalid(_)), "{error}");
assert!(error.to_string().contains("SKILL.md"), "{error}");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_missing_source_directory_is_refused() {
let root = scratch("missing");
let cwd = root.join("tree");
std::fs::create_dir_all(&cwd).unwrap();
let mut mutation = claude_mutation(&root, &cwd);
mutation.source = Some(root.join("nowhere").to_string_lossy().into_owned());
let error = mutate_skill(SkillVerb::Install, &mutation).unwrap_err();
assert!(matches!(error, SkillControlError::Invalid(_)), "{error}");
assert!(error.to_string().contains("not a directory"), "{error}");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn removing_a_skill_the_loader_does_not_report_is_refused() {
let root = scratch("absent-row");
let cwd = root.join("tree");
std::fs::create_dir_all(&cwd).unwrap();
let mut removal = claude_mutation(&root, &cwd);
removal.name = Some("never-installed".into());
let error = mutate_skill(SkillVerb::Remove, &removal).unwrap_err();
assert!(matches!(error, SkillControlError::Invalid(_)), "{error}");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn openclaw_refuses_remove_at_the_pin() {
let error = mutate_skill(
SkillVerb::Remove,
&SkillMutation {
harness: HarnessId::OPENCLAW.into(),
name: Some("clawhub-demo".into()),
..SkillMutation::default()
},
)
.unwrap_err();
assert!(
matches!(error, SkillControlError::Unsupported(_)),
"{error}"
);
assert!(
error.to_string().contains("no `skills remove` verb"),
"{error}"
);
}
#[test]
fn supercode_has_no_skills_root_of_its_own() {
let error = mutate_skill(
SkillVerb::Install,
&SkillMutation {
harness: HarnessId::SUPERCODE.into(),
source: Some("/tmp/whatever".into()),
..SkillMutation::default()
},
)
.unwrap_err();
assert!(
matches!(error, SkillControlError::Unsupported(_)),
"{error}"
);
assert!(error.to_string().contains("no skills root"), "{error}");
}
#[test]
fn hermes_refuses_a_local_directory_and_a_project_scope() {
let root = scratch("hermes-refusals");
let source = write_package(&root, "probe-src", "local-only");
let mut mutation = SkillMutation {
harness: HarnessId::HERMES.into(),
source: Some(source.to_string_lossy().into_owned()),
homes: homes(&root),
..SkillMutation::default()
};
let error = mutate_skill(SkillVerb::Install, &mutation).unwrap_err();
assert!(
matches!(error, SkillControlError::Unsupported(_)),
"{error}"
);
assert!(error.to_string().contains("registry identifier"), "{error}");
mutation.scope = Some(SkillScope::Project);
let error = mutate_skill(SkillVerb::Install, &mutation).unwrap_err();
assert!(
matches!(error, SkillControlError::Unsupported(_)),
"{error}"
);
assert!(error.to_string().contains("project-scoped"), "{error}");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn hermes_translates_onto_its_own_verb() {
let root = scratch("hermes-argv");
let mut homes = homes(&root);
homes.hermes = root.join("hermes_home");
let mutation = SkillMutation {
harness: HarnessId::HERMES.into(),
name: Some("arxiv-search".into()),
source: Some("openai/skills/arxiv-search".into()),
homes,
..SkillMutation::default()
};
let install = hermes_command(SkillVerb::Install, &mutation, SkillScope::User).unwrap();
assert_eq!(
install.narrate(),
"hermes skills install --yes --name arxiv-search openai/skills/arxiv-search"
);
assert_eq!(
install.env,
vec![(
"HERMES_HOME".to_string(),
root.join("hermes_home").to_string_lossy().into_owned()
)]
);
let remove = hermes_command(SkillVerb::Remove, &mutation, SkillScope::User).unwrap();
assert_eq!(
remove.narrate(),
"hermes skills uninstall arxiv-search --yes"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn openclaw_translates_onto_its_own_verb() {
let root = scratch("openclaw-argv");
let mut homes = homes(&root);
homes.openclaw = root.join("openclaw_home");
let mutation = SkillMutation {
harness: HarnessId::OPENCLAW.into(),
source: Some(root.join("probe-src").to_string_lossy().into_owned()),
homes,
..SkillMutation::default()
};
let global = openclaw_command(SkillVerb::Install, &mutation, SkillScope::User).unwrap();
assert_eq!(
global.narrate(),
format!(
"openclaw skills install {} --global",
root.join("probe-src").display()
)
);
assert_eq!(
global.env,
vec![
(
"OPENCLAW_STATE_DIR".to_string(),
root.join("openclaw_home").to_string_lossy().into_owned()
),
(
"OPENCLAW_CONFIG_PATH".to_string(),
root.join("openclaw_home/openclaw.json")
.to_string_lossy()
.into_owned()
),
]
);
let workspace =
openclaw_command(SkillVerb::Install, &mutation, SkillScope::Project).unwrap();
assert!(!workspace.narrate().contains("--global"), "{workspace:?}");
std::fs::remove_dir_all(&root).ok();
}
}