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 Volter Harness 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 Volter Harness 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(())
}