1use std::path::Path;
7
8const SKILL_CONTENT: &str = include_str!("../assets/SKILL.md");
10
11const PLUGIN_JSON: &str = r#"{
13 "name": "gcode",
14 "description": "AST-aware code search, symbol navigation, and dependency graph analysis",
15 "version": "0.1.0"
16}"#;
17
18#[derive(Debug, Clone, Copy)]
20pub struct SkillTarget {
21 pub display_name: &'static str,
22 kind: InstallKind,
23}
24
25#[derive(Debug, Clone, Copy)]
26enum InstallKind {
27 ClaudePlugin,
28 SkillDir { cli_dir: &'static str },
29}
30
31const SKILL_TARGETS: &[SkillTarget] = &[
32 SkillTarget {
33 display_name: "Claude Code",
34 kind: InstallKind::ClaudePlugin,
35 },
36 SkillTarget {
37 display_name: "Codex",
38 kind: InstallKind::SkillDir { cli_dir: ".codex" },
39 },
40 SkillTarget {
41 display_name: "Droid",
42 kind: InstallKind::SkillDir {
43 cli_dir: ".factory",
44 },
45 },
46 SkillTarget {
47 display_name: "Grok",
48 kind: InstallKind::SkillDir { cli_dir: ".grok" },
49 },
50 SkillTarget {
51 display_name: "Qwen",
52 kind: InstallKind::SkillDir { cli_dir: ".qwen" },
53 },
54 SkillTarget {
55 display_name: "Antigravity CLI",
56 kind: InstallKind::SkillDir { cli_dir: ".agents" },
57 },
58];
59
60pub fn supported_targets() -> &'static [SkillTarget] {
62 SKILL_TARGETS
63}
64
65pub fn install_skill(project_root: &Path, target: &SkillTarget) -> std::io::Result<String> {
68 match target.kind {
69 InstallKind::ClaudePlugin => install_claude_plugin(project_root),
70 InstallKind::SkillDir { cli_dir } => install_skill_dir(project_root, cli_dir),
71 }
72}
73
74fn install_claude_plugin(project_root: &Path) -> std::io::Result<String> {
76 let plugin_dir = project_root.join(".claude-plugin");
77 std::fs::create_dir_all(&plugin_dir)?;
78 std::fs::write(plugin_dir.join("plugin.json"), PLUGIN_JSON)?;
79
80 let skill_dir = project_root.join("skills").join("gcode");
81 std::fs::create_dir_all(&skill_dir)?;
82 std::fs::write(skill_dir.join("SKILL.md"), SKILL_CONTENT)?;
83
84 Ok("skills/gcode/SKILL.md".to_string())
85}
86
87fn install_skill_dir(project_root: &Path, cli_dir: &str) -> std::io::Result<String> {
89 let skill_dir = project_root.join(cli_dir).join("skills").join("gcode");
90 std::fs::create_dir_all(&skill_dir)?;
91 std::fs::write(skill_dir.join("SKILL.md"), SKILL_CONTENT)?;
92
93 Ok(format!("{}/skills/gcode/SKILL.md", cli_dir))
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 fn target_path(project_root: &Path, target: &SkillTarget) -> std::path::PathBuf {
101 match target.kind {
102 InstallKind::ClaudePlugin => project_root.join("skills/gcode/SKILL.md"),
103 InstallKind::SkillDir { cli_dir } => {
104 project_root.join(cli_dir).join("skills/gcode/SKILL.md")
105 }
106 }
107 }
108
109 fn expected_reported_path(target: &SkillTarget) -> String {
110 match target.kind {
111 InstallKind::ClaudePlugin => "skills/gcode/SKILL.md".to_string(),
112 InstallKind::SkillDir { cli_dir } => format!("{cli_dir}/skills/gcode/SKILL.md"),
113 }
114 }
115
116 #[test]
117 fn plugin_json_is_valid() {
118 let manifest: serde_json::Value =
119 serde_json::from_str(PLUGIN_JSON).expect("plugin json parses");
120
121 assert_eq!(manifest["name"], "gcode");
122 assert_eq!(manifest["version"], "0.1.0");
123 assert!(
124 manifest["description"]
125 .as_str()
126 .is_some_and(|s| !s.is_empty())
127 );
128 }
129
130 #[test]
131 fn supported_targets_are_stable() {
132 let names: Vec<_> = supported_targets()
133 .iter()
134 .map(|target| target.display_name)
135 .collect();
136
137 assert_eq!(
138 names,
139 vec![
140 "Claude Code",
141 "Codex",
142 "Droid",
143 "Grok",
144 "Qwen",
145 "Antigravity CLI",
146 ]
147 );
148 }
149
150 #[test]
151 fn installs_skill_to_all_supported_target_paths() {
152 let tmp = tempfile::tempdir().expect("tempdir");
153
154 for target in supported_targets() {
155 let installed_path = install_skill(tmp.path(), target).expect("install skill");
156 let skill_path = target_path(tmp.path(), target);
157
158 assert_eq!(
159 std::fs::read_to_string(&skill_path).expect("read installed skill"),
160 SKILL_CONTENT
161 );
162 assert_eq!(installed_path, expected_reported_path(target));
163 }
164 }
165
166 #[test]
167 fn claude_plugin_manifest_is_written() {
168 let tmp = tempfile::tempdir().expect("tempdir");
169 let target = supported_targets()
170 .iter()
171 .find(|target| target.display_name == "Claude Code")
172 .expect("claude target");
173
174 let reported_path = install_skill(tmp.path(), target).expect("install claude skill");
175 let manifest_path = tmp.path().join(".claude-plugin/plugin.json");
176 let manifest: serde_json::Value = serde_json::from_str(
177 &std::fs::read_to_string(manifest_path).expect("read plugin manifest"),
178 )
179 .expect("parse plugin manifest");
180
181 assert_eq!(reported_path, "skills/gcode/SKILL.md");
182 assert_eq!(manifest["name"], "gcode");
183 assert_eq!(
184 manifest["description"],
185 "AST-aware code search, symbol navigation, and dependency graph analysis"
186 );
187 assert_eq!(manifest["version"], "0.1.0");
188 }
189
190 #[test]
191 fn installing_skills_does_not_delete_existing_cli_files() {
192 let tmp = tempfile::tempdir().expect("tempdir");
193 let sentinels = [
194 ".codex/config.toml",
195 ".factory/settings.json",
196 ".grok/notes.md",
197 ".qwen/state.json",
198 ".agents/memory.md",
199 ".claude-plugin/existing.json",
200 "skills/custom/SKILL.md",
201 ];
202
203 for path in sentinels {
204 let path = tmp.path().join(path);
205 std::fs::create_dir_all(path.parent().expect("sentinel parent"))
206 .expect("create sentinel parent");
207 std::fs::write(&path, "keep").expect("write sentinel");
208 }
209
210 for target in supported_targets() {
211 install_skill(tmp.path(), target).expect("install skill");
212 }
213
214 for path in sentinels {
215 assert_eq!(
216 std::fs::read_to_string(tmp.path().join(path)).expect("read sentinel"),
217 "keep"
218 );
219 }
220 }
221}