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