1use serde::Serialize;
13use std::path::{Path, PathBuf};
14
15const SKILL_FILE_NAME: &str = "SKILL.md";
16const FNV1A64_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
17const FNV1A64_PRIME: u64 = 0x0000_0100_0000_01b3;
18
19#[derive(Clone, Copy, Debug)]
24pub struct SkillAsset<'a> {
25 pub path: &'a str,
29 pub contents: &'a str,
31}
32
33#[derive(Clone, Copy, Debug)]
41pub struct SkillSpec<'a> {
42 pub name: &'a str,
44 pub source: &'a str,
46 pub title: &'a str,
48 pub marker_slug: &'a str,
50 pub assets: &'a [SkillAsset<'a>],
54}
55
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum SkillAgentSelection {
59 All,
61 Codex,
63 ClaudeCode,
65 Opencode,
67 Hermes,
69}
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
73#[serde(rename_all = "kebab-case")]
74pub enum SkillAgent {
75 Codex,
77 ClaudeCode,
79 Opencode,
81 Hermes,
83}
84
85#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
87#[serde(rename_all = "lowercase")]
88pub enum SkillScope {
89 Personal,
91 Workspace,
93}
94
95#[derive(Clone, Debug)]
97pub struct SkillOptions {
98 pub agent: SkillAgentSelection,
100 pub scope: SkillScope,
102 pub skills_dir: Option<String>,
104 pub force: bool,
106}
107
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub enum SkillAction {
111 Status,
113 Install,
115 Uninstall,
117}
118
119#[derive(Clone, Debug, Serialize)]
121pub struct SkillTargetStatus {
122 pub agent: SkillAgent,
124 pub scope: SkillScope,
126 pub skills_dir: PathBuf,
128 pub skill_dir: PathBuf,
130 pub skill_path: PathBuf,
132 pub installed: bool,
134 pub managed: bool,
136 pub valid: bool,
138 pub current: bool,
140 pub validation_error: Option<String>,
142}
143
144#[derive(Clone, Debug, Serialize)]
146pub struct SkillUninstallStatus {
147 pub agent: SkillAgent,
149 pub scope: SkillScope,
151 pub skills_dir: PathBuf,
153 pub skill_dir: PathBuf,
155 pub skill_path: PathBuf,
157 pub removed: bool,
159 pub assets_removed: Vec<String>,
163 pub directory_retained: bool,
168}
169
170#[derive(Clone, Debug, Serialize)]
173#[serde(tag = "code")]
174pub enum SkillReport {
175 #[serde(rename = "skill_status")]
177 Status {
178 skill: String,
180 installed_all: bool,
182 valid_all: bool,
184 current_all: bool,
186 targets: Vec<SkillTargetStatus>,
188 },
189 #[serde(rename = "skill_install")]
191 Install {
192 skill: String,
194 installed: bool,
196 targets: Vec<SkillTargetStatus>,
198 hint: &'static str,
200 },
201 #[serde(rename = "skill_uninstall")]
203 Uninstall {
204 skill: String,
206 removed_any: bool,
208 targets: Vec<SkillUninstallStatus>,
210 },
211}
212
213#[derive(Clone, Copy, Debug, PartialEq, Eq)]
216pub enum SkillErrorKind {
217 Usage,
220 InvalidSpec,
223 Refused,
227 Io,
229}
230
231impl SkillErrorKind {
232 #[must_use]
234 pub fn code(self) -> &'static str {
235 match self {
236 Self::Usage => "skill_usage_error",
237 Self::InvalidSpec => "skill_spec_invalid",
238 Self::Refused => "skill_refused",
239 Self::Io => "skill_io_failed",
240 }
241 }
242}
243
244#[derive(Clone, Debug)]
246pub struct SkillError {
247 pub kind: SkillErrorKind,
249 pub message: String,
251 pub hint: Option<String>,
253 pub partial_report: Option<Box<SkillReport>>,
255}
256
257impl std::fmt::Display for SkillError {
258 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259 formatter.write_str(&self.message)
260 }
261}
262
263impl std::error::Error for SkillError {}
264
265impl SkillError {
266 fn new(kind: SkillErrorKind, message: String, hint: Option<String>) -> Self {
267 Self {
268 kind,
269 message,
270 hint,
271 partial_report: None,
272 }
273 }
274
275 fn io(action: &str, err: std::io::Error) -> Self {
276 Self {
277 kind: SkillErrorKind::Io,
278 message: format!("{action} failed: {err}"),
279 hint: None,
280 partial_report: None,
281 }
282 }
283
284 fn write_failed(action: &str, err: crate::atomic_file::AtomicError) -> Self {
286 let hint = err.commit_uncertain().then(|| {
291 "the file is installed but its durability is unconfirmed; re-run to be sure".to_string()
292 });
293 Self {
294 kind: SkillErrorKind::Io,
295 message: format!("{action} failed: {err}"),
296 hint,
297 partial_report: None,
298 }
299 }
300
301 fn with_partial_report(mut self, report: SkillReport) -> Self {
302 self.partial_report = Some(Box::new(report));
303 self
304 }
305}
306
307pub fn run_skill_admin(
312 spec: &SkillSpec,
313 action: SkillAction,
314 options: &SkillOptions,
315) -> Result<SkillReport, SkillError> {
316 validate_spec(spec)?;
317 match action {
318 SkillAction::Status => status(spec, options),
319 SkillAction::Install => install(spec, options),
320 SkillAction::Uninstall => uninstall(spec, options),
321 }
322}
323
324fn status(spec: &SkillSpec, options: &SkillOptions) -> Result<SkillReport, SkillError> {
325 let targets = resolve_targets(spec, options)?;
326 let mut statuses = Vec::with_capacity(targets.len());
327 for target in &targets {
328 statuses.push(target_status(spec, target)?);
329 }
330 Ok(SkillReport::Status {
331 skill: spec.name.to_string(),
332 installed_all: statuses.iter().all(|s| s.installed),
333 valid_all: statuses.iter().all(|s| s.valid),
334 current_all: statuses.iter().all(|s| s.current),
335 targets: statuses,
336 })
337}
338
339fn install(spec: &SkillSpec, options: &SkillOptions) -> Result<SkillReport, SkillError> {
340 validate_skill_text(spec, spec.source, SkillErrorKind::InvalidSpec)?;
341 for asset in spec.assets {
342 validate_asset_path(asset.path)?;
343 }
344 let targets = resolve_targets(spec, options)?;
345 let content = managed_skill_contents(spec);
346 preflight_install_targets(spec, options, &targets)?;
347 for target in &targets {
348 if let Err(err) = std::fs::create_dir_all(&target.skill_dir)
349 .map_err(|e| SkillError::io("create skill dir", e))
350 {
351 return Err(err.with_partial_report(install_report_lossy(spec, &targets, false)));
352 }
353 }
354 install_targets(spec, &targets, &content)
355}
356
357fn preflight_install_targets(
358 spec: &SkillSpec,
359 options: &SkillOptions,
360 targets: &[SkillTarget],
361) -> Result<(), SkillError> {
362 let mut failures = Vec::new();
363 let mut escaped = false;
364 for target in targets {
365 if let Err(err) = containment_failure(spec, target) {
368 failures.push(err);
369 escaped = true;
370 continue;
371 }
372 let skill_dir_managed = skill_path_file_type(&target.skill_path)?
378 .is_some_and(|kind| kind.is_file())
379 && is_managed_or_bundled_skill(spec, &target.skill_path)?;
380 for asset in spec.assets {
381 let dest = asset_target_path(&target.skill_dir, asset.path);
382 let Some(kind) = skill_path_file_type(&dest)? else {
383 continue;
384 };
385 if !kind.is_file() && !kind.is_symlink() {
386 failures.push(format!(
387 "refusing to overwrite non-regular skill asset at {}",
388 dest.display()
389 ));
390 } else if !skill_dir_managed
391 && !options.force
392 && (kind.is_symlink()
393 || std::fs::read(&dest).map_err(|e| SkillError::io("read skill asset", e))?
394 != asset.contents.as_bytes())
395 {
396 failures.push(format!(
397 "refusing to overwrite unmanaged skill asset at {}",
398 dest.display()
399 ));
400 }
401 }
402 if let Some(kind) = skill_path_file_type(&target.skill_path)? {
403 if kind.is_symlink() {
404 if !options.force {
405 failures.push(format!(
406 "refusing to overwrite symlinked skill at {}",
407 target.skill_path.display()
408 ));
409 }
410 continue;
411 }
412 if !kind.is_file() {
413 failures.push(format!(
414 "refusing to overwrite non-regular skill at {}",
415 target.skill_path.display()
416 ));
417 continue;
418 }
419 if !is_managed_or_bundled_skill(spec, &target.skill_path)? && !options.force {
420 failures.push(format!(
421 "refusing to overwrite unmanaged skill at {}",
422 target.skill_path.display()
423 ));
424 }
425 }
426 }
427 if failures.is_empty() {
428 return Ok(());
429 }
430 let hint = if escaped {
434 "--force does not permit writing outside the skills directory"
435 } else {
436 "pass --force to replace unmanaged files or symlinks"
437 };
438 Err(SkillError::new(
439 SkillErrorKind::Refused,
440 failures.join("; "),
441 Some(hint.to_string()),
442 )
443 .with_partial_report(install_report_lossy(spec, targets, false)))
444}
445
446fn install_targets(
447 spec: &SkillSpec,
448 targets: &[SkillTarget],
449 content: &str,
450) -> Result<SkillReport, SkillError> {
451 let mut installed = Vec::with_capacity(targets.len());
452 for target in targets {
453 if let Err(err) = write_skill_atomic(target, content) {
454 return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
455 }
456 if let Err(err) = install_target_assets(spec, target) {
457 return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
458 }
459 if let Err(err) = validate_installed_skill(spec, &target.skill_path) {
460 return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
461 }
462 match target_status(spec, target) {
463 Ok(status) => installed.push(status),
464 Err(err) => {
465 return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
466 }
467 }
468 }
469 Ok(SkillReport::Install {
470 skill: spec.name.to_string(),
471 installed: true,
472 targets: installed,
473 hint: "restart the agent so it reloads installed skills",
474 })
475}
476
477fn uninstall(spec: &SkillSpec, options: &SkillOptions) -> Result<SkillReport, SkillError> {
478 let targets = resolve_targets(spec, options)?;
479 preflight_uninstall_targets(spec, options, &targets)?;
480 let mut removed = Vec::with_capacity(targets.len());
481 for target in &targets {
482 let Some(kind) = skill_path_file_type(&target.skill_path)? else {
483 removed.push(target_uninstall_status(target, false));
484 continue;
485 };
486 if !kind.is_file() && !kind.is_symlink() {
487 let err = SkillError::new(
488 SkillErrorKind::Refused,
489 format!(
490 "refusing to remove non-regular skill at {}",
491 target.skill_path.display()
492 ),
493 None,
494 );
495 return Err(err.with_partial_report(uninstall_report_lossy(spec, &targets, &removed)));
496 }
497 if let Err(err) = ensure_no_symlinked_dirs(&target.skills_dir, &target.skill_path) {
498 return Err(err.with_partial_report(uninstall_report_lossy(spec, &targets, &removed)));
499 }
500 let assets_removed = match remove_target_assets(spec, target) {
506 Ok(assets_removed) => assets_removed,
507 Err(err) => {
508 return Err(
509 err.with_partial_report(uninstall_report_lossy(spec, &targets, &removed))
510 );
511 }
512 };
513 if let Err(err) =
514 std::fs::remove_file(&target.skill_path).map_err(|e| SkillError::io("remove skill", e))
515 {
516 return Err(err.with_partial_report(uninstall_report_lossy(spec, &targets, &removed)));
517 }
518 let _ = std::fs::remove_dir(&target.skill_dir);
519 removed.push(SkillUninstallStatus {
520 assets_removed,
521 ..target_uninstall_status(target, true)
522 });
523 }
524 Ok(SkillReport::Uninstall {
525 skill: spec.name.to_string(),
526 removed_any: removed.iter().any(|s| s.removed),
527 targets: removed,
528 })
529}
530
531fn preflight_uninstall_targets(
532 spec: &SkillSpec,
533 options: &SkillOptions,
534 targets: &[SkillTarget],
535) -> Result<(), SkillError> {
536 let mut failures = Vec::new();
537 let mut escaped = false;
538 for target in targets {
539 if let Err(err) = containment_failure(spec, target) {
540 failures.push(err);
541 escaped = true;
542 continue;
543 }
544 for asset in spec.assets {
548 let dest = asset_target_path(&target.skill_dir, asset.path);
549 if let Some(kind) = skill_path_file_type(&dest)?
550 && kind.is_dir()
551 {
552 failures.push(format!(
553 "refusing to remove bundled asset {} because it is a directory",
554 dest.display()
555 ));
556 }
557 }
558 let Some(kind) = skill_path_file_type(&target.skill_path)? else {
559 continue;
560 };
561 if kind.is_symlink() {
562 if !options.force {
563 failures.push(format!(
564 "refusing to remove symlinked skill at {}",
565 target.skill_path.display()
566 ));
567 }
568 continue;
569 }
570 if !kind.is_file() {
571 failures.push(format!(
572 "refusing to remove non-regular skill at {}",
573 target.skill_path.display()
574 ));
575 continue;
576 }
577 if !is_managed_or_bundled_skill(spec, &target.skill_path)? && !options.force {
578 failures.push(format!(
579 "refusing to remove unmanaged skill at {}",
580 target.skill_path.display()
581 ));
582 }
583 }
584 if failures.is_empty() {
585 return Ok(());
586 }
587 let hint = if escaped {
588 "--force does not permit removing files outside the skills directory".to_string()
589 } else {
590 format!(
591 "only skills generated by {} skill install can be removed without --force",
592 spec.marker_slug
593 )
594 };
595 Err(
596 SkillError::new(SkillErrorKind::Refused, failures.join("; "), Some(hint))
597 .with_partial_report(uninstall_report_lossy(spec, targets, &[])),
598 )
599}
600
601struct SkillTarget {
602 agent: SkillAgent,
603 scope: SkillScope,
604 skills_dir: PathBuf,
605 skill_dir: PathBuf,
606 skill_path: PathBuf,
607}
608
609fn resolve_targets(
610 spec: &SkillSpec,
611 options: &SkillOptions,
612) -> Result<Vec<SkillTarget>, SkillError> {
613 if options.skills_dir.is_some() && options.agent == SkillAgentSelection::All {
614 return Err(SkillError::new(
615 SkillErrorKind::Usage,
616 "--skills-dir requires a single --agent".to_string(),
617 Some("custom skills directories are ambiguous when --agent all is used".to_string()),
618 ));
619 }
620 match (options.agent, options.scope) {
621 (SkillAgentSelection::All, SkillScope::Personal) => Ok(vec![
622 resolve_target(spec, SkillAgent::Codex, SkillScope::Personal, None)?,
623 resolve_target(spec, SkillAgent::ClaudeCode, SkillScope::Personal, None)?,
624 resolve_target(spec, SkillAgent::Opencode, SkillScope::Personal, None)?,
625 resolve_target(spec, SkillAgent::Hermes, SkillScope::Personal, None)?,
626 ]),
627 (SkillAgentSelection::All, SkillScope::Workspace) => Ok(vec![
628 resolve_target(spec, SkillAgent::Codex, SkillScope::Workspace, None)?,
629 resolve_target(spec, SkillAgent::ClaudeCode, SkillScope::Workspace, None)?,
630 resolve_target(spec, SkillAgent::Opencode, SkillScope::Workspace, None)?,
631 resolve_target(spec, SkillAgent::Hermes, SkillScope::Workspace, None)?,
632 ]),
633 (SkillAgentSelection::Codex, SkillScope::Workspace) => Ok(vec![resolve_target(
634 spec,
635 SkillAgent::Codex,
636 SkillScope::Workspace,
637 options.skills_dir.as_deref(),
638 )?]),
639 (SkillAgentSelection::Codex, SkillScope::Personal) => Ok(vec![resolve_target(
640 spec,
641 SkillAgent::Codex,
642 SkillScope::Personal,
643 options.skills_dir.as_deref(),
644 )?]),
645 (SkillAgentSelection::ClaudeCode, scope) => Ok(vec![resolve_target(
646 spec,
647 SkillAgent::ClaudeCode,
648 scope,
649 options.skills_dir.as_deref(),
650 )?]),
651 (SkillAgentSelection::Opencode, scope) => Ok(vec![resolve_target(
652 spec,
653 SkillAgent::Opencode,
654 scope,
655 options.skills_dir.as_deref(),
656 )?]),
657 (SkillAgentSelection::Hermes, scope) => Ok(vec![resolve_target(
658 spec,
659 SkillAgent::Hermes,
660 scope,
661 options.skills_dir.as_deref(),
662 )?]),
663 }
664}
665
666fn resolve_target(
667 spec: &SkillSpec,
668 agent: SkillAgent,
669 scope: SkillScope,
670 skills_dir: Option<&str>,
671) -> Result<SkillTarget, SkillError> {
672 let skills_dir = match skills_dir {
673 Some(dir) => expand_tilde(dir)?,
674 None => default_skills_dir(agent, scope)?,
675 };
676 let skill_dir = skills_dir.join(spec.name);
677 let skill_path = skill_dir.join(SKILL_FILE_NAME);
678 Ok(SkillTarget {
679 agent,
680 scope,
681 skills_dir,
682 skill_dir,
683 skill_path,
684 })
685}
686
687fn default_skills_dir(agent: SkillAgent, scope: SkillScope) -> Result<PathBuf, SkillError> {
688 match (agent, scope) {
689 (SkillAgent::Codex, SkillScope::Personal) => {
690 if let Some(codex_home) = env_dir("CODEX_HOME") {
691 Ok(codex_home.join("skills"))
692 } else {
693 Ok(home_dir()?.join(".codex").join("skills"))
694 }
695 }
696 (SkillAgent::Codex, SkillScope::Workspace) => workspace_skills_dir(".codex"),
697 (SkillAgent::ClaudeCode, SkillScope::Personal) => {
698 Ok(home_dir()?.join(".claude").join("skills"))
699 }
700 (SkillAgent::ClaudeCode, SkillScope::Workspace) => workspace_skills_dir(".claude"),
701 (SkillAgent::Opencode, SkillScope::Personal) => {
702 if let Some(xdg) = env_dir("XDG_CONFIG_HOME") {
703 Ok(xdg.join("opencode").join("skills"))
704 } else {
705 Ok(home_dir()?.join(".config").join("opencode").join("skills"))
706 }
707 }
708 (SkillAgent::Opencode, SkillScope::Workspace) => workspace_skills_dir(".opencode"),
709 (SkillAgent::Hermes, SkillScope::Personal) => {
710 if let Some(hermes_home) = env_dir("HERMES_HOME") {
711 Ok(hermes_home.join("skills"))
712 } else {
713 Ok(home_dir()?.join(".hermes").join("skills"))
714 }
715 }
716 (SkillAgent::Hermes, SkillScope::Workspace) => workspace_skills_dir(".hermes"),
717 }
718}
719
720fn workspace_skills_dir(agent_dir: &str) -> Result<PathBuf, SkillError> {
721 std::env::current_dir()
722 .map(|dir| dir.join(agent_dir).join("skills"))
723 .map_err(|e| SkillError::io("resolve current directory", e))
724}
725
726fn target_status(spec: &SkillSpec, target: &SkillTarget) -> Result<SkillTargetStatus, SkillError> {
727 if let Err(message) = containment_failure(spec, target) {
731 return Ok(SkillTargetStatus {
732 agent: target.agent,
733 scope: target.scope,
734 skills_dir: target.skills_dir.clone(),
735 skill_dir: target.skill_dir.clone(),
736 skill_path: target.skill_path.clone(),
737 installed: false,
738 managed: false,
739 valid: false,
740 current: false,
741 validation_error: Some(message),
742 });
743 }
744 let Some(kind) = skill_path_file_type(&target.skill_path)? else {
745 return Ok(SkillTargetStatus {
746 agent: target.agent,
747 scope: target.scope,
748 skills_dir: target.skills_dir.clone(),
749 skill_dir: target.skill_dir.clone(),
750 skill_path: target.skill_path.clone(),
751 installed: false,
752 managed: false,
753 valid: false,
754 current: false,
755 validation_error: None,
756 });
757 };
758 let installed = true;
759 let mut valid = false;
760 let mut current = false;
761 let mut validation_error = None;
762 let mut managed = false;
763 if kind.is_symlink() {
764 validation_error = Some("target SKILL.md is a symlink; refusing to follow it".to_string());
765 } else if kind.is_file() {
766 let text = std::fs::read_to_string(&target.skill_path)
767 .map_err(|e| SkillError::io("read skill", e))?;
768 managed = skill_text_is_managed_or_bundled(spec, &text);
769 current = normalized_content_hash(spec, &text) == source_hash(spec)
770 && assets_current(spec, &target.skill_dir);
771 match validate_skill_text(spec, &text, SkillErrorKind::Refused) {
772 Ok(()) => valid = true,
773 Err(err) => validation_error = Some(err.message),
774 }
775 } else {
776 validation_error = Some("target SKILL.md is not a regular file".to_string());
777 }
778 Ok(SkillTargetStatus {
779 agent: target.agent,
780 scope: target.scope,
781 skills_dir: target.skills_dir.clone(),
782 skill_dir: target.skill_dir.clone(),
783 skill_path: target.skill_path.clone(),
784 installed,
785 managed,
786 valid,
787 current,
788 validation_error,
789 })
790}
791
792fn target_uninstall_status(target: &SkillTarget, removed: bool) -> SkillUninstallStatus {
793 SkillUninstallStatus {
794 agent: target.agent,
795 scope: target.scope,
796 skills_dir: target.skills_dir.clone(),
797 skill_dir: target.skill_dir.clone(),
798 skill_path: target.skill_path.clone(),
799 removed,
800 assets_removed: Vec::new(),
801 directory_retained: std::fs::symlink_metadata(&target.skill_dir).is_ok(),
802 }
803}
804
805fn target_status_lossy(spec: &SkillSpec, target: &SkillTarget) -> SkillTargetStatus {
806 target_status(spec, target).unwrap_or_else(|err| {
807 let installed = std::fs::symlink_metadata(&target.skill_path).is_ok();
808 SkillTargetStatus {
809 agent: target.agent,
810 scope: target.scope,
811 skills_dir: target.skills_dir.clone(),
812 skill_dir: target.skill_dir.clone(),
813 skill_path: target.skill_path.clone(),
814 installed,
815 managed: false,
816 valid: false,
817 current: false,
818 validation_error: Some(err.message),
819 }
820 })
821}
822
823fn install_report_lossy(spec: &SkillSpec, targets: &[SkillTarget], installed: bool) -> SkillReport {
824 SkillReport::Install {
825 skill: spec.name.to_string(),
826 installed,
827 targets: targets
828 .iter()
829 .map(|target| target_status_lossy(spec, target))
830 .collect(),
831 hint: "restart the agent so it reloads installed skills",
832 }
833}
834
835fn uninstall_report_lossy(
836 spec: &SkillSpec,
837 targets: &[SkillTarget],
838 removed: &[SkillUninstallStatus],
839) -> SkillReport {
840 let mut statuses = Vec::with_capacity(targets.len());
841 for target in targets {
842 if let Some(status) = removed.iter().find(|status| {
843 status.agent == target.agent
844 && status.scope == target.scope
845 && status.skill_path == target.skill_path
846 }) {
847 statuses.push(status.clone());
848 } else {
849 statuses.push(target_uninstall_status(target, false));
850 }
851 }
852 SkillReport::Uninstall {
853 skill: spec.name.to_string(),
854 removed_any: statuses.iter().any(|status| status.removed),
855 targets: statuses,
856 }
857}
858
859fn generated_by(spec: &SkillSpec) -> String {
860 format!("Generated by {} skill install", spec.marker_slug)
861}
862
863fn text_hash(text: &str) -> String {
864 let mut hash = FNV1A64_OFFSET;
865 for byte in text.as_bytes() {
866 hash ^= u64::from(*byte);
867 hash = hash.wrapping_mul(FNV1A64_PRIME);
868 }
869 format!("{hash:016x}")
870}
871
872fn source_hash(spec: &SkillSpec) -> String {
873 normalized_content_hash(spec, spec.source)
874}
875
876fn normalized_content_hash(spec: &SkillSpec, text: &str) -> String {
877 text_hash(&normalize_skill_text(spec, text))
878}
879
880fn managed_marker_block(spec: &SkillSpec) -> String {
881 let slug = spec.marker_slug;
882 format!(
883 "<!--\n{}\n{}-managed-skill: true\n{}-managed-skill-name: {}\n{}-managed-skill-owner: {}\n{}-managed-skill-content-hash-fnv1a64: {}\n-->",
884 generated_by(spec),
885 slug,
886 slug,
887 spec.name,
888 slug,
889 slug,
890 slug,
891 source_hash(spec)
892 )
893}
894
895fn managed_skill_contents(spec: &SkillSpec) -> String {
896 let block = managed_marker_block(spec);
897 let mut lines = spec.source.lines();
898 let mut output = String::new();
899 let mut inserted = false;
900 if let Some(first) = lines.next() {
901 output.push_str(first);
902 output.push('\n');
903 }
904 for line in lines {
905 output.push_str(line);
906 output.push('\n');
907 if !inserted && line.trim() == "---" {
908 output.push_str(&block);
909 output.push_str("\n\n");
910 inserted = true;
911 }
912 }
913 if !inserted {
914 output.push_str(&block);
915 output.push('\n');
916 }
917 output
918}
919
920fn validate_installed_skill(spec: &SkillSpec, path: &Path) -> Result<(), SkillError> {
921 let text =
922 std::fs::read_to_string(path).map_err(|e| SkillError::io("read installed skill", e))?;
923 validate_skill_text(spec, &text, SkillErrorKind::Io)
926}
927
928fn validate_skill_text(
929 spec: &SkillSpec,
930 text: &str,
931 kind: SkillErrorKind,
932) -> Result<(), SkillError> {
933 crate::skill::validate_skill_named(text, spec.name).map_err(|err| {
934 SkillError::new(
935 kind,
936 format!("invalid {} skill front matter: {err}", spec.title),
937 Some(format!(
938 "make SKILL.md metadata conform to the Agent Skills specification and set name to {}",
939 spec.name
940 )),
941 )
942 })?;
943 Ok(())
944}
945
946fn is_managed_or_bundled_skill(spec: &SkillSpec, path: &Path) -> Result<bool, SkillError> {
947 let Some(kind) = skill_path_file_type(path)? else {
948 return Ok(false);
949 };
950 if kind.is_symlink() {
951 return Err(SkillError::new(
952 SkillErrorKind::Refused,
953 format!("refusing to inspect symlinked skill at {}", path.display()),
954 Some("pass --force to replace or remove the symlink itself".to_string()),
955 ));
956 }
957 let text = std::fs::read_to_string(path).map_err(|e| SkillError::io("read skill", e))?;
958 Ok(skill_text_is_managed_or_bundled(spec, &text))
959}
960
961fn skill_text_is_managed_or_bundled(spec: &SkillSpec, text: &str) -> bool {
962 skill_text_has_managed_identity(spec, text)
963 || normalize_skill_text(spec, text) == normalize_skill_text(spec, spec.source)
964}
965
966fn skill_text_has_managed_identity(spec: &SkillSpec, text: &str) -> bool {
967 for block in html_comment_blocks(text) {
968 if managed_marker_block_has_identity(spec, &block) {
969 return true;
970 }
971 }
972 false
973}
974
975fn managed_marker_block_has_identity(spec: &SkillSpec, block: &str) -> bool {
976 let slug = spec.marker_slug;
977 let generated_by_line = generated_by(spec);
978 let managed_line = format!("{slug}-managed-skill: true");
979 let name_line = format!("{slug}-managed-skill-name: {}", spec.name);
980 let owner_line = format!("{slug}-managed-skill-owner: {slug}");
981 let mut has_generated_by = false;
982 let mut has_managed = false;
983 let mut has_name = false;
984 let mut has_owner = false;
985 for line in block.replace("\r\n", "\n").lines() {
986 let trimmed = line.trim();
987 has_generated_by |= trimmed == generated_by_line;
988 has_managed |= trimmed == managed_line;
989 has_name |= trimmed == name_line;
990 has_owner |= trimmed == owner_line;
991 }
992 has_generated_by && has_managed && has_name && has_owner
993}
994
995fn html_comment_blocks(text: &str) -> Vec<String> {
996 let normalized = text.replace("\r\n", "\n");
997 let mut blocks = Vec::new();
998 let mut lines = normalized.lines();
999 while let Some(line) = lines.next() {
1000 if line.trim() != "<!--" {
1001 continue;
1002 }
1003 let mut block = vec![line.to_string()];
1004 for next in lines.by_ref() {
1005 block.push(next.to_string());
1006 if next.trim() == "-->" {
1007 blocks.push(block.join("\n"));
1008 break;
1009 }
1010 }
1011 }
1012 blocks
1013}
1014
1015fn strip_managed_marker_blocks(spec: &SkillSpec, text: &str) -> String {
1016 let normalized = text.replace("\r\n", "\n");
1017 let mut output = Vec::new();
1018 let mut lines = normalized.lines();
1019 while let Some(line) = lines.next() {
1020 if line.trim() != "<!--" {
1021 output.push(line.to_string());
1022 continue;
1023 }
1024 let mut block = vec![line.to_string()];
1025 let mut closed = false;
1026 for next in lines.by_ref() {
1027 block.push(next.to_string());
1028 if next.trim() == "-->" {
1029 closed = true;
1030 break;
1031 }
1032 }
1033 if closed {
1034 let block_text = block.join("\n");
1035 if managed_marker_block_has_identity(spec, &block_text) {
1036 continue;
1037 }
1038 }
1039 output.extend(block);
1040 }
1041 output.join("\n")
1042}
1043
1044fn normalize_skill_text(spec: &SkillSpec, text: &str) -> String {
1045 let text = strip_managed_marker_blocks(spec, text);
1046 let mut out: Vec<&str> = Vec::new();
1050 for line in text.lines() {
1051 let trimmed = line.trim();
1052 if trimmed.is_empty() && out.last().is_some_and(|prev| prev.trim().is_empty()) {
1053 continue;
1054 }
1055 out.push(line);
1056 }
1057 out.join("\n").trim().to_string()
1058}
1059
1060fn validate_spec(spec: &SkillSpec) -> Result<(), SkillError> {
1061 validate_slug("skill name", spec.name)?;
1062 validate_slug("marker slug", spec.marker_slug)?;
1063 validate_skill_text(spec, spec.source, SkillErrorKind::InvalidSpec)
1064}
1065
1066fn validate_slug(field: &str, value: &str) -> Result<(), SkillError> {
1067 if slug_is_valid(value) {
1068 return Ok(());
1069 }
1070 Err(SkillError::new(
1071 SkillErrorKind::InvalidSpec,
1072 format!(
1073 "invalid {field} {value:?}: expected a lowercase slug matching [a-z0-9][a-z0-9-]*[a-z0-9]"
1074 ),
1075 Some("use lowercase ASCII letters, digits, and single hyphen-separated words".to_string()),
1076 ))
1077}
1078
1079fn slug_is_valid(value: &str) -> bool {
1080 let bytes = value.as_bytes();
1081 if bytes.is_empty() {
1082 return false;
1083 }
1084 fn is_lower_alnum(byte: u8) -> bool {
1085 byte.is_ascii_lowercase() || byte.is_ascii_digit()
1086 }
1087 if !is_lower_alnum(bytes[0]) || !is_lower_alnum(bytes[bytes.len() - 1]) {
1088 return false;
1089 }
1090 bytes
1091 .iter()
1092 .all(|byte| is_lower_alnum(*byte) || *byte == b'-')
1093}
1094
1095fn skill_path_file_type(path: &Path) -> Result<Option<std::fs::FileType>, SkillError> {
1096 match std::fs::symlink_metadata(path) {
1097 Ok(metadata) => Ok(Some(metadata.file_type())),
1098 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1099 Err(err) => Err(SkillError::io("inspect skill path", err)),
1100 }
1101}
1102
1103fn ensure_no_symlinked_dirs(root: &Path, path: &Path) -> Result<(), SkillError> {
1124 let Ok(relative) = path.strip_prefix(root) else {
1125 return Err(SkillError::new(
1126 SkillErrorKind::Refused,
1127 format!(
1128 "{} is not inside the skills directory {}",
1129 path.display(),
1130 root.display()
1131 ),
1132 Some("pass --skills-dir to install where you mean to".to_string()),
1133 ));
1134 };
1135 let mut current = root.to_path_buf();
1136 let mut components = relative.components().peekable();
1137 while let Some(component) = components.next() {
1138 current.push(component);
1139 if components.peek().is_none() {
1142 break;
1143 }
1144 match std::fs::symlink_metadata(¤t) {
1145 Ok(metadata) if metadata.file_type().is_symlink() => {
1146 return Err(SkillError::new(
1147 SkillErrorKind::Refused,
1148 format!(
1149 "refusing to reach {} through the symlinked directory {}",
1150 path.display(),
1151 current.display()
1152 ),
1153 Some(
1154 "replace the link with a real directory, or pass --skills-dir".to_string(),
1155 ),
1156 ));
1157 }
1158 Ok(_) => {}
1159 Err(err) if err.kind() == std::io::ErrorKind::NotFound => break,
1161 Err(err) => return Err(SkillError::io("inspect skill path", err)),
1162 }
1163 }
1164 Ok(())
1165}
1166
1167fn containment_failure(spec: &SkillSpec, target: &SkillTarget) -> Result<(), String> {
1171 let mut paths = vec![target.skill_path.clone()];
1172 paths.extend(
1173 spec.assets
1174 .iter()
1175 .map(|asset| asset_target_path(&target.skill_dir, asset.path)),
1176 );
1177 for path in paths {
1178 if let Err(err) = ensure_no_symlinked_dirs(&target.skills_dir, &path) {
1179 return Err(err.message);
1180 }
1181 }
1182 Ok(())
1183}
1184
1185fn write_skill_atomic(target: &SkillTarget, content: &str) -> Result<(), SkillError> {
1186 ensure_no_symlinked_dirs(&target.skills_dir, &target.skill_path)?;
1187 write_file_atomic(&target.skill_path, content, "install skill file")
1188}
1189
1190fn write_file_atomic(path: &Path, content: &str, action: &str) -> Result<(), SkillError> {
1198 let preserved = match std::fs::symlink_metadata(path) {
1199 Ok(metadata) if metadata.file_type().is_file() => Some(metadata.permissions()),
1200 _ => None,
1201 };
1202 let unix_mode = if preserved.is_some() {
1203 None
1204 } else {
1205 Some(0o644)
1206 };
1207 crate::atomic_file::install(
1208 path,
1209 crate::atomic_file::AtomicInstall::replacing(content.as_bytes())
1210 .with_permissions(preserved)
1211 .with_unix_mode(unix_mode),
1212 )
1213 .map_err(|err| SkillError::write_failed(action, err))
1214}
1215
1216fn validate_asset_path(path: &str) -> Result<(), SkillError> {
1219 let bad = path.is_empty()
1220 || Path::new(path).is_absolute()
1221 || path
1222 .split(['/', '\\'])
1223 .any(|seg| seg.is_empty() || seg == "." || seg == "..");
1224 if bad {
1225 return Err(SkillError::new(
1226 SkillErrorKind::InvalidSpec,
1227 format!("invalid skill asset path: {path}"),
1228 Some(
1229 "asset paths must be relative to the skill directory with no `.`/`..` segments"
1230 .to_string(),
1231 ),
1232 ));
1233 }
1234 Ok(())
1235}
1236
1237fn asset_target_path(skill_dir: &Path, rel_path: &str) -> PathBuf {
1240 let mut out = skill_dir.to_path_buf();
1241 for seg in rel_path.split(['/', '\\']) {
1242 out.push(seg);
1243 }
1244 out
1245}
1246
1247fn install_target_assets(spec: &SkillSpec, target: &SkillTarget) -> Result<(), SkillError> {
1250 for asset in spec.assets {
1251 let dest = asset_target_path(&target.skill_dir, asset.path);
1252 ensure_no_symlinked_dirs(&target.skills_dir, &dest)?;
1256 if let Some(parent) = dest.parent() {
1257 std::fs::create_dir_all(parent)
1258 .map_err(|e| SkillError::io("create skill asset dir", e))?;
1259 }
1260 write_file_atomic(&dest, asset.contents, "install skill asset")?;
1261 }
1262 Ok(())
1263}
1264
1265fn assets_current(spec: &SkillSpec, skill_dir: &Path) -> bool {
1268 spec.assets.iter().all(|asset| {
1269 let dest = asset_target_path(skill_dir, asset.path);
1270 std::fs::read_to_string(&dest)
1271 .map(|text| text == asset.contents)
1272 .unwrap_or(false)
1273 })
1274}
1275
1276fn remove_target_assets(spec: &SkillSpec, target: &SkillTarget) -> Result<Vec<String>, SkillError> {
1288 let mut removed = Vec::new();
1289 let mut dirs: Vec<PathBuf> = Vec::new();
1290 for asset in spec.assets {
1291 let dest = asset_target_path(&target.skill_dir, asset.path);
1292 ensure_no_symlinked_dirs(&target.skills_dir, &dest)?;
1293 match std::fs::remove_file(&dest) {
1294 Ok(()) => removed.push(asset.path.to_string()),
1295 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1297 Err(err) => {
1298 return Err(SkillError::io(
1299 &format!("remove bundled skill asset {}", asset.path),
1300 err,
1301 ));
1302 }
1303 }
1304 let mut dir = dest.parent().map(Path::to_path_buf);
1305 while let Some(current) = dir {
1306 if current == target.skill_dir || !current.starts_with(&target.skill_dir) {
1307 break;
1308 }
1309 if !dirs.contains(¤t) {
1310 dirs.push(current.clone());
1311 }
1312 dir = current.parent().map(Path::to_path_buf);
1313 }
1314 }
1315 dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count()));
1316 for dir in dirs {
1317 let _ = std::fs::remove_dir(&dir);
1318 }
1319 Ok(removed)
1320}
1321
1322fn env_dir(name: &str) -> Option<PathBuf> {
1325 std::env::var_os(name)
1326 .map(PathBuf::from)
1327 .filter(|path| path.is_absolute())
1328}
1329
1330fn home_dir() -> Result<PathBuf, SkillError> {
1331 env_dir("HOME")
1332 .or_else(|| env_dir("USERPROFILE"))
1333 .ok_or_else(|| {
1334 SkillError::new(
1335 SkillErrorKind::Usage,
1336 "cannot determine home directory".to_string(),
1337 Some("pass --skills-dir explicitly".to_string()),
1338 )
1339 })
1340}
1341
1342fn expand_tilde(input: &str) -> Result<PathBuf, SkillError> {
1343 if input == "~" {
1344 return home_dir();
1345 }
1346 if let Some(rest) = input.strip_prefix("~/") {
1347 return Ok(home_dir()?.join(rest));
1348 }
1349 Ok(PathBuf::from(input))
1350}
1351
1352#[cfg(test)]
1353mod tests {
1354 use super::*;
1355 use std::time::{SystemTime, UNIX_EPOCH};
1356
1357 const SKILL_SOURCE: &str =
1358 "---\nname: agent-first-test\ndescription: test skill\n---\n\n# Body\n\nrules.\n";
1359
1360 fn spec() -> SkillSpec<'static> {
1361 SkillSpec {
1362 name: "agent-first-test",
1363 source: SKILL_SOURCE,
1364 title: "Agent-First Test",
1365 marker_slug: "aftest",
1366 assets: &[],
1367 }
1368 }
1369
1370 fn managed_skill_with_body(body: &str) -> String {
1371 format!(
1372 "---\nname: agent-first-test\ndescription: test skill\n---\n{}\n\n{body}",
1373 managed_marker_block(&spec())
1374 )
1375 }
1376
1377 fn temp_skills_dir(name: &str) -> PathBuf {
1378 let suffix = SystemTime::now()
1379 .duration_since(UNIX_EPOCH)
1380 .map(|d| d.as_nanos())
1381 .unwrap_or(0);
1382 std::env::temp_dir().join(format!(
1383 "afdata_skill_{name}_{}_{}",
1384 std::process::id(),
1385 suffix
1386 ))
1387 }
1388
1389 fn options(agent: SkillAgentSelection, dir: &Path, force: bool) -> SkillOptions {
1390 SkillOptions {
1391 agent,
1392 scope: SkillScope::Personal,
1393 skills_dir: Some(dir.to_string_lossy().to_string()),
1394 force,
1395 }
1396 }
1397
1398 fn custom_target(agent: SkillAgent, dir: &Path) -> SkillTarget {
1399 let skill_dir = dir.join("agent-first-test");
1400 SkillTarget {
1401 agent,
1402 scope: SkillScope::Personal,
1403 skills_dir: dir.to_path_buf(),
1404 skill_path: skill_dir.join(SKILL_FILE_NAME),
1405 skill_dir,
1406 }
1407 }
1408
1409 #[test]
1410 fn validates_bundled_frontmatter() {
1411 assert!(crate::skill::validate_skill_named(SKILL_SOURCE, "agent-first-test").is_ok());
1412 }
1413
1414 #[test]
1415 fn rejects_unquoted_colon_space() {
1416 let bad = "---\nname: x\ndescription: broken: yaml\n---\n";
1417 assert!(crate::skill::validate_skill(bad).is_err());
1418 }
1419
1420 fn install_status_uninstall_for(agent: SkillAgentSelection, expect: SkillAgent, tag: &str) {
1421 let dir = temp_skills_dir(tag);
1422 let opts = options(agent, &dir, false);
1423 let skill_path = dir.join("agent-first-test").join(SKILL_FILE_NAME);
1424
1425 let installed = run_skill_admin(&spec(), SkillAction::Install, &opts);
1426 assert!(installed.is_ok());
1427 assert!(skill_path.is_file());
1428 let text = std::fs::read_to_string(&skill_path).unwrap_or_default();
1429 assert!(text.contains(&managed_marker_block(&spec())));
1430 assert!(text.contains("aftest-managed-skill-name: agent-first-test"));
1431 assert!(text.contains("aftest-managed-skill-owner: aftest"));
1432 assert!(text.contains("aftest-managed-skill-content-hash-fnv1a64:"));
1433 assert!(!text.contains("aftest-managed-skill-source-hash-fnv1a64:"));
1434
1435 let status = run_skill_admin(&spec(), SkillAction::Status, &opts);
1436 assert!(status.is_ok());
1437 if let Ok(SkillReport::Status {
1438 installed_all,
1439 valid_all,
1440 current_all,
1441 targets,
1442 ..
1443 }) = status
1444 {
1445 assert!(installed_all);
1446 assert!(valid_all);
1447 assert!(current_all);
1448 assert_eq!(targets.first().map(|t| t.agent), Some(expect));
1449 assert_eq!(targets.first().map(|t| t.current), Some(true));
1450 }
1451
1452 let removed = run_skill_admin(&spec(), SkillAction::Uninstall, &opts);
1453 assert!(removed.is_ok());
1454 assert!(!skill_path.exists());
1455 let _ = std::fs::remove_dir_all(dir);
1456 }
1457
1458 #[test]
1459 fn install_status_uninstall_codex() {
1460 install_status_uninstall_for(SkillAgentSelection::Codex, SkillAgent::Codex, "codex");
1461 }
1462
1463 #[test]
1464 fn install_status_uninstall_claude_code() {
1465 install_status_uninstall_for(
1466 SkillAgentSelection::ClaudeCode,
1467 SkillAgent::ClaudeCode,
1468 "claude",
1469 );
1470 }
1471
1472 #[test]
1473 fn install_status_uninstall_opencode() {
1474 install_status_uninstall_for(
1475 SkillAgentSelection::Opencode,
1476 SkillAgent::Opencode,
1477 "opencode",
1478 );
1479 }
1480
1481 #[test]
1482 fn install_status_uninstall_hermes() {
1483 install_status_uninstall_for(SkillAgentSelection::Hermes, SkillAgent::Hermes, "hermes");
1484 }
1485
1486 fn spec_with_assets() -> SkillSpec<'static> {
1487 const ASSETS: &[SkillAsset] = &[
1488 SkillAsset {
1489 path: "references/guide.md",
1490 contents: "# guide\n",
1491 },
1492 SkillAsset {
1493 path: "references/registry.json",
1494 contents: "{\"ok\":true}\n",
1495 },
1496 ];
1497 SkillSpec {
1498 name: "agent-first-test",
1499 source: SKILL_SOURCE,
1500 title: "Agent-First Test",
1501 marker_slug: "aftest",
1502 assets: ASSETS,
1503 }
1504 }
1505
1506 #[test]
1507 fn install_writes_and_uninstall_removes_bundled_assets() {
1508 let dir = temp_skills_dir("assets");
1509 let opts = options(SkillAgentSelection::Codex, &dir, false);
1510 let skill_dir = dir.join("agent-first-test");
1511 let guide = skill_dir.join("references").join("guide.md");
1512 let registry = skill_dir.join("references").join("registry.json");
1513
1514 assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_ok());
1515 assert_eq!(
1516 std::fs::read_to_string(&guide).unwrap_or_default(),
1517 "# guide\n"
1518 );
1519 assert_eq!(
1520 std::fs::read_to_string(®istry).unwrap_or_default(),
1521 "{\"ok\":true}\n"
1522 );
1523
1524 std::fs::remove_file(&guide).unwrap();
1528 match run_skill_admin(&spec_with_assets(), SkillAction::Status, &opts) {
1529 Ok(SkillReport::Status { current_all, .. }) => {
1530 assert!(
1531 !current_all,
1532 "a missing asset must make the skill not-current"
1533 );
1534 }
1535 other => panic!("unexpected status: {other:?}"),
1536 }
1537 assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_ok());
1538 assert!(guide.is_file(), "re-install must restore the missing asset");
1539
1540 assert!(run_skill_admin(&spec_with_assets(), SkillAction::Uninstall, &opts).is_ok());
1541 assert!(!skill_dir.join(SKILL_FILE_NAME).exists());
1542 assert!(!guide.exists(), "uninstall must remove bundled assets");
1543 assert!(
1544 !skill_dir.join("references").exists(),
1545 "uninstall must remove now-empty asset directories"
1546 );
1547 assert!(
1548 !skill_dir.exists(),
1549 "uninstall must remove the skill directory"
1550 );
1551 let _ = std::fs::remove_dir_all(dir);
1552 }
1553
1554 #[test]
1555 fn install_rejects_escaping_asset_path() {
1556 const ESCAPE: &[SkillAsset] = &[SkillAsset {
1557 path: "../evil.md",
1558 contents: "x",
1559 }];
1560 let dir = temp_skills_dir("assets-escape");
1561 let opts = options(SkillAgentSelection::Codex, &dir, false);
1562 let spec = SkillSpec {
1563 name: "agent-first-test",
1564 source: SKILL_SOURCE,
1565 title: "Agent-First Test",
1566 marker_slug: "aftest",
1567 assets: ESCAPE,
1568 };
1569 assert!(
1570 run_skill_admin(&spec, SkillAction::Install, &opts).is_err(),
1571 "an asset path escaping the skill dir must be rejected"
1572 );
1573 assert!(!dir.join("evil.md").exists());
1574 let _ = std::fs::remove_dir_all(dir);
1575 }
1576
1577 #[test]
1578 fn status_reports_stale_install_as_not_current() {
1579 let dir = temp_skills_dir("stale");
1580 let opts = options(SkillAgentSelection::Opencode, &dir, false);
1581 let skill_dir = dir.join("agent-first-test");
1582 let skill_path = skill_dir.join(SKILL_FILE_NAME);
1583 assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1584 let stale = managed_skill_with_body("# Body\n\nOLD rules.\n");
1586 assert!(std::fs::write(&skill_path, stale).is_ok());
1587
1588 let status = run_skill_admin(&spec(), SkillAction::Status, &opts);
1589 if let Ok(SkillReport::Status {
1590 current_all,
1591 targets,
1592 ..
1593 }) = status
1594 {
1595 assert!(!current_all);
1596 if let Some(t) = targets.first() {
1597 assert!(t.installed);
1598 assert!(t.valid);
1599 assert!(t.managed);
1600 assert!(!t.current);
1601 }
1602 }
1603
1604 assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_ok());
1606 let refreshed = std::fs::read_to_string(&skill_path).unwrap_or_default();
1607 assert!(refreshed.contains(&managed_marker_block(&spec())));
1608 assert!(!refreshed.contains("<!-- aftest-managed-skill: true -->"));
1609 if let Ok(SkillReport::Status { targets, .. }) =
1610 run_skill_admin(&spec(), SkillAction::Status, &opts)
1611 {
1612 assert_eq!(targets.first().map(|t| t.current), Some(true));
1613 }
1614 let _ = std::fs::remove_dir_all(dir);
1615 }
1616
1617 #[test]
1618 fn random_text_with_marker_words_is_not_managed() {
1619 let dir = temp_skills_dir("marker-words");
1620 let opts = options(SkillAgentSelection::Opencode, &dir, false);
1621 let skill_dir = dir.join("agent-first-test");
1622 let skill_path = skill_dir.join(SKILL_FILE_NAME);
1623 assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1624 let random = format!(
1625 "---\nname: agent-first-test\ndescription: test skill\n---\n\nThis mentions {} and {} but is not a generated block.\n",
1626 generated_by(&spec()),
1627 "aftest-managed-skill: true"
1628 );
1629 assert!(std::fs::write(&skill_path, random).is_ok());
1630
1631 if let Ok(SkillReport::Status { targets, .. }) =
1632 run_skill_admin(&spec(), SkillAction::Status, &opts)
1633 {
1634 assert_eq!(targets.first().map(|t| t.managed), Some(false));
1635 }
1636 assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
1637 let _ = std::fs::remove_dir_all(dir);
1638 }
1639
1640 #[test]
1641 fn old_marker_format_is_not_managed() {
1642 let dir = temp_skills_dir("old-marker");
1643 let opts = options(SkillAgentSelection::Opencode, &dir, false);
1644 let skill_dir = dir.join("agent-first-test");
1645 let skill_path = skill_dir.join(SKILL_FILE_NAME);
1646 assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1647 let old_marker = concat!(
1648 "---\n",
1649 "name: agent-first-test\n",
1650 "description: test skill\n",
1651 "---\n",
1652 "<!--\n",
1653 "Generated by aftest skill install\n",
1654 "aftest-managed-skill: true\n",
1655 "aftest-managed-skill-name: agent-first-test\n",
1656 "aftest-managed-skill-source-hash-fnv1a64: deadbeef\n",
1657 "-->\n",
1658 "\n",
1659 "# Body\n",
1660 "\n",
1661 "rules.\n"
1662 );
1663 assert!(std::fs::write(&skill_path, old_marker).is_ok());
1664
1665 if let Ok(SkillReport::Status { targets, .. }) =
1666 run_skill_admin(&spec(), SkillAction::Status, &opts)
1667 && let Some(t) = targets.first()
1668 {
1669 assert!(t.installed);
1670 assert!(t.valid);
1671 assert!(!t.managed);
1672 assert!(!t.current);
1673 }
1674 assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
1675 let _ = std::fs::remove_dir_all(dir);
1676 }
1677
1678 #[test]
1679 fn install_preflight_reports_all_targets_without_writing() {
1680 let dir = temp_skills_dir("install-preflight");
1681 let codex = custom_target(SkillAgent::Codex, &dir.join("codex"));
1682 let opencode = custom_target(SkillAgent::Opencode, &dir.join("opencode"));
1683 assert!(std::fs::create_dir_all(&opencode.skill_dir).is_ok());
1684 assert!(
1685 std::fs::write(
1686 &opencode.skill_path,
1687 "---\nname: custom\ndescription: custom\n---\n"
1688 )
1689 .is_ok()
1690 );
1691 let opts = SkillOptions {
1692 agent: SkillAgentSelection::All,
1693 scope: SkillScope::Personal,
1694 skills_dir: None,
1695 force: false,
1696 };
1697
1698 let result = preflight_install_targets(&spec(), &opts, &[codex, opencode]);
1699 assert!(result.is_err());
1700 let Err(err) = result else {
1701 return;
1702 };
1703 assert!(
1704 err.message
1705 .contains("refusing to overwrite unmanaged skill")
1706 );
1707 assert!(!dir.join("codex").join("agent-first-test").exists());
1708 let partial_report = err.partial_report.map(|report| *report);
1709 assert!(matches!(partial_report, Some(SkillReport::Install { .. })));
1710 let Some(SkillReport::Install {
1711 installed, targets, ..
1712 }) = partial_report
1713 else {
1714 return;
1715 };
1716 assert!(!installed);
1717 assert_eq!(targets.len(), 2);
1718 assert_eq!(targets.first().map(|target| target.installed), Some(false));
1719 assert_eq!(targets.get(1).map(|target| target.installed), Some(true));
1720 assert_eq!(targets.get(1).map(|target| target.managed), Some(false));
1721 let _ = std::fs::remove_dir_all(dir);
1722 }
1723
1724 #[test]
1725 fn uninstall_preflight_reports_all_targets_without_removing() {
1726 let dir = temp_skills_dir("uninstall-preflight");
1727 let codex = custom_target(SkillAgent::Codex, &dir.join("codex"));
1728 let opencode = custom_target(SkillAgent::Opencode, &dir.join("opencode"));
1729 assert!(std::fs::create_dir_all(&codex.skill_dir).is_ok());
1730 assert!(std::fs::create_dir_all(&opencode.skill_dir).is_ok());
1731 assert!(std::fs::write(&codex.skill_path, managed_skill_contents(&spec())).is_ok());
1732 assert!(
1733 std::fs::write(
1734 &opencode.skill_path,
1735 "---\nname: custom\ndescription: custom\n---\n"
1736 )
1737 .is_ok()
1738 );
1739 let opts = SkillOptions {
1740 agent: SkillAgentSelection::All,
1741 scope: SkillScope::Personal,
1742 skills_dir: None,
1743 force: false,
1744 };
1745
1746 let result = preflight_uninstall_targets(&spec(), &opts, &[codex, opencode]);
1747 assert!(result.is_err());
1748 let Err(err) = result else {
1749 return;
1750 };
1751 assert!(err.message.contains("refusing to remove unmanaged skill"));
1752 assert!(
1753 dir.join("codex")
1754 .join("agent-first-test")
1755 .join(SKILL_FILE_NAME)
1756 .exists()
1757 );
1758 let partial_report = err.partial_report.map(|report| *report);
1759 assert!(matches!(
1760 partial_report,
1761 Some(SkillReport::Uninstall { .. })
1762 ));
1763 let Some(SkillReport::Uninstall {
1764 removed_any,
1765 targets,
1766 ..
1767 }) = partial_report
1768 else {
1769 return;
1770 };
1771 assert!(!removed_any);
1772 assert_eq!(targets.len(), 2);
1773 assert!(targets.iter().all(|target| !target.removed));
1774 let _ = std::fs::remove_dir_all(dir);
1775 }
1776
1777 #[test]
1778 fn install_and_uninstall_refuse_unmanaged() {
1779 let dir = temp_skills_dir("unmanaged");
1780 let skill_dir = dir.join("agent-first-test");
1781 let skill_path = skill_dir.join(SKILL_FILE_NAME);
1782 assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1783 assert!(
1784 std::fs::write(&skill_path, "---\nname: custom\ndescription: custom\n---\n").is_ok()
1785 );
1786 let opts = options(SkillAgentSelection::Codex, &dir, false);
1787
1788 assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
1789 assert!(run_skill_admin(&spec(), SkillAction::Uninstall, &opts).is_err());
1790 assert!(skill_path.exists());
1791 let _ = std::fs::remove_dir_all(dir);
1792 }
1793
1794 #[test]
1795 fn invalid_spec_slugs_are_rejected_before_path_resolution() {
1796 for name in ["", "../x", "x/y", ".hidden", "bad_name", "Bad"] {
1797 let bad = SkillSpec {
1798 name,
1799 source: SKILL_SOURCE,
1800 title: "Bad",
1801 marker_slug: "aftest",
1802 assets: &[],
1803 };
1804 let opts = options(SkillAgentSelection::Codex, Path::new("/tmp/afdata"), false);
1805 assert!(
1806 run_skill_admin(&bad, SkillAction::Status, &opts).is_err(),
1807 "{name:?}"
1808 );
1809 }
1810
1811 let bad_marker = SkillSpec {
1812 name: "agent-first-test",
1813 source: SKILL_SOURCE,
1814 title: "Bad",
1815 marker_slug: "../aftest",
1816 assets: &[],
1817 };
1818 let opts = options(SkillAgentSelection::Codex, Path::new("/tmp/afdata"), false);
1819 assert!(run_skill_admin(&bad_marker, SkillAction::Status, &opts).is_err());
1820 }
1821
1822 #[test]
1823 fn frontmatter_name_must_match_spec_name() {
1824 let bad = SkillSpec {
1825 name: "agent-first-test",
1826 source: "---\nname: other-skill\ndescription: test skill\n---\n",
1827 title: "Bad",
1828 marker_slug: "aftest",
1829 assets: &[],
1830 };
1831 let dir = temp_skills_dir("frontmatter-name");
1832 let opts = options(SkillAgentSelection::Codex, &dir, false);
1833 assert!(run_skill_admin(&bad, SkillAction::Install, &opts).is_err());
1834 let _ = std::fs::remove_dir_all(dir);
1835 }
1836
1837 #[cfg(unix)]
1838 #[test]
1839 fn symlink_target_is_rejected_by_default_and_force_does_not_follow() {
1840 use std::os::unix::fs::symlink;
1841
1842 let dir = temp_skills_dir("symlink-install");
1843 let opts = options(SkillAgentSelection::Codex, &dir, false);
1844 let force_opts = options(SkillAgentSelection::Codex, &dir, true);
1845 let skill_dir = dir.join("agent-first-test");
1846 let skill_path = skill_dir.join(SKILL_FILE_NAME);
1847 let external = dir.join("external.md");
1848 assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1849 assert!(std::fs::write(&external, "external").is_ok());
1850 assert!(symlink(&external, &skill_path).is_ok());
1851
1852 assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
1853 assert_eq!(
1854 std::fs::read_to_string(&external).unwrap_or_default(),
1855 "external"
1856 );
1857 assert!(run_skill_admin(&spec(), SkillAction::Uninstall, &opts).is_err());
1858 assert!(skill_path.is_symlink());
1859
1860 assert!(run_skill_admin(&spec(), SkillAction::Install, &force_opts).is_ok());
1861 assert_eq!(
1862 std::fs::read_to_string(&external).unwrap_or_default(),
1863 "external"
1864 );
1865 assert!(skill_path.is_file());
1866 assert!(!skill_path.is_symlink());
1867 let _ = std::fs::remove_dir_all(dir);
1868 }
1869
1870 #[cfg(unix)]
1871 #[test]
1872 fn a_symlinked_skill_directory_never_receives_a_write() {
1873 use std::os::unix::fs::symlink;
1874
1875 let dir = temp_skills_dir("symlink-skill-dir");
1876 let outside = temp_skills_dir("symlink-skill-dir-outside");
1877 assert!(std::fs::create_dir_all(&dir).is_ok());
1878 assert!(std::fs::create_dir_all(&outside).is_ok());
1879 let sentinel = outside.join(SKILL_FILE_NAME);
1880 assert!(std::fs::write(&sentinel, "sentinel").is_ok());
1881 assert!(symlink(&outside, dir.join("agent-first-test")).is_ok());
1884
1885 for force in [false, true] {
1886 let opts = options(SkillAgentSelection::Codex, &dir, force);
1887 let err = run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts)
1888 .expect_err("install through a symlinked skill directory must fail");
1889 assert!(
1890 err.message.contains("symlinked directory"),
1891 "unexpected message: {}",
1892 err.message
1893 );
1894 assert_eq!(
1895 err.hint.as_deref(),
1896 Some("--force does not permit writing outside the skills directory"),
1897 "--force must not read as permission to escape the root"
1898 );
1899 assert!(
1900 run_skill_admin(&spec_with_assets(), SkillAction::Uninstall, &opts).is_err(),
1901 "uninstall must refuse the same path"
1902 );
1903 }
1904
1905 assert_eq!(
1906 std::fs::read_to_string(&sentinel).unwrap_or_default(),
1907 "sentinel",
1908 "the file outside the skills directory must be untouched"
1909 );
1910 assert!(!outside.join("references").exists());
1911 let _ = std::fs::remove_dir_all(dir);
1912 let _ = std::fs::remove_dir_all(outside);
1913 }
1914
1915 #[cfg(unix)]
1916 #[test]
1917 fn a_symlinked_asset_directory_never_receives_a_write() {
1918 use std::os::unix::fs::symlink;
1919
1920 let dir = temp_skills_dir("symlink-asset-dir");
1921 let outside = temp_skills_dir("symlink-asset-dir-outside");
1922 let skill_dir = dir.join("agent-first-test");
1923 assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1924 assert!(std::fs::create_dir_all(&outside).is_ok());
1925 let sentinel = outside.join("guide.md");
1926 assert!(std::fs::write(&sentinel, "sentinel").is_ok());
1927 assert!(symlink(&outside, skill_dir.join("references")).is_ok());
1930
1931 let opts = options(SkillAgentSelection::Codex, &dir, true);
1932 assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_err());
1933
1934 assert_eq!(
1935 std::fs::read_to_string(&sentinel).unwrap_or_default(),
1936 "sentinel"
1937 );
1938 assert!(!skill_dir.join(SKILL_FILE_NAME).exists());
1939 let _ = std::fs::remove_dir_all(dir);
1940 let _ = std::fs::remove_dir_all(outside);
1941 }
1942
1943 #[cfg(unix)]
1944 #[test]
1945 fn status_names_a_symlinked_skill_directory_instead_of_reading_through_it() {
1946 use std::os::unix::fs::symlink;
1947
1948 let dir = temp_skills_dir("symlink-status");
1949 let outside = temp_skills_dir("symlink-status-outside");
1950 assert!(std::fs::create_dir_all(&dir).is_ok());
1951 assert!(std::fs::create_dir_all(&outside).is_ok());
1952 assert!(std::fs::write(outside.join(SKILL_FILE_NAME), "sentinel").is_ok());
1953 assert!(symlink(&outside, dir.join("agent-first-test")).is_ok());
1954
1955 let opts = options(SkillAgentSelection::Codex, &dir, false);
1956 let report = run_skill_admin(&spec(), SkillAction::Status, &opts)
1957 .expect("status must report every target rather than fail outright");
1958 let SkillReport::Status { targets, .. } = report else {
1959 panic!("expected a status report");
1960 };
1961 let target = targets.first().expect("one target");
1962 assert!(!target.installed, "nothing may be read through the link");
1963 assert!(
1964 target
1965 .validation_error
1966 .as_deref()
1967 .unwrap_or_default()
1968 .contains("symlinked directory")
1969 );
1970 let _ = std::fs::remove_dir_all(dir);
1971 let _ = std::fs::remove_dir_all(outside);
1972 }
1973
1974 #[test]
1975 fn uninstall_reports_the_assets_it_removed_and_a_directory_the_user_kept() {
1976 let dir = temp_skills_dir("uninstall-report");
1977 let opts = options(SkillAgentSelection::Codex, &dir, false);
1978 assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_ok());
1979 let mine = dir.join("agent-first-test").join("notes.md");
1982 assert!(std::fs::write(&mine, "mine").is_ok());
1983
1984 let report = run_skill_admin(&spec_with_assets(), SkillAction::Uninstall, &opts)
1985 .expect("uninstall must succeed");
1986 let SkillReport::Uninstall { targets, .. } = report else {
1987 panic!("expected an uninstall report");
1988 };
1989 let target = targets.first().expect("one target");
1990 assert!(target.removed);
1991 assert_eq!(
1992 target.assets_removed,
1993 vec![
1994 "references/guide.md".to_string(),
1995 "references/registry.json".to_string()
1996 ]
1997 );
1998 assert!(
1999 target.directory_retained,
2000 "a directory holding the user's own file survives, and the report says so"
2001 );
2002 assert_eq!(std::fs::read_to_string(&mine).unwrap_or_default(), "mine");
2003 let _ = std::fs::remove_dir_all(dir);
2004 }
2005
2006 #[test]
2007 fn install_leaves_a_users_own_reference_alone_without_force() {
2008 let dir = temp_skills_dir("unmanaged-asset");
2009 let opts = options(SkillAgentSelection::Codex, &dir, false);
2010 let skill_dir = dir.join("agent-first-test");
2011 let guide = skill_dir.join("references").join("guide.md");
2012 std::fs::create_dir_all(guide.parent().unwrap()).unwrap();
2013 std::fs::write(&guide, "my notes\n").unwrap();
2014
2015 let err = run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts)
2016 .expect_err("a file the user put there is not this tool's to replace");
2017 assert!(
2018 err.message.contains("unmanaged skill asset"),
2019 "{}",
2020 err.message
2021 );
2022 assert_eq!(std::fs::read_to_string(&guide).unwrap(), "my notes\n");
2023 assert!(!skill_dir.join(SKILL_FILE_NAME).exists());
2024
2025 std::fs::write(&guide, "# guide\n").unwrap();
2027 assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_ok());
2028
2029 std::fs::write(&guide, "older bundle\n").unwrap();
2032 assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_ok());
2033 assert_eq!(std::fs::read_to_string(&guide).unwrap(), "# guide\n");
2034
2035 std::fs::remove_file(skill_dir.join(SKILL_FILE_NAME)).unwrap();
2036 std::fs::write(&guide, "my notes\n").unwrap();
2037 let forced = options(SkillAgentSelection::Codex, &dir, true);
2038 assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &forced).is_ok());
2039 assert_eq!(std::fs::read_to_string(&guide).unwrap(), "# guide\n");
2040 let _ = std::fs::remove_dir_all(dir);
2041 }
2042
2043 #[test]
2044 fn install_refuses_a_directory_at_an_asset_path_before_writing_anything() {
2045 let dir = temp_skills_dir("asset-is-dir");
2046 let opts = options(SkillAgentSelection::Codex, &dir, true);
2047 let skill_dir = dir.join("agent-first-test");
2048 std::fs::create_dir_all(skill_dir.join("references").join("guide.md")).unwrap();
2049
2050 let err = run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts)
2051 .expect_err("a directory cannot be replaced by an asset file");
2052 assert!(
2053 err.message.contains("non-regular skill asset"),
2054 "{}",
2055 err.message
2056 );
2057 assert!(!skill_dir.join(SKILL_FILE_NAME).exists());
2058 let _ = std::fs::remove_dir_all(dir);
2059 }
2060
2061 #[cfg(unix)]
2065 #[test]
2066 fn an_uninstall_that_fails_partway_can_be_run_again() {
2067 use std::os::unix::fs::PermissionsExt;
2068 let dir = temp_skills_dir("uninstall-retry");
2069 let opts = options(SkillAgentSelection::Codex, &dir, false);
2070 let skill_dir = dir.join("agent-first-test");
2071 let references = skill_dir.join("references");
2072 assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_ok());
2073
2074 std::fs::set_permissions(&references, std::fs::Permissions::from_mode(0o555)).unwrap();
2075 let failed = run_skill_admin(&spec_with_assets(), SkillAction::Uninstall, &opts);
2076 std::fs::set_permissions(&references, std::fs::Permissions::from_mode(0o755)).unwrap();
2077 assert!(failed.is_err());
2078 assert!(skill_dir.join(SKILL_FILE_NAME).is_file());
2079
2080 assert!(run_skill_admin(&spec_with_assets(), SkillAction::Uninstall, &opts).is_ok());
2081 assert!(!references.join("guide.md").exists());
2082 assert!(!skill_dir.exists());
2083 let _ = std::fs::remove_dir_all(dir);
2084 }
2085
2086 #[test]
2087 fn uninstall_reports_a_bundled_asset_it_could_not_remove() {
2088 let dir = temp_skills_dir("uninstall-stuck-asset");
2089 let opts = options(SkillAgentSelection::Codex, &dir, false);
2090 assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_ok());
2091 let asset = dir
2094 .join("agent-first-test")
2095 .join("references")
2096 .join("guide.md");
2097 assert!(std::fs::remove_file(&asset).is_ok());
2098 assert!(std::fs::create_dir(&asset).is_ok());
2099 assert!(std::fs::write(asset.join("kept.md"), "kept").is_ok());
2100
2101 let err = run_skill_admin(&spec_with_assets(), SkillAction::Uninstall, &opts)
2102 .expect_err("a managed asset that cannot be removed is not a clean uninstall");
2103 let named = Path::new("references").join("guide.md");
2107 assert!(
2108 err.message.contains(&named.display().to_string()),
2109 "unexpected message: {}",
2110 err.message
2111 );
2112 assert!(
2113 asset.join("kept.md").exists(),
2114 "the user's file must survive the refusal"
2115 );
2116 let _ = std::fs::remove_dir_all(dir);
2117 }
2118
2119 #[cfg(unix)]
2120 #[test]
2121 fn force_uninstall_removes_symlink_without_following() {
2122 use std::os::unix::fs::symlink;
2123
2124 let dir = temp_skills_dir("symlink-uninstall");
2125 let force_opts = options(SkillAgentSelection::Codex, &dir, true);
2126 let skill_dir = dir.join("agent-first-test");
2127 let skill_path = skill_dir.join(SKILL_FILE_NAME);
2128 let external = dir.join("external.md");
2129 assert!(std::fs::create_dir_all(&skill_dir).is_ok());
2130 assert!(std::fs::write(&external, "external").is_ok());
2131 assert!(symlink(&external, &skill_path).is_ok());
2132
2133 assert!(run_skill_admin(&spec(), SkillAction::Uninstall, &force_opts).is_ok());
2134 assert!(!skill_path.exists());
2135 assert_eq!(
2136 std::fs::read_to_string(&external).unwrap_or_default(),
2137 "external"
2138 );
2139 let _ = std::fs::remove_dir_all(dir);
2140 }
2141
2142 #[test]
2143 fn serializes_to_protocol_shape() {
2144 let dir = temp_skills_dir("serialize");
2145 let opts = options(SkillAgentSelection::Opencode, &dir, false);
2146 if let Ok(report) = run_skill_admin(&spec(), SkillAction::Install, &opts) {
2147 let value = serde_json::to_value(&report).unwrap_or(serde_json::Value::Null);
2148 assert_eq!(value["code"], "skill_install");
2149 assert_eq!(value["installed"], true);
2150 assert_eq!(value["targets"][0]["agent"], "opencode");
2151 assert_eq!(value["targets"][0]["current"], true);
2152 assert_eq!(
2153 value["targets"][0]["skill_dir"],
2154 serde_json::json!(dir.join("agent-first-test").to_string_lossy().to_string())
2155 );
2156 }
2157 let _ = std::fs::remove_dir_all(dir);
2158 }
2159
2160 #[test]
2161 fn all_personal_resolves_four_targets() {
2162 let opts = SkillOptions {
2163 agent: SkillAgentSelection::All,
2164 scope: SkillScope::Personal,
2165 skills_dir: None,
2166 force: false,
2167 };
2168 let targets = resolve_targets(&spec(), &opts);
2169 assert!(targets.is_ok());
2170 if let Ok(targets) = targets {
2171 assert_eq!(targets.len(), 4);
2172 assert_eq!(targets[0].agent, SkillAgent::Codex);
2173 assert_eq!(targets[1].agent, SkillAgent::ClaudeCode);
2174 assert_eq!(targets[2].agent, SkillAgent::Opencode);
2175 assert_eq!(targets[3].agent, SkillAgent::Hermes);
2176 }
2177 }
2178
2179 #[test]
2180 fn all_workspace_resolves_four_targets() {
2181 let opts = SkillOptions {
2182 agent: SkillAgentSelection::All,
2183 scope: SkillScope::Workspace,
2184 skills_dir: None,
2185 force: false,
2186 };
2187 let targets = resolve_targets(&spec(), &opts);
2188 assert!(targets.is_ok());
2189 if let Ok(targets) = targets {
2190 assert_eq!(targets.len(), 4);
2191 assert_eq!(targets[0].agent, SkillAgent::Codex);
2192 assert_eq!(targets[0].scope, SkillScope::Workspace);
2193 assert_eq!(targets[1].agent, SkillAgent::ClaudeCode);
2194 assert_eq!(targets[1].scope, SkillScope::Workspace);
2195 assert_eq!(targets[2].agent, SkillAgent::Opencode);
2196 assert_eq!(targets[2].scope, SkillScope::Workspace);
2197 assert_eq!(targets[3].agent, SkillAgent::Hermes);
2198 assert_eq!(targets[3].scope, SkillScope::Workspace);
2199 }
2200 }
2201
2202 #[test]
2203 fn codex_workspace_scope_uses_codex_skills_dir() {
2204 let opts = SkillOptions {
2205 agent: SkillAgentSelection::Codex,
2206 scope: SkillScope::Workspace,
2207 skills_dir: None,
2208 force: false,
2209 };
2210 let targets = resolve_targets(&spec(), &opts);
2211 assert!(targets.is_ok());
2212 if let Ok(targets) = targets {
2213 assert_eq!(targets.len(), 1);
2214 assert_eq!(targets[0].agent, SkillAgent::Codex);
2215 assert_eq!(targets[0].scope, SkillScope::Workspace);
2216 assert!(targets[0].skills_dir.ends_with(".codex/skills"));
2217 }
2218 }
2219
2220 #[test]
2221 fn hermes_workspace_scope_uses_hermes_skills_dir() {
2222 let opts = SkillOptions {
2223 agent: SkillAgentSelection::Hermes,
2224 scope: SkillScope::Workspace,
2225 skills_dir: None,
2226 force: false,
2227 };
2228 let targets = resolve_targets(&spec(), &opts);
2229 assert!(targets.is_ok());
2230 if let Ok(targets) = targets {
2231 assert_eq!(targets.len(), 1);
2232 assert_eq!(targets[0].agent, SkillAgent::Hermes);
2233 assert_eq!(targets[0].scope, SkillScope::Workspace);
2234 assert!(targets[0].skills_dir.ends_with(".hermes/skills"));
2235 }
2236 }
2237}