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