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