1use serde::Serialize;
13use std::io::Write;
14use std::path::{Path, PathBuf};
15
16const SKILL_FILE_NAME: &str = "SKILL.md";
17const FNV1A64_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
18const FNV1A64_PRIME: u64 = 0x0000_0100_0000_01b3;
19
20#[derive(Clone, Copy, Debug)]
25pub struct SkillAsset<'a> {
26 pub path: &'a str,
30 pub contents: &'a str,
32}
33
34#[derive(Clone, Copy, Debug)]
42pub struct SkillSpec<'a> {
43 pub name: &'a str,
45 pub source: &'a str,
47 pub title: &'a str,
49 pub marker_slug: &'a str,
51 pub assets: &'a [SkillAsset<'a>],
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum SkillAgentSelection {
60 All,
62 Codex,
64 ClaudeCode,
66 Opencode,
68 Hermes,
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
74#[serde(rename_all = "kebab-case")]
75pub enum SkillAgent {
76 Codex,
78 ClaudeCode,
80 Opencode,
82 Hermes,
84}
85
86#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
88#[serde(rename_all = "lowercase")]
89pub enum SkillScope {
90 Personal,
92 Workspace,
94}
95
96#[derive(Clone, Debug)]
98pub struct SkillOptions {
99 pub agent: SkillAgentSelection,
101 pub scope: SkillScope,
103 pub skills_dir: Option<String>,
105 pub force: bool,
107}
108
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
111pub enum SkillAction {
112 Status,
114 Install,
116 Uninstall,
118}
119
120#[derive(Clone, Debug, Serialize)]
122pub struct SkillTargetStatus {
123 pub agent: SkillAgent,
125 pub scope: SkillScope,
127 pub skills_dir: PathBuf,
129 pub skill_dir: PathBuf,
131 pub skill_path: PathBuf,
133 pub installed: bool,
135 pub managed: bool,
137 pub valid: bool,
139 pub current: bool,
141 pub validation_error: Option<String>,
143}
144
145#[derive(Clone, Debug, Serialize)]
147pub struct SkillUninstallStatus {
148 pub agent: SkillAgent,
150 pub scope: SkillScope,
152 pub skills_dir: PathBuf,
154 pub skill_dir: PathBuf,
156 pub skill_path: PathBuf,
158 pub removed: bool,
160}
161
162#[derive(Clone, Debug, Serialize)]
165#[serde(tag = "code")]
166pub enum SkillReport {
167 #[serde(rename = "skill_status")]
169 Status {
170 skill: String,
172 installed_all: bool,
174 valid_all: bool,
176 current_all: bool,
178 targets: Vec<SkillTargetStatus>,
180 },
181 #[serde(rename = "skill_install")]
183 Install {
184 skill: String,
186 installed: bool,
188 targets: Vec<SkillTargetStatus>,
190 hint: &'static str,
192 },
193 #[serde(rename = "skill_uninstall")]
195 Uninstall {
196 skill: String,
198 removed_any: bool,
200 targets: Vec<SkillUninstallStatus>,
202 },
203}
204
205#[derive(Clone, Debug)]
207pub struct SkillError {
208 pub message: String,
210 pub hint: Option<String>,
212 pub partial_report: Option<SkillReport>,
214}
215
216impl SkillError {
217 fn invalid_request(message: String, hint: Option<String>) -> Self {
218 Self {
219 message,
220 hint,
221 partial_report: None,
222 }
223 }
224
225 fn io(action: &str, err: std::io::Error) -> Self {
226 Self {
227 message: format!("{action} failed: {err}"),
228 hint: None,
229 partial_report: None,
230 }
231 }
232
233 fn with_partial_report(mut self, report: SkillReport) -> Self {
234 self.partial_report = Some(report);
235 self
236 }
237}
238
239pub fn run_skill_admin(
244 spec: &SkillSpec,
245 action: SkillAction,
246 options: &SkillOptions,
247) -> Result<SkillReport, SkillError> {
248 validate_spec(spec)?;
249 match action {
250 SkillAction::Status => status(spec, options),
251 SkillAction::Install => install(spec, options),
252 SkillAction::Uninstall => uninstall(spec, options),
253 }
254}
255
256fn status(spec: &SkillSpec, options: &SkillOptions) -> Result<SkillReport, SkillError> {
257 let targets = resolve_targets(spec, options)?;
258 let mut statuses = Vec::with_capacity(targets.len());
259 for target in &targets {
260 statuses.push(target_status(spec, target)?);
261 }
262 Ok(SkillReport::Status {
263 skill: spec.name.to_string(),
264 installed_all: statuses.iter().all(|s| s.installed),
265 valid_all: statuses.iter().all(|s| s.valid),
266 current_all: statuses.iter().all(|s| s.current),
267 targets: statuses,
268 })
269}
270
271fn install(spec: &SkillSpec, options: &SkillOptions) -> Result<SkillReport, SkillError> {
272 validate_skill_text(spec, spec.source)?;
273 for asset in spec.assets {
274 validate_asset_path(asset.path)?;
275 }
276 let targets = resolve_targets(spec, options)?;
277 let content = managed_skill_contents(spec);
278 preflight_install_targets(spec, options, &targets)?;
279 for target in &targets {
280 if let Err(err) = std::fs::create_dir_all(&target.skill_dir)
281 .map_err(|e| SkillError::io("create skill dir", e))
282 {
283 return Err(err.with_partial_report(install_report_lossy(spec, &targets, false)));
284 }
285 }
286 install_targets(spec, &targets, &content)
287}
288
289fn preflight_install_targets(
290 spec: &SkillSpec,
291 options: &SkillOptions,
292 targets: &[SkillTarget],
293) -> Result<(), SkillError> {
294 let mut failures = Vec::new();
295 for target in targets {
296 if let Some(kind) = skill_path_file_type(&target.skill_path)? {
297 if kind.is_symlink() {
298 if !options.force {
299 failures.push(format!(
300 "refusing to overwrite symlinked skill at {}",
301 target.skill_path.display()
302 ));
303 }
304 continue;
305 }
306 if !kind.is_file() {
307 failures.push(format!(
308 "refusing to overwrite non-regular skill at {}",
309 target.skill_path.display()
310 ));
311 continue;
312 }
313 if !is_managed_or_bundled_skill(spec, &target.skill_path)? && !options.force {
314 failures.push(format!(
315 "refusing to overwrite unmanaged skill at {}",
316 target.skill_path.display()
317 ));
318 }
319 }
320 }
321 if failures.is_empty() {
322 return Ok(());
323 }
324 Err(SkillError::invalid_request(
325 failures.join("; "),
326 Some("pass --force to replace unmanaged files or symlinks".to_string()),
327 )
328 .with_partial_report(install_report_lossy(spec, targets, false)))
329}
330
331fn install_targets(
332 spec: &SkillSpec,
333 targets: &[SkillTarget],
334 content: &str,
335) -> Result<SkillReport, SkillError> {
336 let mut installed = Vec::with_capacity(targets.len());
337 for target in targets {
338 if let Err(err) = write_skill_atomic(target, content) {
339 return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
340 }
341 if let Err(err) = install_target_assets(spec, target) {
342 return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
343 }
344 if let Err(err) = validate_installed_skill(spec, &target.skill_path) {
345 return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
346 }
347 match target_status(spec, target) {
348 Ok(status) => installed.push(status),
349 Err(err) => {
350 return Err(err.with_partial_report(install_report_lossy(spec, targets, false)));
351 }
352 }
353 }
354 Ok(SkillReport::Install {
355 skill: spec.name.to_string(),
356 installed: true,
357 targets: installed,
358 hint: "restart the agent so it reloads installed skills",
359 })
360}
361
362fn uninstall(spec: &SkillSpec, options: &SkillOptions) -> Result<SkillReport, SkillError> {
363 let targets = resolve_targets(spec, options)?;
364 preflight_uninstall_targets(spec, options, &targets)?;
365 let mut removed = Vec::with_capacity(targets.len());
366 for target in &targets {
367 let Some(kind) = skill_path_file_type(&target.skill_path)? else {
368 removed.push(target_uninstall_status(target, false));
369 continue;
370 };
371 if !kind.is_file() && !kind.is_symlink() {
372 let err = SkillError::invalid_request(
373 format!(
374 "refusing to remove non-regular skill at {}",
375 target.skill_path.display()
376 ),
377 None,
378 );
379 return Err(err.with_partial_report(uninstall_report_lossy(spec, &targets, &removed)));
380 }
381 if let Err(err) =
382 std::fs::remove_file(&target.skill_path).map_err(|e| SkillError::io("remove skill", e))
383 {
384 return Err(err.with_partial_report(uninstall_report_lossy(spec, &targets, &removed)));
385 }
386 remove_target_assets(spec, target);
387 let _ = std::fs::remove_dir(&target.skill_dir);
388 removed.push(target_uninstall_status(target, true));
389 }
390 Ok(SkillReport::Uninstall {
391 skill: spec.name.to_string(),
392 removed_any: removed.iter().any(|s| s.removed),
393 targets: removed,
394 })
395}
396
397fn preflight_uninstall_targets(
398 spec: &SkillSpec,
399 options: &SkillOptions,
400 targets: &[SkillTarget],
401) -> Result<(), SkillError> {
402 let mut failures = Vec::new();
403 for target in targets {
404 let Some(kind) = skill_path_file_type(&target.skill_path)? else {
405 continue;
406 };
407 if kind.is_symlink() {
408 if !options.force {
409 failures.push(format!(
410 "refusing to remove symlinked skill at {}",
411 target.skill_path.display()
412 ));
413 }
414 continue;
415 }
416 if !kind.is_file() {
417 failures.push(format!(
418 "refusing to remove non-regular skill at {}",
419 target.skill_path.display()
420 ));
421 continue;
422 }
423 if !is_managed_or_bundled_skill(spec, &target.skill_path)? && !options.force {
424 failures.push(format!(
425 "refusing to remove unmanaged skill at {}",
426 target.skill_path.display()
427 ));
428 }
429 }
430 if failures.is_empty() {
431 return Ok(());
432 }
433 Err(SkillError::invalid_request(
434 failures.join("; "),
435 Some(format!(
436 "only skills generated by {} skill install can be removed without --force",
437 spec.marker_slug
438 )),
439 )
440 .with_partial_report(uninstall_report_lossy(spec, targets, &[])))
441}
442
443struct SkillTarget {
444 agent: SkillAgent,
445 scope: SkillScope,
446 skills_dir: PathBuf,
447 skill_dir: PathBuf,
448 skill_path: PathBuf,
449}
450
451fn resolve_targets(
452 spec: &SkillSpec,
453 options: &SkillOptions,
454) -> Result<Vec<SkillTarget>, SkillError> {
455 if options.skills_dir.is_some() && options.agent == SkillAgentSelection::All {
456 return Err(SkillError::invalid_request(
457 "--skills-dir requires a single --agent".to_string(),
458 Some("custom skills directories are ambiguous when --agent all is used".to_string()),
459 ));
460 }
461 match (options.agent, options.scope) {
462 (SkillAgentSelection::All, SkillScope::Personal) => Ok(vec![
463 resolve_target(spec, SkillAgent::Codex, SkillScope::Personal, None)?,
464 resolve_target(spec, SkillAgent::ClaudeCode, SkillScope::Personal, None)?,
465 resolve_target(spec, SkillAgent::Opencode, SkillScope::Personal, None)?,
466 resolve_target(spec, SkillAgent::Hermes, SkillScope::Personal, None)?,
467 ]),
468 (SkillAgentSelection::All, SkillScope::Workspace) => Ok(vec![
469 resolve_target(spec, SkillAgent::Codex, SkillScope::Workspace, None)?,
470 resolve_target(spec, SkillAgent::ClaudeCode, SkillScope::Workspace, None)?,
471 resolve_target(spec, SkillAgent::Opencode, SkillScope::Workspace, None)?,
472 resolve_target(spec, SkillAgent::Hermes, SkillScope::Workspace, None)?,
473 ]),
474 (SkillAgentSelection::Codex, SkillScope::Workspace) => Ok(vec![resolve_target(
475 spec,
476 SkillAgent::Codex,
477 SkillScope::Workspace,
478 options.skills_dir.as_deref(),
479 )?]),
480 (SkillAgentSelection::Codex, SkillScope::Personal) => Ok(vec![resolve_target(
481 spec,
482 SkillAgent::Codex,
483 SkillScope::Personal,
484 options.skills_dir.as_deref(),
485 )?]),
486 (SkillAgentSelection::ClaudeCode, scope) => Ok(vec![resolve_target(
487 spec,
488 SkillAgent::ClaudeCode,
489 scope,
490 options.skills_dir.as_deref(),
491 )?]),
492 (SkillAgentSelection::Opencode, scope) => Ok(vec![resolve_target(
493 spec,
494 SkillAgent::Opencode,
495 scope,
496 options.skills_dir.as_deref(),
497 )?]),
498 (SkillAgentSelection::Hermes, scope) => Ok(vec![resolve_target(
499 spec,
500 SkillAgent::Hermes,
501 scope,
502 options.skills_dir.as_deref(),
503 )?]),
504 }
505}
506
507fn resolve_target(
508 spec: &SkillSpec,
509 agent: SkillAgent,
510 scope: SkillScope,
511 skills_dir: Option<&str>,
512) -> Result<SkillTarget, SkillError> {
513 let skills_dir = match skills_dir {
514 Some(dir) => expand_tilde(dir)?,
515 None => default_skills_dir(agent, scope)?,
516 };
517 let skill_dir = skills_dir.join(spec.name);
518 let skill_path = skill_dir.join(SKILL_FILE_NAME);
519 Ok(SkillTarget {
520 agent,
521 scope,
522 skills_dir,
523 skill_dir,
524 skill_path,
525 })
526}
527
528fn default_skills_dir(agent: SkillAgent, scope: SkillScope) -> Result<PathBuf, SkillError> {
529 match (agent, scope) {
530 (SkillAgent::Codex, SkillScope::Personal) => {
531 if let Some(codex_home) = std::env::var_os("CODEX_HOME") {
532 Ok(PathBuf::from(codex_home).join("skills"))
533 } else {
534 Ok(home_dir()?.join(".codex").join("skills"))
535 }
536 }
537 (SkillAgent::Codex, SkillScope::Workspace) => workspace_skills_dir(".codex"),
538 (SkillAgent::ClaudeCode, SkillScope::Personal) => {
539 Ok(home_dir()?.join(".claude").join("skills"))
540 }
541 (SkillAgent::ClaudeCode, SkillScope::Workspace) => workspace_skills_dir(".claude"),
542 (SkillAgent::Opencode, SkillScope::Personal) => {
543 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
544 Ok(PathBuf::from(xdg).join("opencode").join("skills"))
545 } else {
546 Ok(home_dir()?.join(".config").join("opencode").join("skills"))
547 }
548 }
549 (SkillAgent::Opencode, SkillScope::Workspace) => workspace_skills_dir(".opencode"),
550 (SkillAgent::Hermes, SkillScope::Personal) => {
551 if let Some(hermes_home) = std::env::var_os("HERMES_HOME") {
552 Ok(PathBuf::from(hermes_home).join("skills"))
553 } else {
554 Ok(home_dir()?.join(".hermes").join("skills"))
555 }
556 }
557 (SkillAgent::Hermes, SkillScope::Workspace) => workspace_skills_dir(".hermes"),
558 }
559}
560
561fn workspace_skills_dir(agent_dir: &str) -> Result<PathBuf, SkillError> {
562 std::env::current_dir()
563 .map(|dir| dir.join(agent_dir).join("skills"))
564 .map_err(|e| SkillError::io("resolve current directory", e))
565}
566
567fn target_status(spec: &SkillSpec, target: &SkillTarget) -> Result<SkillTargetStatus, SkillError> {
568 let Some(kind) = skill_path_file_type(&target.skill_path)? else {
569 return Ok(SkillTargetStatus {
570 agent: target.agent,
571 scope: target.scope,
572 skills_dir: target.skills_dir.clone(),
573 skill_dir: target.skill_dir.clone(),
574 skill_path: target.skill_path.clone(),
575 installed: false,
576 managed: false,
577 valid: false,
578 current: false,
579 validation_error: None,
580 });
581 };
582 let installed = true;
583 let mut valid = false;
584 let mut current = false;
585 let mut validation_error = None;
586 let mut managed = false;
587 if kind.is_symlink() {
588 validation_error = Some("target SKILL.md is a symlink; refusing to follow it".to_string());
589 } else if kind.is_file() {
590 let text = std::fs::read_to_string(&target.skill_path)
591 .map_err(|e| SkillError::io("read skill", e))?;
592 managed = skill_text_is_managed_or_bundled(spec, &text);
593 current = normalized_content_hash(spec, &text) == source_hash(spec)
594 && assets_current(spec, &target.skill_dir);
595 match validate_skill_text(spec, &text) {
596 Ok(()) => valid = true,
597 Err(err) => validation_error = Some(err.message),
598 }
599 } else {
600 validation_error = Some("target SKILL.md is not a regular file".to_string());
601 }
602 Ok(SkillTargetStatus {
603 agent: target.agent,
604 scope: target.scope,
605 skills_dir: target.skills_dir.clone(),
606 skill_dir: target.skill_dir.clone(),
607 skill_path: target.skill_path.clone(),
608 installed,
609 managed,
610 valid,
611 current,
612 validation_error,
613 })
614}
615
616fn target_uninstall_status(target: &SkillTarget, removed: bool) -> SkillUninstallStatus {
617 SkillUninstallStatus {
618 agent: target.agent,
619 scope: target.scope,
620 skills_dir: target.skills_dir.clone(),
621 skill_dir: target.skill_dir.clone(),
622 skill_path: target.skill_path.clone(),
623 removed,
624 }
625}
626
627fn target_status_lossy(spec: &SkillSpec, target: &SkillTarget) -> SkillTargetStatus {
628 target_status(spec, target).unwrap_or_else(|err| {
629 let installed = std::fs::symlink_metadata(&target.skill_path).is_ok();
630 SkillTargetStatus {
631 agent: target.agent,
632 scope: target.scope,
633 skills_dir: target.skills_dir.clone(),
634 skill_dir: target.skill_dir.clone(),
635 skill_path: target.skill_path.clone(),
636 installed,
637 managed: false,
638 valid: false,
639 current: false,
640 validation_error: Some(err.message),
641 }
642 })
643}
644
645fn install_report_lossy(spec: &SkillSpec, targets: &[SkillTarget], installed: bool) -> SkillReport {
646 SkillReport::Install {
647 skill: spec.name.to_string(),
648 installed,
649 targets: targets
650 .iter()
651 .map(|target| target_status_lossy(spec, target))
652 .collect(),
653 hint: "restart the agent so it reloads installed skills",
654 }
655}
656
657fn uninstall_report_lossy(
658 spec: &SkillSpec,
659 targets: &[SkillTarget],
660 removed: &[SkillUninstallStatus],
661) -> SkillReport {
662 let mut statuses = Vec::with_capacity(targets.len());
663 for target in targets {
664 if let Some(status) = removed.iter().find(|status| {
665 status.agent == target.agent
666 && status.scope == target.scope
667 && status.skill_path == target.skill_path
668 }) {
669 statuses.push(status.clone());
670 } else {
671 statuses.push(target_uninstall_status(target, false));
672 }
673 }
674 SkillReport::Uninstall {
675 skill: spec.name.to_string(),
676 removed_any: statuses.iter().any(|status| status.removed),
677 targets: statuses,
678 }
679}
680
681fn generated_by(spec: &SkillSpec) -> String {
682 format!("Generated by {} skill install", spec.marker_slug)
683}
684
685fn text_hash(text: &str) -> String {
686 let mut hash = FNV1A64_OFFSET;
687 for byte in text.as_bytes() {
688 hash ^= u64::from(*byte);
689 hash = hash.wrapping_mul(FNV1A64_PRIME);
690 }
691 format!("{hash:016x}")
692}
693
694fn source_hash(spec: &SkillSpec) -> String {
695 normalized_content_hash(spec, spec.source)
696}
697
698fn normalized_content_hash(spec: &SkillSpec, text: &str) -> String {
699 text_hash(&normalize_skill_text(spec, text))
700}
701
702fn managed_marker_block(spec: &SkillSpec) -> String {
703 let slug = spec.marker_slug;
704 format!(
705 "<!--\n{}\n{}-managed-skill: true\n{}-managed-skill-name: {}\n{}-managed-skill-owner: {}\n{}-managed-skill-content-hash-fnv1a64: {}\n-->",
706 generated_by(spec),
707 slug,
708 slug,
709 spec.name,
710 slug,
711 slug,
712 slug,
713 source_hash(spec)
714 )
715}
716
717fn managed_skill_contents(spec: &SkillSpec) -> String {
718 let block = managed_marker_block(spec);
719 let mut lines = spec.source.lines();
720 let mut output = String::new();
721 let mut inserted = false;
722 if let Some(first) = lines.next() {
723 output.push_str(first);
724 output.push('\n');
725 }
726 for line in lines {
727 output.push_str(line);
728 output.push('\n');
729 if !inserted && line.trim() == "---" {
730 output.push_str(&block);
731 output.push_str("\n\n");
732 inserted = true;
733 }
734 }
735 if !inserted {
736 output.push_str(&block);
737 output.push('\n');
738 }
739 output
740}
741
742fn validate_installed_skill(spec: &SkillSpec, path: &Path) -> Result<(), SkillError> {
743 let text =
744 std::fs::read_to_string(path).map_err(|e| SkillError::io("read installed skill", e))?;
745 validate_skill_text(spec, &text)
746}
747
748fn validate_skill_text(spec: &SkillSpec, text: &str) -> Result<(), SkillError> {
749 crate::skill::validate_skill_named(text, spec.name).map_err(|err| {
750 SkillError::invalid_request(
751 format!("invalid {} skill front matter: {err}", spec.title),
752 Some(format!(
753 "make SKILL.md metadata conform to the Agent Skills specification and set name to {}",
754 spec.name
755 )),
756 )
757 })?;
758 Ok(())
759}
760
761fn is_managed_or_bundled_skill(spec: &SkillSpec, path: &Path) -> Result<bool, SkillError> {
762 let Some(kind) = skill_path_file_type(path)? else {
763 return Ok(false);
764 };
765 if kind.is_symlink() {
766 return Err(SkillError::invalid_request(
767 format!("refusing to inspect symlinked skill at {}", path.display()),
768 Some("pass --force to replace or remove the symlink itself".to_string()),
769 ));
770 }
771 let text = std::fs::read_to_string(path).map_err(|e| SkillError::io("read skill", e))?;
772 Ok(skill_text_is_managed_or_bundled(spec, &text))
773}
774
775fn skill_text_is_managed_or_bundled(spec: &SkillSpec, text: &str) -> bool {
776 skill_text_has_managed_identity(spec, text)
777 || normalize_skill_text(spec, text) == normalize_skill_text(spec, spec.source)
778}
779
780fn skill_text_has_managed_identity(spec: &SkillSpec, text: &str) -> bool {
781 for block in html_comment_blocks(text) {
782 if managed_marker_block_has_identity(spec, &block) {
783 return true;
784 }
785 }
786 false
787}
788
789fn managed_marker_block_has_identity(spec: &SkillSpec, block: &str) -> bool {
790 let slug = spec.marker_slug;
791 let generated_by_line = generated_by(spec);
792 let managed_line = format!("{slug}-managed-skill: true");
793 let name_line = format!("{slug}-managed-skill-name: {}", spec.name);
794 let owner_line = format!("{slug}-managed-skill-owner: {slug}");
795 let mut has_generated_by = false;
796 let mut has_managed = false;
797 let mut has_name = false;
798 let mut has_owner = false;
799 for line in block.replace("\r\n", "\n").lines() {
800 let trimmed = line.trim();
801 has_generated_by |= trimmed == generated_by_line;
802 has_managed |= trimmed == managed_line;
803 has_name |= trimmed == name_line;
804 has_owner |= trimmed == owner_line;
805 }
806 has_generated_by && has_managed && has_name && has_owner
807}
808
809fn html_comment_blocks(text: &str) -> Vec<String> {
810 let normalized = text.replace("\r\n", "\n");
811 let mut blocks = Vec::new();
812 let mut lines = normalized.lines();
813 while let Some(line) = lines.next() {
814 if line.trim() != "<!--" {
815 continue;
816 }
817 let mut block = vec![line.to_string()];
818 for next in lines.by_ref() {
819 block.push(next.to_string());
820 if next.trim() == "-->" {
821 blocks.push(block.join("\n"));
822 break;
823 }
824 }
825 }
826 blocks
827}
828
829fn strip_managed_marker_blocks(spec: &SkillSpec, text: &str) -> String {
830 let normalized = text.replace("\r\n", "\n");
831 let mut output = Vec::new();
832 let mut lines = normalized.lines();
833 while let Some(line) = lines.next() {
834 if line.trim() != "<!--" {
835 output.push(line.to_string());
836 continue;
837 }
838 let mut block = vec![line.to_string()];
839 let mut closed = false;
840 for next in lines.by_ref() {
841 block.push(next.to_string());
842 if next.trim() == "-->" {
843 closed = true;
844 break;
845 }
846 }
847 if closed {
848 let block_text = block.join("\n");
849 if managed_marker_block_has_identity(spec, &block_text) {
850 continue;
851 }
852 }
853 output.extend(block);
854 }
855 output.join("\n")
856}
857
858fn normalize_skill_text(spec: &SkillSpec, text: &str) -> String {
859 let text = strip_managed_marker_blocks(spec, text);
860 let mut out: Vec<&str> = Vec::new();
864 for line in text.lines() {
865 let trimmed = line.trim();
866 if trimmed.is_empty() && out.last().is_some_and(|prev| prev.trim().is_empty()) {
867 continue;
868 }
869 out.push(line);
870 }
871 out.join("\n").trim().to_string()
872}
873
874fn validate_spec(spec: &SkillSpec) -> Result<(), SkillError> {
875 validate_slug("skill name", spec.name)?;
876 validate_slug("marker slug", spec.marker_slug)?;
877 validate_skill_text(spec, spec.source)
878}
879
880fn validate_slug(field: &str, value: &str) -> Result<(), SkillError> {
881 if slug_is_valid(value) {
882 return Ok(());
883 }
884 Err(SkillError::invalid_request(
885 format!(
886 "invalid {field} {value:?}: expected a lowercase slug matching [a-z0-9][a-z0-9-]*[a-z0-9]"
887 ),
888 Some("use lowercase ASCII letters, digits, and single hyphen-separated words".to_string()),
889 ))
890}
891
892fn slug_is_valid(value: &str) -> bool {
893 let bytes = value.as_bytes();
894 if bytes.is_empty() {
895 return false;
896 }
897 fn is_lower_alnum(byte: u8) -> bool {
898 byte.is_ascii_lowercase() || byte.is_ascii_digit()
899 }
900 if !is_lower_alnum(bytes[0]) || !is_lower_alnum(bytes[bytes.len() - 1]) {
901 return false;
902 }
903 bytes
904 .iter()
905 .all(|byte| is_lower_alnum(*byte) || *byte == b'-')
906}
907
908fn skill_path_file_type(path: &Path) -> Result<Option<std::fs::FileType>, SkillError> {
909 match std::fs::symlink_metadata(path) {
910 Ok(metadata) => Ok(Some(metadata.file_type())),
911 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
912 Err(err) => Err(SkillError::io("inspect skill path", err)),
913 }
914}
915
916fn write_skill_atomic(target: &SkillTarget, content: &str) -> Result<(), SkillError> {
917 write_file_atomic(&target.skill_path, content)
918}
919
920fn write_file_atomic(path: &Path, content: &str) -> Result<(), SkillError> {
924 let parent = path.parent().ok_or_else(|| {
925 SkillError::io(
926 "resolve skill file parent",
927 std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
928 )
929 })?;
930 let file_name = path
931 .file_name()
932 .and_then(|name| name.to_str())
933 .unwrap_or(SKILL_FILE_NAME);
934 let nanos = std::time::SystemTime::now()
935 .duration_since(std::time::UNIX_EPOCH)
936 .map(|d| d.as_nanos())
937 .unwrap_or(0);
938 for attempt in 0..16 {
939 let tmp_path = parent.join(format!(
940 ".{file_name}.{}.{}.{}.tmp",
941 std::process::id(),
942 nanos,
943 attempt
944 ));
945 let mut tmp = match std::fs::OpenOptions::new()
946 .write(true)
947 .create_new(true)
948 .open(&tmp_path)
949 {
950 Ok(file) => file,
951 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
952 Err(err) => return Err(SkillError::io("create temporary skill", err)),
953 };
954 let result = (|| {
955 tmp.write_all(content.as_bytes())
956 .map_err(|e| SkillError::io("write temporary skill", e))?;
957 tmp.sync_all()
958 .map_err(|e| SkillError::io("sync temporary skill", e))?;
959 drop(tmp);
960 std::fs::rename(&tmp_path, path).map_err(|e| SkillError::io("replace skill", e))?;
961 Ok(())
962 })();
963 if result.is_err() {
964 let _ = std::fs::remove_file(&tmp_path);
965 }
966 return result;
967 }
968 Err(SkillError::io(
969 "create temporary skill",
970 std::io::Error::new(
971 std::io::ErrorKind::AlreadyExists,
972 "temporary skill path collision",
973 ),
974 ))
975}
976
977fn validate_asset_path(path: &str) -> Result<(), SkillError> {
980 let bad = path.is_empty()
981 || Path::new(path).is_absolute()
982 || path
983 .split(['/', '\\'])
984 .any(|seg| seg.is_empty() || seg == "." || seg == "..");
985 if bad {
986 return Err(SkillError::invalid_request(
987 format!("invalid skill asset path: {path}"),
988 Some(
989 "asset paths must be relative to the skill directory with no `.`/`..` segments"
990 .to_string(),
991 ),
992 ));
993 }
994 Ok(())
995}
996
997fn asset_target_path(skill_dir: &Path, rel_path: &str) -> PathBuf {
1000 let mut out = skill_dir.to_path_buf();
1001 for seg in rel_path.split(['/', '\\']) {
1002 out.push(seg);
1003 }
1004 out
1005}
1006
1007fn install_target_assets(spec: &SkillSpec, target: &SkillTarget) -> Result<(), SkillError> {
1010 for asset in spec.assets {
1011 let dest = asset_target_path(&target.skill_dir, asset.path);
1012 if let Some(parent) = dest.parent() {
1013 std::fs::create_dir_all(parent)
1014 .map_err(|e| SkillError::io("create skill asset dir", e))?;
1015 }
1016 write_file_atomic(&dest, asset.contents)?;
1017 }
1018 Ok(())
1019}
1020
1021fn assets_current(spec: &SkillSpec, skill_dir: &Path) -> bool {
1024 spec.assets.iter().all(|asset| {
1025 let dest = asset_target_path(skill_dir, asset.path);
1026 std::fs::read_to_string(&dest)
1027 .map(|text| text == asset.contents)
1028 .unwrap_or(false)
1029 })
1030}
1031
1032fn remove_target_assets(spec: &SkillSpec, target: &SkillTarget) {
1036 let mut dirs: Vec<PathBuf> = Vec::new();
1037 for asset in spec.assets {
1038 let dest = asset_target_path(&target.skill_dir, asset.path);
1039 let _ = std::fs::remove_file(&dest);
1040 let mut dir = dest.parent().map(Path::to_path_buf);
1041 while let Some(current) = dir {
1042 if current == target.skill_dir || !current.starts_with(&target.skill_dir) {
1043 break;
1044 }
1045 if !dirs.contains(¤t) {
1046 dirs.push(current.clone());
1047 }
1048 dir = current.parent().map(Path::to_path_buf);
1049 }
1050 }
1051 dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count()));
1052 for dir in dirs {
1053 let _ = std::fs::remove_dir(&dir);
1054 }
1055}
1056
1057fn home_dir() -> Result<PathBuf, SkillError> {
1058 std::env::var_os("HOME")
1059 .or_else(|| std::env::var_os("USERPROFILE"))
1060 .map(PathBuf::from)
1061 .ok_or_else(|| {
1062 SkillError::invalid_request(
1063 "cannot determine home directory".to_string(),
1064 Some("pass --skills-dir explicitly".to_string()),
1065 )
1066 })
1067}
1068
1069fn expand_tilde(input: &str) -> Result<PathBuf, SkillError> {
1070 if input == "~" {
1071 return home_dir();
1072 }
1073 if let Some(rest) = input.strip_prefix("~/") {
1074 return Ok(home_dir()?.join(rest));
1075 }
1076 Ok(PathBuf::from(input))
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081 use super::*;
1082 use std::time::{SystemTime, UNIX_EPOCH};
1083
1084 const SKILL_SOURCE: &str =
1085 "---\nname: agent-first-test\ndescription: test skill\n---\n\n# Body\n\nrules.\n";
1086
1087 fn spec() -> SkillSpec<'static> {
1088 SkillSpec {
1089 name: "agent-first-test",
1090 source: SKILL_SOURCE,
1091 title: "Agent-First Test",
1092 marker_slug: "aftest",
1093 assets: &[],
1094 }
1095 }
1096
1097 fn managed_skill_with_body(body: &str) -> String {
1098 format!(
1099 "---\nname: agent-first-test\ndescription: test skill\n---\n{}\n\n{body}",
1100 managed_marker_block(&spec())
1101 )
1102 }
1103
1104 fn temp_skills_dir(name: &str) -> PathBuf {
1105 let suffix = SystemTime::now()
1106 .duration_since(UNIX_EPOCH)
1107 .map(|d| d.as_nanos())
1108 .unwrap_or(0);
1109 std::env::temp_dir().join(format!(
1110 "afdata_skill_{name}_{}_{}",
1111 std::process::id(),
1112 suffix
1113 ))
1114 }
1115
1116 fn options(agent: SkillAgentSelection, dir: &Path, force: bool) -> SkillOptions {
1117 SkillOptions {
1118 agent,
1119 scope: SkillScope::Personal,
1120 skills_dir: Some(dir.to_string_lossy().to_string()),
1121 force,
1122 }
1123 }
1124
1125 fn custom_target(agent: SkillAgent, dir: &Path) -> SkillTarget {
1126 let skill_dir = dir.join("agent-first-test");
1127 SkillTarget {
1128 agent,
1129 scope: SkillScope::Personal,
1130 skills_dir: dir.to_path_buf(),
1131 skill_path: skill_dir.join(SKILL_FILE_NAME),
1132 skill_dir,
1133 }
1134 }
1135
1136 #[test]
1137 fn validates_bundled_frontmatter() {
1138 assert!(crate::skill::validate_skill_named(SKILL_SOURCE, "agent-first-test").is_ok());
1139 }
1140
1141 #[test]
1142 fn rejects_unquoted_colon_space() {
1143 let bad = "---\nname: x\ndescription: broken: yaml\n---\n";
1144 assert!(crate::skill::validate_skill(bad).is_err());
1145 }
1146
1147 fn install_status_uninstall_for(agent: SkillAgentSelection, expect: SkillAgent, tag: &str) {
1148 let dir = temp_skills_dir(tag);
1149 let opts = options(agent, &dir, false);
1150 let skill_path = dir.join("agent-first-test").join(SKILL_FILE_NAME);
1151
1152 let installed = run_skill_admin(&spec(), SkillAction::Install, &opts);
1153 assert!(installed.is_ok());
1154 assert!(skill_path.is_file());
1155 let text = std::fs::read_to_string(&skill_path).unwrap_or_default();
1156 assert!(text.contains(&managed_marker_block(&spec())));
1157 assert!(text.contains("aftest-managed-skill-name: agent-first-test"));
1158 assert!(text.contains("aftest-managed-skill-owner: aftest"));
1159 assert!(text.contains("aftest-managed-skill-content-hash-fnv1a64:"));
1160 assert!(!text.contains("aftest-managed-skill-source-hash-fnv1a64:"));
1161
1162 let status = run_skill_admin(&spec(), SkillAction::Status, &opts);
1163 assert!(status.is_ok());
1164 if let Ok(SkillReport::Status {
1165 installed_all,
1166 valid_all,
1167 current_all,
1168 targets,
1169 ..
1170 }) = status
1171 {
1172 assert!(installed_all);
1173 assert!(valid_all);
1174 assert!(current_all);
1175 assert_eq!(targets.first().map(|t| t.agent), Some(expect));
1176 assert_eq!(targets.first().map(|t| t.current), Some(true));
1177 }
1178
1179 let removed = run_skill_admin(&spec(), SkillAction::Uninstall, &opts);
1180 assert!(removed.is_ok());
1181 assert!(!skill_path.exists());
1182 let _ = std::fs::remove_dir_all(dir);
1183 }
1184
1185 #[test]
1186 fn install_status_uninstall_codex() {
1187 install_status_uninstall_for(SkillAgentSelection::Codex, SkillAgent::Codex, "codex");
1188 }
1189
1190 #[test]
1191 fn install_status_uninstall_claude_code() {
1192 install_status_uninstall_for(
1193 SkillAgentSelection::ClaudeCode,
1194 SkillAgent::ClaudeCode,
1195 "claude",
1196 );
1197 }
1198
1199 #[test]
1200 fn install_status_uninstall_opencode() {
1201 install_status_uninstall_for(
1202 SkillAgentSelection::Opencode,
1203 SkillAgent::Opencode,
1204 "opencode",
1205 );
1206 }
1207
1208 #[test]
1209 fn install_status_uninstall_hermes() {
1210 install_status_uninstall_for(SkillAgentSelection::Hermes, SkillAgent::Hermes, "hermes");
1211 }
1212
1213 fn spec_with_assets() -> SkillSpec<'static> {
1214 const ASSETS: &[SkillAsset] = &[
1215 SkillAsset {
1216 path: "references/guide.md",
1217 contents: "# guide\n",
1218 },
1219 SkillAsset {
1220 path: "references/registry.json",
1221 contents: "{\"ok\":true}\n",
1222 },
1223 ];
1224 SkillSpec {
1225 name: "agent-first-test",
1226 source: SKILL_SOURCE,
1227 title: "Agent-First Test",
1228 marker_slug: "aftest",
1229 assets: ASSETS,
1230 }
1231 }
1232
1233 #[test]
1234 fn install_writes_and_uninstall_removes_bundled_assets() {
1235 let dir = temp_skills_dir("assets");
1236 let opts = options(SkillAgentSelection::Codex, &dir, false);
1237 let skill_dir = dir.join("agent-first-test");
1238 let guide = skill_dir.join("references").join("guide.md");
1239 let registry = skill_dir.join("references").join("registry.json");
1240
1241 assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_ok());
1242 assert_eq!(
1243 std::fs::read_to_string(&guide).unwrap_or_default(),
1244 "# guide\n"
1245 );
1246 assert_eq!(
1247 std::fs::read_to_string(®istry).unwrap_or_default(),
1248 "{\"ok\":true}\n"
1249 );
1250
1251 std::fs::remove_file(&guide).unwrap();
1255 match run_skill_admin(&spec_with_assets(), SkillAction::Status, &opts) {
1256 Ok(SkillReport::Status { current_all, .. }) => {
1257 assert!(
1258 !current_all,
1259 "a missing asset must make the skill not-current"
1260 );
1261 }
1262 other => panic!("unexpected status: {other:?}"),
1263 }
1264 assert!(run_skill_admin(&spec_with_assets(), SkillAction::Install, &opts).is_ok());
1265 assert!(guide.is_file(), "re-install must restore the missing asset");
1266
1267 assert!(run_skill_admin(&spec_with_assets(), SkillAction::Uninstall, &opts).is_ok());
1268 assert!(!skill_dir.join(SKILL_FILE_NAME).exists());
1269 assert!(!guide.exists(), "uninstall must remove bundled assets");
1270 assert!(
1271 !skill_dir.join("references").exists(),
1272 "uninstall must remove now-empty asset directories"
1273 );
1274 assert!(
1275 !skill_dir.exists(),
1276 "uninstall must remove the skill directory"
1277 );
1278 let _ = std::fs::remove_dir_all(dir);
1279 }
1280
1281 #[test]
1282 fn install_rejects_escaping_asset_path() {
1283 const ESCAPE: &[SkillAsset] = &[SkillAsset {
1284 path: "../evil.md",
1285 contents: "x",
1286 }];
1287 let dir = temp_skills_dir("assets-escape");
1288 let opts = options(SkillAgentSelection::Codex, &dir, false);
1289 let spec = SkillSpec {
1290 name: "agent-first-test",
1291 source: SKILL_SOURCE,
1292 title: "Agent-First Test",
1293 marker_slug: "aftest",
1294 assets: ESCAPE,
1295 };
1296 assert!(
1297 run_skill_admin(&spec, SkillAction::Install, &opts).is_err(),
1298 "an asset path escaping the skill dir must be rejected"
1299 );
1300 assert!(!dir.join("evil.md").exists());
1301 let _ = std::fs::remove_dir_all(dir);
1302 }
1303
1304 #[test]
1305 fn status_reports_stale_install_as_not_current() {
1306 let dir = temp_skills_dir("stale");
1307 let opts = options(SkillAgentSelection::Opencode, &dir, false);
1308 let skill_dir = dir.join("agent-first-test");
1309 let skill_path = skill_dir.join(SKILL_FILE_NAME);
1310 assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1311 let stale = managed_skill_with_body("# Body\n\nOLD rules.\n");
1313 assert!(std::fs::write(&skill_path, stale).is_ok());
1314
1315 let status = run_skill_admin(&spec(), SkillAction::Status, &opts);
1316 if let Ok(SkillReport::Status {
1317 current_all,
1318 targets,
1319 ..
1320 }) = status
1321 {
1322 assert!(!current_all);
1323 if let Some(t) = targets.first() {
1324 assert!(t.installed);
1325 assert!(t.valid);
1326 assert!(t.managed);
1327 assert!(!t.current);
1328 }
1329 }
1330
1331 assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_ok());
1333 let refreshed = std::fs::read_to_string(&skill_path).unwrap_or_default();
1334 assert!(refreshed.contains(&managed_marker_block(&spec())));
1335 assert!(!refreshed.contains("<!-- aftest-managed-skill: true -->"));
1336 if let Ok(SkillReport::Status { targets, .. }) =
1337 run_skill_admin(&spec(), SkillAction::Status, &opts)
1338 {
1339 assert_eq!(targets.first().map(|t| t.current), Some(true));
1340 }
1341 let _ = std::fs::remove_dir_all(dir);
1342 }
1343
1344 #[test]
1345 fn random_text_with_marker_words_is_not_managed() {
1346 let dir = temp_skills_dir("marker-words");
1347 let opts = options(SkillAgentSelection::Opencode, &dir, false);
1348 let skill_dir = dir.join("agent-first-test");
1349 let skill_path = skill_dir.join(SKILL_FILE_NAME);
1350 assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1351 let random = format!(
1352 "---\nname: agent-first-test\ndescription: test skill\n---\n\nThis mentions {} and {} but is not a generated block.\n",
1353 generated_by(&spec()),
1354 "aftest-managed-skill: true"
1355 );
1356 assert!(std::fs::write(&skill_path, random).is_ok());
1357
1358 if let Ok(SkillReport::Status { targets, .. }) =
1359 run_skill_admin(&spec(), SkillAction::Status, &opts)
1360 {
1361 assert_eq!(targets.first().map(|t| t.managed), Some(false));
1362 }
1363 assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
1364 let _ = std::fs::remove_dir_all(dir);
1365 }
1366
1367 #[test]
1368 fn old_marker_format_is_not_managed() {
1369 let dir = temp_skills_dir("old-marker");
1370 let opts = options(SkillAgentSelection::Opencode, &dir, false);
1371 let skill_dir = dir.join("agent-first-test");
1372 let skill_path = skill_dir.join(SKILL_FILE_NAME);
1373 assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1374 let old_marker = concat!(
1375 "---\n",
1376 "name: agent-first-test\n",
1377 "description: test skill\n",
1378 "---\n",
1379 "<!--\n",
1380 "Generated by aftest skill install\n",
1381 "aftest-managed-skill: true\n",
1382 "aftest-managed-skill-name: agent-first-test\n",
1383 "aftest-managed-skill-source-hash-fnv1a64: deadbeef\n",
1384 "-->\n",
1385 "\n",
1386 "# Body\n",
1387 "\n",
1388 "rules.\n"
1389 );
1390 assert!(std::fs::write(&skill_path, old_marker).is_ok());
1391
1392 if let Ok(SkillReport::Status { targets, .. }) =
1393 run_skill_admin(&spec(), SkillAction::Status, &opts)
1394 && let Some(t) = targets.first()
1395 {
1396 assert!(t.installed);
1397 assert!(t.valid);
1398 assert!(!t.managed);
1399 assert!(!t.current);
1400 }
1401 assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
1402 let _ = std::fs::remove_dir_all(dir);
1403 }
1404
1405 #[test]
1406 fn install_preflight_reports_all_targets_without_writing() {
1407 let dir = temp_skills_dir("install-preflight");
1408 let codex = custom_target(SkillAgent::Codex, &dir.join("codex"));
1409 let opencode = custom_target(SkillAgent::Opencode, &dir.join("opencode"));
1410 assert!(std::fs::create_dir_all(&opencode.skill_dir).is_ok());
1411 assert!(
1412 std::fs::write(
1413 &opencode.skill_path,
1414 "---\nname: custom\ndescription: custom\n---\n"
1415 )
1416 .is_ok()
1417 );
1418 let opts = SkillOptions {
1419 agent: SkillAgentSelection::All,
1420 scope: SkillScope::Personal,
1421 skills_dir: None,
1422 force: false,
1423 };
1424
1425 let result = preflight_install_targets(&spec(), &opts, &[codex, opencode]);
1426 assert!(result.is_err());
1427 let Err(err) = result else {
1428 return;
1429 };
1430 assert!(
1431 err.message
1432 .contains("refusing to overwrite unmanaged skill")
1433 );
1434 assert!(!dir.join("codex").join("agent-first-test").exists());
1435 let partial_report = err.partial_report;
1436 assert!(matches!(partial_report, Some(SkillReport::Install { .. })));
1437 let Some(SkillReport::Install {
1438 installed, targets, ..
1439 }) = partial_report
1440 else {
1441 return;
1442 };
1443 assert!(!installed);
1444 assert_eq!(targets.len(), 2);
1445 assert_eq!(targets.first().map(|target| target.installed), Some(false));
1446 assert_eq!(targets.get(1).map(|target| target.installed), Some(true));
1447 assert_eq!(targets.get(1).map(|target| target.managed), Some(false));
1448 let _ = std::fs::remove_dir_all(dir);
1449 }
1450
1451 #[test]
1452 fn uninstall_preflight_reports_all_targets_without_removing() {
1453 let dir = temp_skills_dir("uninstall-preflight");
1454 let codex = custom_target(SkillAgent::Codex, &dir.join("codex"));
1455 let opencode = custom_target(SkillAgent::Opencode, &dir.join("opencode"));
1456 assert!(std::fs::create_dir_all(&codex.skill_dir).is_ok());
1457 assert!(std::fs::create_dir_all(&opencode.skill_dir).is_ok());
1458 assert!(std::fs::write(&codex.skill_path, managed_skill_contents(&spec())).is_ok());
1459 assert!(
1460 std::fs::write(
1461 &opencode.skill_path,
1462 "---\nname: custom\ndescription: custom\n---\n"
1463 )
1464 .is_ok()
1465 );
1466 let opts = SkillOptions {
1467 agent: SkillAgentSelection::All,
1468 scope: SkillScope::Personal,
1469 skills_dir: None,
1470 force: false,
1471 };
1472
1473 let result = preflight_uninstall_targets(&spec(), &opts, &[codex, opencode]);
1474 assert!(result.is_err());
1475 let Err(err) = result else {
1476 return;
1477 };
1478 assert!(err.message.contains("refusing to remove unmanaged skill"));
1479 assert!(
1480 dir.join("codex")
1481 .join("agent-first-test")
1482 .join(SKILL_FILE_NAME)
1483 .exists()
1484 );
1485 let partial_report = err.partial_report;
1486 assert!(matches!(
1487 partial_report,
1488 Some(SkillReport::Uninstall { .. })
1489 ));
1490 let Some(SkillReport::Uninstall {
1491 removed_any,
1492 targets,
1493 ..
1494 }) = partial_report
1495 else {
1496 return;
1497 };
1498 assert!(!removed_any);
1499 assert_eq!(targets.len(), 2);
1500 assert!(targets.iter().all(|target| !target.removed));
1501 let _ = std::fs::remove_dir_all(dir);
1502 }
1503
1504 #[test]
1505 fn install_and_uninstall_refuse_unmanaged() {
1506 let dir = temp_skills_dir("unmanaged");
1507 let skill_dir = dir.join("agent-first-test");
1508 let skill_path = skill_dir.join(SKILL_FILE_NAME);
1509 assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1510 assert!(
1511 std::fs::write(&skill_path, "---\nname: custom\ndescription: custom\n---\n").is_ok()
1512 );
1513 let opts = options(SkillAgentSelection::Codex, &dir, false);
1514
1515 assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
1516 assert!(run_skill_admin(&spec(), SkillAction::Uninstall, &opts).is_err());
1517 assert!(skill_path.exists());
1518 let _ = std::fs::remove_dir_all(dir);
1519 }
1520
1521 #[test]
1522 fn invalid_spec_slugs_are_rejected_before_path_resolution() {
1523 for name in ["", "../x", "x/y", ".hidden", "bad_name", "Bad"] {
1524 let bad = SkillSpec {
1525 name,
1526 source: SKILL_SOURCE,
1527 title: "Bad",
1528 marker_slug: "aftest",
1529 assets: &[],
1530 };
1531 let opts = options(SkillAgentSelection::Codex, Path::new("/tmp/afdata"), false);
1532 assert!(
1533 run_skill_admin(&bad, SkillAction::Status, &opts).is_err(),
1534 "{name:?}"
1535 );
1536 }
1537
1538 let bad_marker = SkillSpec {
1539 name: "agent-first-test",
1540 source: SKILL_SOURCE,
1541 title: "Bad",
1542 marker_slug: "../aftest",
1543 assets: &[],
1544 };
1545 let opts = options(SkillAgentSelection::Codex, Path::new("/tmp/afdata"), false);
1546 assert!(run_skill_admin(&bad_marker, SkillAction::Status, &opts).is_err());
1547 }
1548
1549 #[test]
1550 fn frontmatter_name_must_match_spec_name() {
1551 let bad = SkillSpec {
1552 name: "agent-first-test",
1553 source: "---\nname: other-skill\ndescription: test skill\n---\n",
1554 title: "Bad",
1555 marker_slug: "aftest",
1556 assets: &[],
1557 };
1558 let dir = temp_skills_dir("frontmatter-name");
1559 let opts = options(SkillAgentSelection::Codex, &dir, false);
1560 assert!(run_skill_admin(&bad, SkillAction::Install, &opts).is_err());
1561 let _ = std::fs::remove_dir_all(dir);
1562 }
1563
1564 #[cfg(unix)]
1565 #[test]
1566 fn symlink_target_is_rejected_by_default_and_force_does_not_follow() {
1567 use std::os::unix::fs::symlink;
1568
1569 let dir = temp_skills_dir("symlink-install");
1570 let opts = options(SkillAgentSelection::Codex, &dir, false);
1571 let force_opts = options(SkillAgentSelection::Codex, &dir, true);
1572 let skill_dir = dir.join("agent-first-test");
1573 let skill_path = skill_dir.join(SKILL_FILE_NAME);
1574 let external = dir.join("external.md");
1575 assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1576 assert!(std::fs::write(&external, "external").is_ok());
1577 assert!(symlink(&external, &skill_path).is_ok());
1578
1579 assert!(run_skill_admin(&spec(), SkillAction::Install, &opts).is_err());
1580 assert_eq!(
1581 std::fs::read_to_string(&external).unwrap_or_default(),
1582 "external"
1583 );
1584 assert!(run_skill_admin(&spec(), SkillAction::Uninstall, &opts).is_err());
1585 assert!(skill_path.is_symlink());
1586
1587 assert!(run_skill_admin(&spec(), SkillAction::Install, &force_opts).is_ok());
1588 assert_eq!(
1589 std::fs::read_to_string(&external).unwrap_or_default(),
1590 "external"
1591 );
1592 assert!(skill_path.is_file());
1593 assert!(!skill_path.is_symlink());
1594 let _ = std::fs::remove_dir_all(dir);
1595 }
1596
1597 #[cfg(unix)]
1598 #[test]
1599 fn force_uninstall_removes_symlink_without_following() {
1600 use std::os::unix::fs::symlink;
1601
1602 let dir = temp_skills_dir("symlink-uninstall");
1603 let force_opts = options(SkillAgentSelection::Codex, &dir, true);
1604 let skill_dir = dir.join("agent-first-test");
1605 let skill_path = skill_dir.join(SKILL_FILE_NAME);
1606 let external = dir.join("external.md");
1607 assert!(std::fs::create_dir_all(&skill_dir).is_ok());
1608 assert!(std::fs::write(&external, "external").is_ok());
1609 assert!(symlink(&external, &skill_path).is_ok());
1610
1611 assert!(run_skill_admin(&spec(), SkillAction::Uninstall, &force_opts).is_ok());
1612 assert!(!skill_path.exists());
1613 assert_eq!(
1614 std::fs::read_to_string(&external).unwrap_or_default(),
1615 "external"
1616 );
1617 let _ = std::fs::remove_dir_all(dir);
1618 }
1619
1620 #[test]
1621 fn serializes_to_protocol_shape() {
1622 let dir = temp_skills_dir("serialize");
1623 let opts = options(SkillAgentSelection::Opencode, &dir, false);
1624 if let Ok(report) = run_skill_admin(&spec(), SkillAction::Install, &opts) {
1625 let value = serde_json::to_value(&report).unwrap_or(serde_json::Value::Null);
1626 assert_eq!(value["code"], "skill_install");
1627 assert_eq!(value["installed"], true);
1628 assert_eq!(value["targets"][0]["agent"], "opencode");
1629 assert_eq!(value["targets"][0]["current"], true);
1630 assert_eq!(
1631 value["targets"][0]["skill_dir"],
1632 serde_json::json!(dir.join("agent-first-test").to_string_lossy().to_string())
1633 );
1634 }
1635 let _ = std::fs::remove_dir_all(dir);
1636 }
1637
1638 #[test]
1639 fn all_personal_resolves_four_targets() {
1640 let opts = SkillOptions {
1641 agent: SkillAgentSelection::All,
1642 scope: SkillScope::Personal,
1643 skills_dir: None,
1644 force: false,
1645 };
1646 let targets = resolve_targets(&spec(), &opts);
1647 assert!(targets.is_ok());
1648 if let Ok(targets) = targets {
1649 assert_eq!(targets.len(), 4);
1650 assert_eq!(targets[0].agent, SkillAgent::Codex);
1651 assert_eq!(targets[1].agent, SkillAgent::ClaudeCode);
1652 assert_eq!(targets[2].agent, SkillAgent::Opencode);
1653 assert_eq!(targets[3].agent, SkillAgent::Hermes);
1654 }
1655 }
1656
1657 #[test]
1658 fn all_workspace_resolves_four_targets() {
1659 let opts = SkillOptions {
1660 agent: SkillAgentSelection::All,
1661 scope: SkillScope::Workspace,
1662 skills_dir: None,
1663 force: false,
1664 };
1665 let targets = resolve_targets(&spec(), &opts);
1666 assert!(targets.is_ok());
1667 if let Ok(targets) = targets {
1668 assert_eq!(targets.len(), 4);
1669 assert_eq!(targets[0].agent, SkillAgent::Codex);
1670 assert_eq!(targets[0].scope, SkillScope::Workspace);
1671 assert_eq!(targets[1].agent, SkillAgent::ClaudeCode);
1672 assert_eq!(targets[1].scope, SkillScope::Workspace);
1673 assert_eq!(targets[2].agent, SkillAgent::Opencode);
1674 assert_eq!(targets[2].scope, SkillScope::Workspace);
1675 assert_eq!(targets[3].agent, SkillAgent::Hermes);
1676 assert_eq!(targets[3].scope, SkillScope::Workspace);
1677 }
1678 }
1679
1680 #[test]
1681 fn codex_workspace_scope_uses_codex_skills_dir() {
1682 let opts = SkillOptions {
1683 agent: SkillAgentSelection::Codex,
1684 scope: SkillScope::Workspace,
1685 skills_dir: None,
1686 force: false,
1687 };
1688 let targets = resolve_targets(&spec(), &opts);
1689 assert!(targets.is_ok());
1690 if let Ok(targets) = targets {
1691 assert_eq!(targets.len(), 1);
1692 assert_eq!(targets[0].agent, SkillAgent::Codex);
1693 assert_eq!(targets[0].scope, SkillScope::Workspace);
1694 assert!(targets[0].skills_dir.ends_with(".codex/skills"));
1695 }
1696 }
1697
1698 #[test]
1699 fn hermes_workspace_scope_uses_hermes_skills_dir() {
1700 let opts = SkillOptions {
1701 agent: SkillAgentSelection::Hermes,
1702 scope: SkillScope::Workspace,
1703 skills_dir: None,
1704 force: false,
1705 };
1706 let targets = resolve_targets(&spec(), &opts);
1707 assert!(targets.is_ok());
1708 if let Ok(targets) = targets {
1709 assert_eq!(targets.len(), 1);
1710 assert_eq!(targets[0].agent, SkillAgent::Hermes);
1711 assert_eq!(targets[0].scope, SkillScope::Workspace);
1712 assert!(targets[0].skills_dir.ends_with(".hermes/skills"));
1713 }
1714 }
1715}