1use std::fmt;
2use std::path::PathBuf;
3
4use crate::types::{ArtifactKind, InstallScope};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9enum AgentFormat {
10 Markdown,
11 Toml,
12}
13
14struct PlatformSpec {
15 name: &'static str,
16 slug: &'static str,
17 manifest_dir: Option<&'static str>,
20 agent_format: Option<AgentFormat>,
23}
24
25pub const PLATFORM_HELP_VALUES: &str = concat!(
26 "Platform values:\n",
27 " claude Claude Code — markdown agents and skills in `.claude/`.\n",
28 " copilot GitHub Copilot — markdown agents and skills; local in `.github/`, global in `~/.copilot/`.\n",
29 " cursor Cursor — markdown agents and skills in `.cursor/`.\n",
30 " windsurf Windsurf — markdown agents and skills; global in `~/.codeium/windsurf/`.\n",
31 " gemini Gemini CLI — markdown agents and skills in `.gemini/`.\n",
32 " opencode opencode — markdown agents; skills in the shared `.agents` directory.\n",
33 " codex Codex CLI — TOML agents; skills in the shared `.agents` directory.\n",
34 " pi Pi — skills only; no native agent concept.\n",
35 " crush Crush — skills only; reads the shared `.agents` directory.\n",
36 " amp Amp — skills only; global scope uses `~/.config/agents/skills`.\n",
37 " zed Zed — skills only; agents are settings-embedded profiles cmx does not manage.\n",
38 " openhands OpenHands — skills only; agents are trigger-activated skills.\n",
39 " hermes Hermes — skills only; global scope uses `~/.hermes/skills`; no agent files.\n",
40 " devin Devin — skills only; discovers `SKILL.md` in connected repos and reads the shared `.agents` directory.\n",
41);
42
43#[derive(
51 Debug,
52 Clone,
53 Copy,
54 PartialEq,
55 Eq,
56 Hash,
57 Default,
58 clap::ValueEnum,
59 serde::Serialize,
60 serde::Deserialize,
61)]
62#[serde(rename_all = "lowercase")]
63pub enum Platform {
64 #[default]
65 Claude,
66 Copilot,
67 Cursor,
68 Windsurf,
69 Gemini,
70 Opencode,
71 Codex,
72 Pi,
73 Crush,
74 Amp,
75 Zed,
76 Openhands,
77 Hermes,
78 Devin,
79}
80
81impl fmt::Display for Platform {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 f.write_str(self.spec().name)
84 }
85}
86
87impl Platform {
88 fn spec(self) -> PlatformSpec {
94 match self {
95 Self::Claude => PlatformSpec {
98 name: "claude",
99 slug: "",
100 manifest_dir: None,
101 agent_format: Some(AgentFormat::Markdown),
102 },
103 Self::Copilot => PlatformSpec {
104 name: "copilot",
105 slug: "copilot",
106 manifest_dir: Some(".copilot-plugin"),
107 agent_format: Some(AgentFormat::Markdown),
108 },
109 Self::Cursor => PlatformSpec {
110 name: "cursor",
111 slug: "cursor",
112 manifest_dir: Some(".cursor-plugin"),
113 agent_format: Some(AgentFormat::Markdown),
114 },
115 Self::Windsurf => PlatformSpec {
116 name: "windsurf",
117 slug: "windsurf",
118 manifest_dir: Some(".windsurf-plugin"),
119 agent_format: Some(AgentFormat::Markdown),
120 },
121 Self::Gemini => PlatformSpec {
122 name: "gemini",
123 slug: "gemini",
124 manifest_dir: Some(".gemini-plugin"),
125 agent_format: Some(AgentFormat::Markdown),
126 },
127 Self::Opencode => PlatformSpec {
128 name: "opencode",
129 slug: "opencode",
130 manifest_dir: None,
131 agent_format: Some(AgentFormat::Markdown),
132 },
133 Self::Codex => PlatformSpec {
134 name: "codex",
135 slug: "codex",
136 manifest_dir: None,
137 agent_format: Some(AgentFormat::Toml),
138 },
139 Self::Pi => PlatformSpec {
140 name: "pi",
141 slug: "pi",
142 manifest_dir: None,
143 agent_format: None,
144 },
145 Self::Crush => PlatformSpec {
146 name: "crush",
147 slug: "crush",
148 manifest_dir: None,
149 agent_format: None,
150 },
151 Self::Amp => PlatformSpec {
152 name: "amp",
153 slug: "amp",
154 manifest_dir: None,
155 agent_format: None,
156 },
157 Self::Zed => PlatformSpec {
158 name: "zed",
159 slug: "zed",
160 manifest_dir: None,
161 agent_format: None,
162 },
163 Self::Openhands => PlatformSpec {
164 name: "openhands",
165 slug: "openhands",
166 manifest_dir: None,
167 agent_format: None,
168 },
169 Self::Hermes => PlatformSpec {
170 name: "hermes",
171 slug: "hermes",
172 manifest_dir: None,
173 agent_format: None,
174 },
175 Self::Devin => PlatformSpec {
176 name: "devin",
177 slug: "devin",
178 manifest_dir: None,
179 agent_format: None,
180 },
181 }
182 }
183
184 pub fn install_subpath(self, kind: ArtifactKind, scope: InstallScope) -> Option<PathBuf> {
198 use ArtifactKind::{Agent, Skill};
199
200 let dir = |base: &str, leaf: &str| PathBuf::from(base).join(leaf);
201 let local = scope.is_local();
202
203 match (self, kind) {
204 (Self::Claude, Agent) => Some(dir(".claude", "agents")),
205 (Self::Claude, Skill) => Some(dir(".claude", "skills")),
206
207 (Self::Copilot, Agent) => {
210 Some(dir(if local { ".github" } else { ".copilot" }, "agents"))
211 }
212 (Self::Copilot, Skill) => {
213 Some(dir(if local { ".github" } else { ".copilot" }, "skills"))
214 }
215
216 (Self::Cursor, Agent) => Some(dir(".cursor", "agents")),
217 (Self::Cursor, Skill) => Some(dir(".cursor", "skills")),
218
219 (Self::Windsurf, Agent) if local => Some(dir(".windsurf", "agents")),
221 (Self::Windsurf, Skill) if local => Some(dir(".windsurf", "skills")),
222 (Self::Windsurf, Agent) => {
223 Some(PathBuf::from(".codeium").join("windsurf").join("agents"))
224 }
225 (Self::Windsurf, Skill) => {
226 Some(PathBuf::from(".codeium").join("windsurf").join("skills"))
227 }
228
229 (Self::Gemini, Agent) => Some(dir(".gemini", "agents")),
230 (Self::Gemini, Skill) => Some(dir(".gemini", "skills")),
231
232 (Self::Opencode, Agent) if local => Some(dir(".opencode", "agent")),
235 (Self::Opencode, Agent) => {
236 Some(PathBuf::from(".config").join("opencode").join("agent"))
237 }
238
239 (Self::Codex, Agent) => Some(dir(".codex", "agents")),
241
242 (
244 Self::Pi
245 | Self::Crush
246 | Self::Amp
247 | Self::Zed
248 | Self::Openhands
249 | Self::Hermes
250 | Self::Devin,
251 Agent,
252 ) => None,
253
254 (Self::Amp, Skill) if !local => {
256 Some(PathBuf::from(".config").join("agents").join("skills"))
257 }
258 (Self::Hermes, Skill) if !local => Some(dir(".hermes", "skills")),
259
260 (
263 Self::Opencode
264 | Self::Codex
265 | Self::Pi
266 | Self::Crush
267 | Self::Zed
268 | Self::Openhands
269 | Self::Devin
270 | Self::Amp
271 | Self::Hermes,
272 Skill,
273 ) => Some(PathBuf::from(".agents").join("skills")),
274 }
275 }
276
277 pub fn supports(self, kind: ArtifactKind) -> bool {
284 kind == ArtifactKind::Skill || self.spec().agent_format.is_some()
285 }
286
287 pub fn agent_extension(self) -> &'static str {
292 match self.spec().agent_format {
293 Some(AgentFormat::Toml) => "toml",
294 _ => "md",
295 }
296 }
297
298 pub fn transforms_agent_to_toml(self) -> bool {
301 matches!(self.spec().agent_format, Some(AgentFormat::Toml))
302 }
303
304 pub fn slug(self) -> &'static str {
309 self.spec().slug
310 }
311
312 pub fn manifest_dir(self) -> Option<&'static str> {
322 self.spec().manifest_dir
323 }
324
325 pub const ALL: [Platform; 14] = [
331 Self::Claude,
332 Self::Copilot,
333 Self::Cursor,
334 Self::Windsurf,
335 Self::Gemini,
336 Self::Opencode,
337 Self::Codex,
338 Self::Pi,
339 Self::Crush,
340 Self::Amp,
341 Self::Zed,
342 Self::Openhands,
343 Self::Hermes,
344 Self::Devin,
345 ];
346
347 pub fn targets() -> Vec<Platform> {
354 Self::ALL.iter().filter(|p| p.manifest_dir().is_some()).copied().collect()
355 }
356
357 pub fn manifest_targets() -> Vec<(Platform, &'static str)> {
364 Self::ALL.iter().filter_map(|p| p.manifest_dir().map(|dir| (*p, dir))).collect()
365 }
366}
367
368pub fn platforms_label(platforms: &[Platform]) -> String {
374 platforms.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ")
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn default_is_claude() {
383 assert_eq!(Platform::default(), Platform::Claude);
384 }
385
386 #[test]
387 fn display_produces_lowercase_names() {
388 assert_eq!(Platform::Claude.to_string(), "claude");
389 assert_eq!(Platform::Copilot.to_string(), "copilot");
390 assert_eq!(Platform::Cursor.to_string(), "cursor");
391 assert_eq!(Platform::Windsurf.to_string(), "windsurf");
392 assert_eq!(Platform::Gemini.to_string(), "gemini");
393 assert_eq!(Platform::Opencode.to_string(), "opencode");
394 assert_eq!(Platform::Codex.to_string(), "codex");
395 assert_eq!(Platform::Pi.to_string(), "pi");
396 assert_eq!(Platform::Crush.to_string(), "crush");
397 assert_eq!(Platform::Amp.to_string(), "amp");
398 assert_eq!(Platform::Zed.to_string(), "zed");
399 assert_eq!(Platform::Openhands.to_string(), "openhands");
400 assert_eq!(Platform::Hermes.to_string(), "hermes");
401 assert_eq!(Platform::Devin.to_string(), "devin");
402 }
403
404 #[test]
405 fn manifest_dir_values() {
406 assert_eq!(Platform::Copilot.manifest_dir(), Some(".copilot-plugin"));
408 assert_eq!(Platform::Cursor.manifest_dir(), Some(".cursor-plugin"));
409 assert_eq!(Platform::Windsurf.manifest_dir(), Some(".windsurf-plugin"));
410 assert_eq!(Platform::Gemini.manifest_dir(), Some(".gemini-plugin"));
411 assert_eq!(Platform::Claude.manifest_dir(), None);
413 }
414
415 #[test]
416 fn manifest_dir_none_for_non_targets() {
417 for p in [
418 Platform::Claude,
419 Platform::Opencode,
420 Platform::Codex,
421 Platform::Pi,
422 Platform::Crush,
423 Platform::Amp,
424 Platform::Zed,
425 Platform::Openhands,
426 Platform::Hermes,
427 Platform::Devin,
428 ] {
429 assert!(
430 p.manifest_dir().is_none(),
431 "{p} is not a manifest target; manifest_dir() must be None"
432 );
433 }
434 }
435
436 #[test]
437 fn targets_is_derived_from_all_with_manifest_dir() {
438 let expected: Vec<Platform> =
439 Platform::ALL.iter().filter(|p| p.manifest_dir().is_some()).copied().collect();
440 assert_eq!(
441 Platform::targets(),
442 expected,
443 "targets() must equal ALL filtered by manifest_dir"
444 );
445 }
446
447 #[test]
448 fn manifest_targets_pairs_each_target_with_its_dir() {
449 let targets = Platform::targets();
450 let manifest_targets = Platform::manifest_targets();
451 assert_eq!(
452 targets.len(),
453 manifest_targets.len(),
454 "manifest_targets() must yield the same platforms as targets()"
455 );
456 for (p, dir) in &manifest_targets {
457 assert_eq!(
458 p.manifest_dir().unwrap(),
459 *dir,
460 "{p}: manifest_targets dir must equal manifest_dir().unwrap()"
461 );
462 assert!(!dir.is_empty(), "{p}: manifest_targets dir must be non-empty");
463 }
464 let platforms: Vec<Platform> = manifest_targets.iter().map(|(p, _)| *p).collect();
465 assert_eq!(platforms, targets, "manifest_targets() platforms must match targets() order");
466 }
467
468 #[test]
469 fn targets_includes_windsurf() {
470 assert!(
471 Platform::targets().contains(&Platform::Windsurf),
472 "expected Windsurf in targets()"
473 );
474 }
475
476 #[test]
477 fn targets_excludes_claude() {
478 assert!(
479 !Platform::targets().contains(&Platform::Claude),
480 "Claude should not be in targets()"
481 );
482 }
483
484 #[test]
485 fn targets_excludes_agents_standard_tools() {
486 for p in [
487 Platform::Opencode,
488 Platform::Codex,
489 Platform::Pi,
490 Platform::Crush,
491 Platform::Amp,
492 Platform::Zed,
493 Platform::Openhands,
494 Platform::Hermes,
495 Platform::Devin,
496 ] {
497 assert!(
498 !Platform::targets().contains(&p),
499 "{p} has no manifest format and must not be a manifest target"
500 );
501 }
502 }
503
504 #[test]
507 fn install_subpath_claude() {
508 assert_eq!(
509 Platform::Claude
510 .install_subpath(ArtifactKind::Agent, InstallScope::Local)
511 .unwrap(),
512 PathBuf::from(".claude").join("agents")
513 );
514 assert_eq!(
515 Platform::Claude
516 .install_subpath(ArtifactKind::Skill, InstallScope::Global)
517 .unwrap(),
518 PathBuf::from(".claude").join("skills")
519 );
520 }
521
522 #[test]
523 fn install_subpath_copilot_differs_by_scope() {
524 assert_eq!(
525 Platform::Copilot
526 .install_subpath(ArtifactKind::Agent, InstallScope::Local)
527 .unwrap(),
528 PathBuf::from(".github").join("agents")
529 );
530 assert_eq!(
531 Platform::Copilot
532 .install_subpath(ArtifactKind::Agent, InstallScope::Global)
533 .unwrap(),
534 PathBuf::from(".copilot").join("agents")
535 );
536 }
537
538 #[test]
539 fn install_subpath_windsurf_global_nests_under_codeium() {
540 assert_eq!(
541 Platform::Windsurf
542 .install_subpath(ArtifactKind::Skill, InstallScope::Global)
543 .unwrap(),
544 PathBuf::from(".codeium").join("windsurf").join("skills")
545 );
546 assert_eq!(
547 Platform::Windsurf
548 .install_subpath(ArtifactKind::Agent, InstallScope::Local)
549 .unwrap(),
550 PathBuf::from(".windsurf").join("agents")
551 );
552 }
553
554 #[test]
557 fn install_subpath_opencode_agent_uses_singular_leaf_and_xdg_global() {
558 assert_eq!(
559 Platform::Opencode
560 .install_subpath(ArtifactKind::Agent, InstallScope::Local)
561 .unwrap(),
562 PathBuf::from(".opencode").join("agent")
563 );
564 assert_eq!(
565 Platform::Opencode
566 .install_subpath(ArtifactKind::Agent, InstallScope::Global)
567 .unwrap(),
568 PathBuf::from(".config").join("opencode").join("agent")
569 );
570 }
571
572 #[test]
573 fn install_subpath_codex_agent_uses_dot_codex_agents() {
574 assert_eq!(
575 Platform::Codex
576 .install_subpath(ArtifactKind::Agent, InstallScope::Local)
577 .unwrap(),
578 PathBuf::from(".codex").join("agents")
579 );
580 assert_eq!(
581 Platform::Codex
582 .install_subpath(ArtifactKind::Agent, InstallScope::Global)
583 .unwrap(),
584 PathBuf::from(".codex").join("agents")
585 );
586 }
587
588 #[test]
589 fn install_subpath_new_tools_share_dot_agents_skills() {
590 for p in [
591 Platform::Opencode,
592 Platform::Codex,
593 Platform::Pi,
594 Platform::Crush,
595 Platform::Zed,
596 Platform::Openhands,
597 Platform::Devin,
598 ] {
599 for scope in InstallScope::ALL {
600 assert_eq!(
601 p.install_subpath(ArtifactKind::Skill, scope).unwrap(),
602 PathBuf::from(".agents").join("skills"),
603 "{p} skills (scope {scope:?}) should resolve to .agents/skills"
604 );
605 }
606 }
607 }
608
609 #[test]
610 fn install_subpath_amp_skill_diverges_at_global_scope() {
611 assert_eq!(
612 Platform::Amp.install_subpath(ArtifactKind::Skill, InstallScope::Local).unwrap(),
613 PathBuf::from(".agents").join("skills")
614 );
615 assert_eq!(
616 Platform::Amp
617 .install_subpath(ArtifactKind::Skill, InstallScope::Global)
618 .unwrap(),
619 PathBuf::from(".config").join("agents").join("skills")
620 );
621 }
622
623 #[test]
624 fn install_subpath_hermes_skill_diverges_at_global_scope() {
625 assert_eq!(
626 Platform::Hermes
627 .install_subpath(ArtifactKind::Skill, InstallScope::Local)
628 .unwrap(),
629 PathBuf::from(".agents").join("skills")
630 );
631 assert_eq!(
632 Platform::Hermes
633 .install_subpath(ArtifactKind::Skill, InstallScope::Global)
634 .unwrap(),
635 PathBuf::from(".hermes").join("skills")
636 );
637 }
638
639 #[test]
640 fn install_subpath_skills_only_tools_return_none_for_agent() {
641 for p in [
642 Platform::Pi,
643 Platform::Crush,
644 Platform::Amp,
645 Platform::Zed,
646 Platform::Openhands,
647 Platform::Hermes,
648 Platform::Devin,
649 ] {
650 for scope in InstallScope::ALL {
651 assert!(
652 p.install_subpath(ArtifactKind::Agent, scope).is_none(),
653 "{p} has no agent concept; install_subpath(Agent, {scope:?}) must be None"
654 );
655 }
656 }
657 }
658
659 #[test]
660 fn supports_and_install_subpath_agent_agree_for_all_variants() {
661 for p in Platform::ALL {
664 for scope in InstallScope::ALL {
665 let has_path = p.install_subpath(ArtifactKind::Agent, scope).is_some();
666 let supported = p.supports(ArtifactKind::Agent);
667 assert_eq!(
668 has_path, supported,
669 "{p}: supports(Agent)={supported} but install_subpath(Agent, {scope:?}).is_some()={has_path}"
670 );
671 }
672 }
673 }
674
675 #[test]
678 fn supports_skills_only_tools_reject_agents() {
679 for p in [
680 Platform::Pi,
681 Platform::Crush,
682 Platform::Amp,
683 Platform::Zed,
684 Platform::Openhands,
685 Platform::Hermes,
686 Platform::Devin,
687 ] {
688 assert!(p.supports(ArtifactKind::Skill), "{p} should support skills");
689 assert!(!p.supports(ArtifactKind::Agent), "{p} should not support agents");
690 }
691 }
692
693 #[test]
694 fn supports_all_kinds_for_other_platforms() {
695 for p in [
696 Platform::Claude,
697 Platform::Copilot,
698 Platform::Cursor,
699 Platform::Windsurf,
700 Platform::Gemini,
701 Platform::Opencode,
702 Platform::Codex,
703 ] {
704 assert!(p.supports(ArtifactKind::Agent), "{p} should support agents");
705 assert!(p.supports(ArtifactKind::Skill), "{p} should support skills");
706 }
707 }
708
709 #[test]
712 fn agent_extension_codex_is_toml_others_md() {
713 assert_eq!(Platform::Codex.agent_extension(), "toml");
714 assert_eq!(Platform::Claude.agent_extension(), "md");
715 assert_eq!(Platform::Opencode.agent_extension(), "md");
716 assert_eq!(Platform::Pi.agent_extension(), "md");
717 }
718
719 #[test]
720 fn only_codex_transforms_agents() {
721 assert!(Platform::Codex.transforms_agent_to_toml());
722 assert!(!Platform::Claude.transforms_agent_to_toml());
723 assert!(!Platform::Opencode.transforms_agent_to_toml());
724 }
725
726 #[test]
729 fn serializes_to_lowercase_cli_name() {
730 assert_eq!(serde_json::to_string(&Platform::Codex).unwrap(), "\"codex\"");
733 assert_eq!(serde_json::to_string(&Platform::Claude).unwrap(), "\"claude\"");
734 assert_eq!(serde_json::from_str::<Platform>("\"openhands\"").unwrap(), Platform::Openhands);
735 }
736
737 #[test]
738 fn slug_values() {
739 assert_eq!(Platform::Claude.slug(), "");
740 assert_eq!(Platform::Copilot.slug(), "copilot");
741 assert_eq!(Platform::Cursor.slug(), "cursor");
742 assert_eq!(Platform::Windsurf.slug(), "windsurf");
743 assert_eq!(Platform::Gemini.slug(), "gemini");
744 assert_eq!(Platform::Opencode.slug(), "opencode");
745 assert_eq!(Platform::Codex.slug(), "codex");
746 assert_eq!(Platform::Pi.slug(), "pi");
747 assert_eq!(Platform::Crush.slug(), "crush");
748 assert_eq!(Platform::Amp.slug(), "amp");
749 assert_eq!(Platform::Zed.slug(), "zed");
750 assert_eq!(Platform::Openhands.slug(), "openhands");
751 assert_eq!(Platform::Hermes.slug(), "hermes");
752 assert_eq!(Platform::Devin.slug(), "devin");
753 }
754
755 #[test]
758 fn all_contains_every_variant() {
759 let every = [
760 Platform::Claude,
761 Platform::Copilot,
762 Platform::Cursor,
763 Platform::Windsurf,
764 Platform::Gemini,
765 Platform::Opencode,
766 Platform::Codex,
767 Platform::Pi,
768 Platform::Crush,
769 Platform::Amp,
770 Platform::Zed,
771 Platform::Openhands,
772 Platform::Hermes,
773 Platform::Devin,
774 ];
775 assert_eq!(Platform::ALL.len(), every.len(), "ALL must list every variant");
776 for p in every {
777 assert!(Platform::ALL.contains(&p), "{p} missing from Platform::ALL");
778 }
779 }
780
781 #[test]
782 fn all_slugs_are_unique() {
783 let mut slugs: Vec<&str> = Platform::ALL.iter().map(|p| p.slug()).collect();
784 let count = slugs.len();
785 slugs.sort_unstable();
786 slugs.dedup();
787 assert_eq!(slugs.len(), count, "platform slugs must be unique across ALL");
788 }
789
790 #[test]
791 fn value_enum_parses_all_variants() {
792 use clap::ValueEnum;
793 assert_eq!(Platform::from_str("claude", true).unwrap(), Platform::Claude);
794 assert_eq!(Platform::from_str("copilot", true).unwrap(), Platform::Copilot);
795 assert_eq!(Platform::from_str("cursor", true).unwrap(), Platform::Cursor);
796 assert_eq!(Platform::from_str("windsurf", true).unwrap(), Platform::Windsurf);
797 assert_eq!(Platform::from_str("gemini", true).unwrap(), Platform::Gemini);
798 assert_eq!(Platform::from_str("opencode", true).unwrap(), Platform::Opencode);
799 assert_eq!(Platform::from_str("codex", true).unwrap(), Platform::Codex);
800 assert_eq!(Platform::from_str("pi", true).unwrap(), Platform::Pi);
801 assert_eq!(Platform::from_str("crush", true).unwrap(), Platform::Crush);
802 assert_eq!(Platform::from_str("amp", true).unwrap(), Platform::Amp);
803 assert_eq!(Platform::from_str("zed", true).unwrap(), Platform::Zed);
804 assert_eq!(Platform::from_str("openhands", true).unwrap(), Platform::Openhands);
805 assert_eq!(Platform::from_str("hermes", true).unwrap(), Platform::Hermes);
806 assert_eq!(Platform::from_str("devin", true).unwrap(), Platform::Devin);
807 }
808}