Skip to main content

dot_agent_core/
platform.rs

1//! Platform abstraction for multi-platform support
2//!
3//! Supports installing profiles/skills to different AI coding assistant platforms:
4//! - Claude Code (~/.claude/)
5//! - Codex CLI (~/.codex/skills/)
6
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11/// Target platform for installation
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
13#[serde(rename_all = "kebab-case")]
14pub enum Platform {
15    /// Claude Code (~/.claude/)
16    #[default]
17    Claude,
18    /// OpenAI Codex CLI (~/.codex/skills/)
19    Codex,
20}
21
22/// Directories supported by Claude Code
23pub const CLAUDE_SUPPORTED_DIRS: &[&str] = &[
24    "skills", "agents", "hooks", "rules", "commands",
25    "settings",
26    // Also supports root files like CLAUDE.md, settings.json, mcp.json, etc.
27];
28
29/// Directories supported by Codex CLI (skills only, using SKILL.md format)
30pub const CODEX_SUPPORTED_DIRS: &[&str] = &["skills"];
31
32/// Files that are platform-specific and should be filtered
33pub const CLAUDE_SPECIFIC_FILES: &[&str] = &[
34    "CLAUDE.md",
35    "settings.json",
36    "settings.local.json",
37    "mcp.json",
38    ".mcp.json",
39    "hooks.json",
40];
41
42impl Platform {
43    /// Get the base directory for this platform
44    pub fn base_dir(&self) -> PathBuf {
45        let home = dirs::home_dir().expect("Could not determine home directory");
46        match self {
47            Self::Claude => home.join(".claude"),
48            Self::Codex => home.join(".codex").join("skills"),
49        }
50    }
51
52    /// Get platform name for display
53    pub fn name(&self) -> &'static str {
54        match self {
55            Self::Claude => "Claude Code",
56            Self::Codex => "Codex CLI",
57        }
58    }
59
60    /// Get short identifier
61    pub fn id(&self) -> &'static str {
62        match self {
63            Self::Claude => "claude",
64            Self::Codex => "codex",
65        }
66    }
67
68    /// Get all supported platforms
69    pub fn all() -> &'static [Platform] {
70        &[Platform::Claude, Platform::Codex]
71    }
72
73    /// Get supported directories for this platform
74    pub fn supported_dirs(&self) -> &'static [&'static str] {
75        match self {
76            Self::Claude => CLAUDE_SUPPORTED_DIRS,
77            Self::Codex => CODEX_SUPPORTED_DIRS,
78        }
79    }
80
81    /// Check if a directory/file path is supported by this platform
82    ///
83    /// Returns true if:
84    /// - Claude: Almost everything is supported
85    /// - Codex: Only skills/ directory is supported
86    pub fn supports_path(&self, path: &std::path::Path) -> bool {
87        match self {
88            Self::Claude => true, // Claude supports everything
89            Self::Codex => {
90                // Codex only supports skills/
91                if let Some(first_component) = path.components().next() {
92                    let dir_name = first_component.as_os_str().to_string_lossy();
93                    // Check if it's a skills directory or a skill file at root
94                    dir_name == "skills" || path.extension().is_some_and(|ext| ext == "md")
95                } else {
96                    false
97                }
98            }
99        }
100    }
101
102    /// Check if a file is platform-specific (should not be copied to other platforms)
103    pub fn is_platform_specific_file(&self, filename: &str) -> bool {
104        match self {
105            Self::Claude => false, // Claude owns these files
106            Self::Codex => CLAUDE_SPECIFIC_FILES.contains(&filename),
107        }
108    }
109}
110
111impl std::fmt::Display for Platform {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        write!(f, "{}", self.name())
114    }
115}
116
117impl std::str::FromStr for Platform {
118    type Err = String;
119
120    fn from_str(s: &str) -> Result<Self, Self::Err> {
121        match s.to_lowercase().as_str() {
122            "claude" | "claude-code" => Ok(Self::Claude),
123            "codex" | "codex-cli" => Ok(Self::Codex),
124            _ => Err(format!("Unknown platform: {}", s)),
125        }
126    }
127}
128
129/// Installation target specifying which platform(s) to install to
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum InstallTarget {
132    /// Install to a single platform
133    Single(Platform),
134    /// Install to all supported platforms
135    All,
136    /// Install to custom path (ignores platform)
137    Custom(PathBuf),
138}
139
140impl Default for InstallTarget {
141    fn default() -> Self {
142        Self::Single(Platform::default())
143    }
144}
145
146impl InstallTarget {
147    /// Create target for Claude Code
148    pub fn claude() -> Self {
149        Self::Single(Platform::Claude)
150    }
151
152    /// Create target for Codex CLI
153    pub fn codex() -> Self {
154        Self::Single(Platform::Codex)
155    }
156
157    /// Create target for all platforms
158    pub fn all() -> Self {
159        Self::All
160    }
161
162    /// Create target for custom path
163    pub fn custom(path: PathBuf) -> Self {
164        Self::Custom(path)
165    }
166
167    /// Get the platforms this target represents
168    pub fn platforms(&self) -> Vec<Platform> {
169        match self {
170            Self::Single(p) => vec![*p],
171            Self::All => Platform::all().to_vec(),
172            Self::Custom(_) => vec![], // Custom path doesn't map to a platform
173        }
174    }
175
176    /// Get installation directories for this target
177    pub fn install_dirs(&self) -> Vec<PathBuf> {
178        match self {
179            Self::Single(p) => vec![p.base_dir()],
180            Self::All => Platform::all().iter().map(|p| p.base_dir()).collect(),
181            Self::Custom(path) => vec![path.clone()],
182        }
183    }
184
185    /// Check if this target includes a specific platform
186    pub fn includes(&self, platform: Platform) -> bool {
187        match self {
188            Self::Single(p) => *p == platform,
189            Self::All => true,
190            Self::Custom(_) => false,
191        }
192    }
193
194    /// Check if this is a multi-platform target
195    pub fn is_multi(&self) -> bool {
196        matches!(self, Self::All)
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn platform_base_dir() {
206        let home = dirs::home_dir().unwrap();
207        assert_eq!(Platform::Claude.base_dir(), home.join(".claude"));
208        assert_eq!(Platform::Codex.base_dir(), home.join(".codex/skills"));
209    }
210
211    #[test]
212    fn platform_from_str() {
213        assert_eq!("claude".parse::<Platform>().unwrap(), Platform::Claude);
214        assert_eq!("codex".parse::<Platform>().unwrap(), Platform::Codex);
215        assert!("unknown".parse::<Platform>().is_err());
216    }
217
218    #[test]
219    fn install_target_platforms() {
220        assert_eq!(InstallTarget::claude().platforms(), vec![Platform::Claude]);
221        assert_eq!(InstallTarget::codex().platforms(), vec![Platform::Codex]);
222        assert_eq!(
223            InstallTarget::all().platforms(),
224            vec![Platform::Claude, Platform::Codex]
225        );
226    }
227
228    #[test]
229    fn install_target_includes() {
230        assert!(InstallTarget::claude().includes(Platform::Claude));
231        assert!(!InstallTarget::claude().includes(Platform::Codex));
232        assert!(InstallTarget::all().includes(Platform::Claude));
233        assert!(InstallTarget::all().includes(Platform::Codex));
234    }
235}