Skip to main content

gobby_code/
skill.rs

1//! Embedded gcode skill for AI CLI agents.
2//!
3//! Bundles the SKILL.md content and installs it to every supported
4//! project-level AI CLI skill target. Gemini CLI remains installed for
5//! compatibility, but it is deprecated.
6
7use std::path::Path;
8
9/// The embedded SKILL.md content.
10const SKILL_CONTENT: &str = include_str!("../assets/SKILL.md");
11
12/// Claude Code plugin.json manifest.
13const 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/// AI CLI skill target supported by `gcode init`.
20#[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    // Gemini CLI is deprecated; keep writing the skill for older setups.
56    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
66/// All supported AI CLI skill targets.
67pub fn supported_targets() -> &'static [SkillTarget] {
68    SKILL_TARGETS
69}
70
71/// Install the gcode skill for a supported CLI target.
72/// Returns the path where the skill was installed.
73pub 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
80/// Install as a Claude Code plugin with plugin.json + skills/gcode/SKILL.md
81fn 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
93/// Install as a SKILL.md in the CLI's skills directory.
94fn 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}