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
25#[derive(
33 Debug,
34 Clone,
35 Copy,
36 PartialEq,
37 Eq,
38 Hash,
39 Default,
40 clap::ValueEnum,
41 serde::Serialize,
42 serde::Deserialize,
43)]
44#[serde(rename_all = "lowercase")]
45pub enum Platform {
46 #[default]
47 Claude,
48 Copilot,
49 Cursor,
50 Windsurf,
51 Gemini,
52 Opencode,
54 Codex,
56 Pi,
58 Crush,
60 Amp,
62 Zed,
64 Openhands,
66 Hermes,
68 Devin,
71}
72
73impl fmt::Display for Platform {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 f.write_str(self.spec().name)
76 }
77}
78
79impl Platform {
80 fn spec(self) -> PlatformSpec {
86 match self {
87 Self::Claude => PlatformSpec {
90 name: "claude",
91 slug: "",
92 manifest_dir: None,
93 agent_format: Some(AgentFormat::Markdown),
94 },
95 Self::Copilot => PlatformSpec {
96 name: "copilot",
97 slug: "copilot",
98 manifest_dir: Some(".copilot-plugin"),
99 agent_format: Some(AgentFormat::Markdown),
100 },
101 Self::Cursor => PlatformSpec {
102 name: "cursor",
103 slug: "cursor",
104 manifest_dir: Some(".cursor-plugin"),
105 agent_format: Some(AgentFormat::Markdown),
106 },
107 Self::Windsurf => PlatformSpec {
108 name: "windsurf",
109 slug: "windsurf",
110 manifest_dir: Some(".windsurf-plugin"),
111 agent_format: Some(AgentFormat::Markdown),
112 },
113 Self::Gemini => PlatformSpec {
114 name: "gemini",
115 slug: "gemini",
116 manifest_dir: Some(".gemini-plugin"),
117 agent_format: Some(AgentFormat::Markdown),
118 },
119 Self::Opencode => PlatformSpec {
120 name: "opencode",
121 slug: "opencode",
122 manifest_dir: None,
123 agent_format: Some(AgentFormat::Markdown),
124 },
125 Self::Codex => PlatformSpec {
126 name: "codex",
127 slug: "codex",
128 manifest_dir: None,
129 agent_format: Some(AgentFormat::Toml),
130 },
131 Self::Pi => PlatformSpec {
132 name: "pi",
133 slug: "pi",
134 manifest_dir: None,
135 agent_format: None,
136 },
137 Self::Crush => PlatformSpec {
138 name: "crush",
139 slug: "crush",
140 manifest_dir: None,
141 agent_format: None,
142 },
143 Self::Amp => PlatformSpec {
144 name: "amp",
145 slug: "amp",
146 manifest_dir: None,
147 agent_format: None,
148 },
149 Self::Zed => PlatformSpec {
150 name: "zed",
151 slug: "zed",
152 manifest_dir: None,
153 agent_format: None,
154 },
155 Self::Openhands => PlatformSpec {
156 name: "openhands",
157 slug: "openhands",
158 manifest_dir: None,
159 agent_format: None,
160 },
161 Self::Hermes => PlatformSpec {
162 name: "hermes",
163 slug: "hermes",
164 manifest_dir: None,
165 agent_format: None,
166 },
167 Self::Devin => PlatformSpec {
168 name: "devin",
169 slug: "devin",
170 manifest_dir: None,
171 agent_format: None,
172 },
173 }
174 }
175
176 pub fn install_subpath(self, kind: ArtifactKind, scope: InstallScope) -> Option<PathBuf> {
190 use ArtifactKind::{Agent, Skill};
191
192 let dir = |base: &str, leaf: &str| PathBuf::from(base).join(leaf);
193 let local = scope.is_local();
194
195 match (self, kind) {
196 (Self::Claude, Agent) => Some(dir(".claude", "agents")),
197 (Self::Claude, Skill) => Some(dir(".claude", "skills")),
198
199 (Self::Copilot, Agent) => {
202 Some(dir(if local { ".github" } else { ".copilot" }, "agents"))
203 }
204 (Self::Copilot, Skill) => {
205 Some(dir(if local { ".github" } else { ".copilot" }, "skills"))
206 }
207
208 (Self::Cursor, Agent) => Some(dir(".cursor", "agents")),
209 (Self::Cursor, Skill) => Some(dir(".cursor", "skills")),
210
211 (Self::Windsurf, Agent) if local => Some(dir(".windsurf", "agents")),
213 (Self::Windsurf, Skill) if local => Some(dir(".windsurf", "skills")),
214 (Self::Windsurf, Agent) => {
215 Some(PathBuf::from(".codeium").join("windsurf").join("agents"))
216 }
217 (Self::Windsurf, Skill) => {
218 Some(PathBuf::from(".codeium").join("windsurf").join("skills"))
219 }
220
221 (Self::Gemini, Agent) => Some(dir(".gemini", "agents")),
222 (Self::Gemini, Skill) => Some(dir(".gemini", "skills")),
223
224 (Self::Opencode, Agent) if local => Some(dir(".opencode", "agent")),
227 (Self::Opencode, Agent) => {
228 Some(PathBuf::from(".config").join("opencode").join("agent"))
229 }
230
231 (Self::Codex, Agent) => Some(dir(".codex", "agents")),
233
234 (
236 Self::Pi
237 | Self::Crush
238 | Self::Amp
239 | Self::Zed
240 | Self::Openhands
241 | Self::Hermes
242 | Self::Devin,
243 Agent,
244 ) => None,
245
246 (Self::Amp, Skill) if !local => {
248 Some(PathBuf::from(".config").join("agents").join("skills"))
249 }
250 (Self::Hermes, Skill) if !local => Some(dir(".hermes", "skills")),
251
252 (
255 Self::Opencode
256 | Self::Codex
257 | Self::Pi
258 | Self::Crush
259 | Self::Zed
260 | Self::Openhands
261 | Self::Devin
262 | Self::Amp
263 | Self::Hermes,
264 Skill,
265 ) => Some(PathBuf::from(".agents").join("skills")),
266 }
267 }
268
269 pub fn supports(self, kind: ArtifactKind) -> bool {
276 kind == ArtifactKind::Skill || self.spec().agent_format.is_some()
277 }
278
279 pub fn agent_extension(self) -> &'static str {
284 match self.spec().agent_format {
285 Some(AgentFormat::Toml) => "toml",
286 _ => "md",
287 }
288 }
289
290 pub fn transforms_agent_to_toml(self) -> bool {
293 matches!(self.spec().agent_format, Some(AgentFormat::Toml))
294 }
295
296 pub fn slug(self) -> &'static str {
301 self.spec().slug
302 }
303
304 pub fn manifest_dir(self) -> Option<&'static str> {
314 self.spec().manifest_dir
315 }
316
317 pub const ALL: [Platform; 14] = [
323 Self::Claude,
324 Self::Copilot,
325 Self::Cursor,
326 Self::Windsurf,
327 Self::Gemini,
328 Self::Opencode,
329 Self::Codex,
330 Self::Pi,
331 Self::Crush,
332 Self::Amp,
333 Self::Zed,
334 Self::Openhands,
335 Self::Hermes,
336 Self::Devin,
337 ];
338
339 pub fn targets() -> Vec<Platform> {
346 Self::ALL.iter().filter(|p| p.manifest_dir().is_some()).copied().collect()
347 }
348
349 pub fn manifest_targets() -> Vec<(Platform, &'static str)> {
356 Self::ALL.iter().filter_map(|p| p.manifest_dir().map(|dir| (*p, dir))).collect()
357 }
358}
359
360pub fn platforms_label(platforms: &[Platform]) -> String {
366 platforms.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ")
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 #[test]
374 fn default_is_claude() {
375 assert_eq!(Platform::default(), Platform::Claude);
376 }
377
378 #[test]
379 fn display_produces_lowercase_names() {
380 assert_eq!(Platform::Claude.to_string(), "claude");
381 assert_eq!(Platform::Copilot.to_string(), "copilot");
382 assert_eq!(Platform::Cursor.to_string(), "cursor");
383 assert_eq!(Platform::Windsurf.to_string(), "windsurf");
384 assert_eq!(Platform::Gemini.to_string(), "gemini");
385 assert_eq!(Platform::Opencode.to_string(), "opencode");
386 assert_eq!(Platform::Codex.to_string(), "codex");
387 assert_eq!(Platform::Pi.to_string(), "pi");
388 assert_eq!(Platform::Crush.to_string(), "crush");
389 assert_eq!(Platform::Amp.to_string(), "amp");
390 assert_eq!(Platform::Zed.to_string(), "zed");
391 assert_eq!(Platform::Openhands.to_string(), "openhands");
392 assert_eq!(Platform::Hermes.to_string(), "hermes");
393 assert_eq!(Platform::Devin.to_string(), "devin");
394 }
395
396 #[test]
397 fn manifest_dir_values() {
398 assert_eq!(Platform::Copilot.manifest_dir(), Some(".copilot-plugin"));
400 assert_eq!(Platform::Cursor.manifest_dir(), Some(".cursor-plugin"));
401 assert_eq!(Platform::Windsurf.manifest_dir(), Some(".windsurf-plugin"));
402 assert_eq!(Platform::Gemini.manifest_dir(), Some(".gemini-plugin"));
403 assert_eq!(Platform::Claude.manifest_dir(), None);
405 }
406
407 #[test]
408 fn manifest_dir_none_for_non_targets() {
409 for p in [
410 Platform::Claude,
411 Platform::Opencode,
412 Platform::Codex,
413 Platform::Pi,
414 Platform::Crush,
415 Platform::Amp,
416 Platform::Zed,
417 Platform::Openhands,
418 Platform::Hermes,
419 Platform::Devin,
420 ] {
421 assert!(
422 p.manifest_dir().is_none(),
423 "{p} is not a manifest target; manifest_dir() must be None"
424 );
425 }
426 }
427
428 #[test]
429 fn targets_is_derived_from_all_with_manifest_dir() {
430 let expected: Vec<Platform> =
431 Platform::ALL.iter().filter(|p| p.manifest_dir().is_some()).copied().collect();
432 assert_eq!(
433 Platform::targets(),
434 expected,
435 "targets() must equal ALL filtered by manifest_dir"
436 );
437 }
438
439 #[test]
440 fn manifest_targets_pairs_each_target_with_its_dir() {
441 let targets = Platform::targets();
442 let manifest_targets = Platform::manifest_targets();
443 assert_eq!(
444 targets.len(),
445 manifest_targets.len(),
446 "manifest_targets() must yield the same platforms as targets()"
447 );
448 for (p, dir) in &manifest_targets {
449 assert_eq!(
450 p.manifest_dir().unwrap(),
451 *dir,
452 "{p}: manifest_targets dir must equal manifest_dir().unwrap()"
453 );
454 assert!(!dir.is_empty(), "{p}: manifest_targets dir must be non-empty");
455 }
456 let platforms: Vec<Platform> = manifest_targets.iter().map(|(p, _)| *p).collect();
457 assert_eq!(platforms, targets, "manifest_targets() platforms must match targets() order");
458 }
459
460 #[test]
461 fn targets_includes_windsurf() {
462 assert!(
463 Platform::targets().contains(&Platform::Windsurf),
464 "expected Windsurf in targets()"
465 );
466 }
467
468 #[test]
469 fn targets_excludes_claude() {
470 assert!(
471 !Platform::targets().contains(&Platform::Claude),
472 "Claude should not be in targets()"
473 );
474 }
475
476 #[test]
477 fn targets_excludes_agents_standard_tools() {
478 for p in [
479 Platform::Opencode,
480 Platform::Codex,
481 Platform::Pi,
482 Platform::Crush,
483 Platform::Amp,
484 Platform::Zed,
485 Platform::Openhands,
486 Platform::Hermes,
487 Platform::Devin,
488 ] {
489 assert!(
490 !Platform::targets().contains(&p),
491 "{p} has no manifest format and must not be a manifest target"
492 );
493 }
494 }
495
496 #[test]
499 fn install_subpath_claude() {
500 assert_eq!(
501 Platform::Claude
502 .install_subpath(ArtifactKind::Agent, InstallScope::Local)
503 .unwrap(),
504 PathBuf::from(".claude").join("agents")
505 );
506 assert_eq!(
507 Platform::Claude
508 .install_subpath(ArtifactKind::Skill, InstallScope::Global)
509 .unwrap(),
510 PathBuf::from(".claude").join("skills")
511 );
512 }
513
514 #[test]
515 fn install_subpath_copilot_differs_by_scope() {
516 assert_eq!(
517 Platform::Copilot
518 .install_subpath(ArtifactKind::Agent, InstallScope::Local)
519 .unwrap(),
520 PathBuf::from(".github").join("agents")
521 );
522 assert_eq!(
523 Platform::Copilot
524 .install_subpath(ArtifactKind::Agent, InstallScope::Global)
525 .unwrap(),
526 PathBuf::from(".copilot").join("agents")
527 );
528 }
529
530 #[test]
531 fn install_subpath_windsurf_global_nests_under_codeium() {
532 assert_eq!(
533 Platform::Windsurf
534 .install_subpath(ArtifactKind::Skill, InstallScope::Global)
535 .unwrap(),
536 PathBuf::from(".codeium").join("windsurf").join("skills")
537 );
538 assert_eq!(
539 Platform::Windsurf
540 .install_subpath(ArtifactKind::Agent, InstallScope::Local)
541 .unwrap(),
542 PathBuf::from(".windsurf").join("agents")
543 );
544 }
545
546 #[test]
549 fn install_subpath_opencode_agent_uses_singular_leaf_and_xdg_global() {
550 assert_eq!(
551 Platform::Opencode
552 .install_subpath(ArtifactKind::Agent, InstallScope::Local)
553 .unwrap(),
554 PathBuf::from(".opencode").join("agent")
555 );
556 assert_eq!(
557 Platform::Opencode
558 .install_subpath(ArtifactKind::Agent, InstallScope::Global)
559 .unwrap(),
560 PathBuf::from(".config").join("opencode").join("agent")
561 );
562 }
563
564 #[test]
565 fn install_subpath_codex_agent_uses_dot_codex_agents() {
566 assert_eq!(
567 Platform::Codex
568 .install_subpath(ArtifactKind::Agent, InstallScope::Local)
569 .unwrap(),
570 PathBuf::from(".codex").join("agents")
571 );
572 assert_eq!(
573 Platform::Codex
574 .install_subpath(ArtifactKind::Agent, InstallScope::Global)
575 .unwrap(),
576 PathBuf::from(".codex").join("agents")
577 );
578 }
579
580 #[test]
581 fn install_subpath_new_tools_share_dot_agents_skills() {
582 for p in [
583 Platform::Opencode,
584 Platform::Codex,
585 Platform::Pi,
586 Platform::Crush,
587 Platform::Zed,
588 Platform::Openhands,
589 Platform::Devin,
590 ] {
591 for scope in InstallScope::ALL {
592 assert_eq!(
593 p.install_subpath(ArtifactKind::Skill, scope).unwrap(),
594 PathBuf::from(".agents").join("skills"),
595 "{p} skills (scope {scope:?}) should resolve to .agents/skills"
596 );
597 }
598 }
599 }
600
601 #[test]
602 fn install_subpath_amp_skill_diverges_at_global_scope() {
603 assert_eq!(
604 Platform::Amp.install_subpath(ArtifactKind::Skill, InstallScope::Local).unwrap(),
605 PathBuf::from(".agents").join("skills")
606 );
607 assert_eq!(
608 Platform::Amp
609 .install_subpath(ArtifactKind::Skill, InstallScope::Global)
610 .unwrap(),
611 PathBuf::from(".config").join("agents").join("skills")
612 );
613 }
614
615 #[test]
616 fn install_subpath_hermes_skill_diverges_at_global_scope() {
617 assert_eq!(
618 Platform::Hermes
619 .install_subpath(ArtifactKind::Skill, InstallScope::Local)
620 .unwrap(),
621 PathBuf::from(".agents").join("skills")
622 );
623 assert_eq!(
624 Platform::Hermes
625 .install_subpath(ArtifactKind::Skill, InstallScope::Global)
626 .unwrap(),
627 PathBuf::from(".hermes").join("skills")
628 );
629 }
630
631 #[test]
632 fn install_subpath_skills_only_tools_return_none_for_agent() {
633 for p in [
634 Platform::Pi,
635 Platform::Crush,
636 Platform::Amp,
637 Platform::Zed,
638 Platform::Openhands,
639 Platform::Hermes,
640 Platform::Devin,
641 ] {
642 for scope in InstallScope::ALL {
643 assert!(
644 p.install_subpath(ArtifactKind::Agent, scope).is_none(),
645 "{p} has no agent concept; install_subpath(Agent, {scope:?}) must be None"
646 );
647 }
648 }
649 }
650
651 #[test]
652 fn supports_and_install_subpath_agent_agree_for_all_variants() {
653 for p in Platform::ALL {
656 for scope in InstallScope::ALL {
657 let has_path = p.install_subpath(ArtifactKind::Agent, scope).is_some();
658 let supported = p.supports(ArtifactKind::Agent);
659 assert_eq!(
660 has_path, supported,
661 "{p}: supports(Agent)={supported} but install_subpath(Agent, {scope:?}).is_some()={has_path}"
662 );
663 }
664 }
665 }
666
667 #[test]
670 fn supports_skills_only_tools_reject_agents() {
671 for p in [
672 Platform::Pi,
673 Platform::Crush,
674 Platform::Amp,
675 Platform::Zed,
676 Platform::Openhands,
677 Platform::Hermes,
678 Platform::Devin,
679 ] {
680 assert!(p.supports(ArtifactKind::Skill), "{p} should support skills");
681 assert!(!p.supports(ArtifactKind::Agent), "{p} should not support agents");
682 }
683 }
684
685 #[test]
686 fn supports_all_kinds_for_other_platforms() {
687 for p in [
688 Platform::Claude,
689 Platform::Copilot,
690 Platform::Cursor,
691 Platform::Windsurf,
692 Platform::Gemini,
693 Platform::Opencode,
694 Platform::Codex,
695 ] {
696 assert!(p.supports(ArtifactKind::Agent), "{p} should support agents");
697 assert!(p.supports(ArtifactKind::Skill), "{p} should support skills");
698 }
699 }
700
701 #[test]
704 fn agent_extension_codex_is_toml_others_md() {
705 assert_eq!(Platform::Codex.agent_extension(), "toml");
706 assert_eq!(Platform::Claude.agent_extension(), "md");
707 assert_eq!(Platform::Opencode.agent_extension(), "md");
708 assert_eq!(Platform::Pi.agent_extension(), "md");
709 }
710
711 #[test]
712 fn only_codex_transforms_agents() {
713 assert!(Platform::Codex.transforms_agent_to_toml());
714 assert!(!Platform::Claude.transforms_agent_to_toml());
715 assert!(!Platform::Opencode.transforms_agent_to_toml());
716 }
717
718 #[test]
721 fn serializes_to_lowercase_cli_name() {
722 assert_eq!(serde_json::to_string(&Platform::Codex).unwrap(), "\"codex\"");
725 assert_eq!(serde_json::to_string(&Platform::Claude).unwrap(), "\"claude\"");
726 assert_eq!(serde_json::from_str::<Platform>("\"openhands\"").unwrap(), Platform::Openhands);
727 }
728
729 #[test]
730 fn slug_values() {
731 assert_eq!(Platform::Claude.slug(), "");
732 assert_eq!(Platform::Copilot.slug(), "copilot");
733 assert_eq!(Platform::Cursor.slug(), "cursor");
734 assert_eq!(Platform::Windsurf.slug(), "windsurf");
735 assert_eq!(Platform::Gemini.slug(), "gemini");
736 assert_eq!(Platform::Opencode.slug(), "opencode");
737 assert_eq!(Platform::Codex.slug(), "codex");
738 assert_eq!(Platform::Pi.slug(), "pi");
739 assert_eq!(Platform::Crush.slug(), "crush");
740 assert_eq!(Platform::Amp.slug(), "amp");
741 assert_eq!(Platform::Zed.slug(), "zed");
742 assert_eq!(Platform::Openhands.slug(), "openhands");
743 assert_eq!(Platform::Hermes.slug(), "hermes");
744 assert_eq!(Platform::Devin.slug(), "devin");
745 }
746
747 #[test]
750 fn all_contains_every_variant() {
751 let every = [
752 Platform::Claude,
753 Platform::Copilot,
754 Platform::Cursor,
755 Platform::Windsurf,
756 Platform::Gemini,
757 Platform::Opencode,
758 Platform::Codex,
759 Platform::Pi,
760 Platform::Crush,
761 Platform::Amp,
762 Platform::Zed,
763 Platform::Openhands,
764 Platform::Hermes,
765 Platform::Devin,
766 ];
767 assert_eq!(Platform::ALL.len(), every.len(), "ALL must list every variant");
768 for p in every {
769 assert!(Platform::ALL.contains(&p), "{p} missing from Platform::ALL");
770 }
771 }
772
773 #[test]
774 fn all_slugs_are_unique() {
775 let mut slugs: Vec<&str> = Platform::ALL.iter().map(|p| p.slug()).collect();
776 let count = slugs.len();
777 slugs.sort_unstable();
778 slugs.dedup();
779 assert_eq!(slugs.len(), count, "platform slugs must be unique across ALL");
780 }
781
782 #[test]
783 fn value_enum_parses_all_variants() {
784 use clap::ValueEnum;
785 assert_eq!(Platform::from_str("claude", true).unwrap(), Platform::Claude);
786 assert_eq!(Platform::from_str("copilot", true).unwrap(), Platform::Copilot);
787 assert_eq!(Platform::from_str("cursor", true).unwrap(), Platform::Cursor);
788 assert_eq!(Platform::from_str("windsurf", true).unwrap(), Platform::Windsurf);
789 assert_eq!(Platform::from_str("gemini", true).unwrap(), Platform::Gemini);
790 assert_eq!(Platform::from_str("opencode", true).unwrap(), Platform::Opencode);
791 assert_eq!(Platform::from_str("codex", true).unwrap(), Platform::Codex);
792 assert_eq!(Platform::from_str("pi", true).unwrap(), Platform::Pi);
793 assert_eq!(Platform::from_str("crush", true).unwrap(), Platform::Crush);
794 assert_eq!(Platform::from_str("amp", true).unwrap(), Platform::Amp);
795 assert_eq!(Platform::from_str("zed", true).unwrap(), Platform::Zed);
796 assert_eq!(Platform::from_str("openhands", true).unwrap(), Platform::Openhands);
797 assert_eq!(Platform::from_str("hermes", true).unwrap(), Platform::Hermes);
798 assert_eq!(Platform::from_str("devin", true).unwrap(), Platform::Devin);
799 }
800}