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