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