Skip to main content

cmx_core/
platform.rs

1use std::fmt;
2use std::path::PathBuf;
3
4use crate::types::{ArtifactKind, InstallScope};
5
6// --- private spec types ---
7
8#[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    /// `Some` only for platforms that define a plugin-manifest format (used by
18    /// cmf). `None` for `.agents`-standard tools, which have no such format.
19    manifest_dir: Option<&'static str>,
20    /// `Some` for platforms that support file-droppable agents; the variant
21    /// selects the installed format. `None` for skills-only tools.
22    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// --- enum ---
44
45/// The target AI coding assistant platform for artifact installation.
46///
47/// Serializes to its lowercase name (`"claude"`, `"codex"`, …) — the same token
48/// the `--platform` flag accepts — so the `platforms` list in `config.json`
49/// stays human-readable and round-trips with the CLI.
50#[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    /// Single authoritative per-variant data table.
89    ///
90    /// The exhaustive `match` forces any new variant to be listed here — the
91    /// compiler rejects a build that adds a 14th variant without filling in its
92    /// spec, eliminating the need for parallel matches elsewhere.
93    fn spec(self) -> PlatformSpec {
94        match self {
95            // Claude is the canonical manifest *source* (`.claude-plugin/`), not a
96            // target — cmf reads from it but never writes back to it.
97            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    /// The install directory for the given artifact kind and scope.
185    ///
186    /// The returned path is relative — to the project root for
187    /// [`InstallScope::Local`], or to `$HOME` for [`InstallScope::Global`]. It
188    /// already includes the kind-specific leaf directory (e.g. `agents`,
189    /// `skills`), so callers do not append a subdirectory.
190    ///
191    /// Returns `None` for unsupported `(platform, kind)` combinations (see
192    /// [`supports`]). Callers should gate on `supports` or `ensure_supports`
193    /// before calling this, so `None` indicates a programming error at the call
194    /// site.
195    ///
196    /// [`supports`]: Platform::supports
197    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            // Copilot reads project files from `.github` but stores user-scoped
208            // artifacts under `.copilot`.
209            (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            // Windsurf nests user-scoped artifacts under `.codeium/windsurf`.
220            (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            // opencode: markdown agents in `.opencode/agent` (singular leaf);
233            // user-scoped config lives under `~/.config/opencode`.
234            (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            // codex: TOML agents in `.codex/agents` (both scopes).
240            (Self::Codex, Agent) => Some(dir(".codex", "agents")),
241
242            // Skills-only tools have no droppable agent concept.
243            (
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            // Amp and Hermes diverge only at *global* scope.
255            (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            // All `.agents`-standard tools (plus Amp/Hermes at project scope)
261            // resolve skills to the shared cross-tool `.agents/skills` location.
262            (
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    /// Whether this platform supports installing the given artifact kind.
278    ///
279    /// Skills-only tools (Pi, Crush, Amp, Zed, `OpenHands`, Hermes) have no
280    /// file-droppable agent concept; `(platform, Agent)` is unsupported for
281    /// them. Derived from [`spec`](Self::spec) — a `None` `agent_format` means
282    /// skills-only.
283    pub fn supports(self, kind: ArtifactKind) -> bool {
284        kind == ArtifactKind::Skill || self.spec().agent_format.is_some()
285    }
286
287    /// The file extension for an installed agent on this platform.
288    ///
289    /// Most platforms store agents as markdown (`md`); codex uses TOML (`toml`)
290    /// and the source markdown is transformed during install.
291    pub fn agent_extension(self) -> &'static str {
292        match self.spec().agent_format {
293            Some(AgentFormat::Toml) => "toml",
294            _ => "md",
295        }
296    }
297
298    /// Whether installing an agent for this platform requires transforming the
299    /// source markdown into codex's TOML subagent format.
300    pub fn transforms_agent_to_toml(self) -> bool {
301        matches!(self.spec().agent_format, Some(AgentFormat::Toml))
302    }
303
304    /// Slug used to construct platform-specific lock file names.
305    ///
306    /// Claude returns an empty string (lock file stays `cmx-lock.json` for
307    /// backward compatibility). All other platforms return a non-empty slug.
308    pub fn slug(self) -> &'static str {
309        self.spec().slug
310    }
311
312    /// The directory name for this platform's plugin manifest (used by cmf).
313    ///
314    /// Returns `Some` only for platforms that receive generated manifest copies
315    /// (Copilot, Cursor, Windsurf, Gemini). Returns `None` for Claude (which is
316    /// the canonical manifest *source*, not a target) and for all
317    /// `.agents`-standard tools (opencode, codex, pi, Crush, Amp, Zed,
318    /// `OpenHands`, Hermes), which have no plugin-manifest format.
319    ///
320    /// Use [`targets`](Platform::targets) to iterate only the `Some` platforms.
321    pub fn manifest_dir(self) -> Option<&'static str> {
322        self.spec().manifest_dir
323    }
324
325    /// Every platform variant, for exhaustive cross-platform operations such as
326    /// the system survey (`cmx doctor`).
327    ///
328    /// Keep this in sync with the enum; the `all_contains_every_variant` test
329    /// guards against a variant being added without being listed here.
330    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    /// All platforms that receive generated plugin manifests.
348    ///
349    /// Derived from `ALL` by filtering for platforms where
350    /// [`manifest_dir`](Self::manifest_dir) is `Some`. The `.agents`-standard
351    /// tools (opencode, codex, pi, Crush, Amp, Zed, `OpenHands`, Hermes) are
352    /// excluded: none of them define a plugin/marketplace manifest format.
353    pub fn targets() -> Vec<Platform> {
354        Self::ALL.iter().filter(|p| p.manifest_dir().is_some()).copied().collect()
355    }
356
357    /// All manifest-target platforms paired with their manifest directory name.
358    ///
359    /// Each tuple `(platform, dir)` is guaranteed to have a non-empty `dir`
360    /// string — callers never need to handle an `Option`. Use this instead of
361    /// `targets()` when the manifest directory name is needed alongside the
362    /// platform, to avoid unwrapping `manifest_dir()` at each call site.
363    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
368/// A human-readable, comma-separated label for a set of platforms
369/// (e.g. `"claude, codex"`).
370///
371/// The single home for what was an identical `platforms_label`/`join_platforms`
372/// helper copy-pasted across the display modules and the sync core.
373pub 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        // Manifest targets receive generated copies.
407        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        // Claude is the manifest *source*, not a target.
412        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    // --- install_subpath: existing platforms (behavior preserved) ---
505
506    #[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    // --- install_subpath: new platforms ---
555
556    #[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        // supports(Agent) == install_subpath(Agent, _).is_some() must hold for
662        // every platform at every scope — the two predicates must never diverge.
663        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    // --- supports ---
676
677    #[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    // --- agent_extension / transform ---
710
711    #[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    // --- slug ---
727
728    #[test]
729    fn serializes_to_lowercase_cli_name() {
730        // The config `platforms` list must round-trip with the tokens the
731        // `--platform` flag and `cmx config platforms add` accept.
732        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    // --- ALL ---
756
757    #[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}