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.
5
6use std::path::Path;
7
8/// The embedded SKILL.md content.
9const SKILL_CONTENT: &str = include_str!("../assets/SKILL.md");
10
11/// Claude Code plugin.json manifest.
12const 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/// AI CLI skill target supported by `gcode init`.
19#[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
60/// All supported AI CLI skill targets.
61pub fn supported_targets() -> &'static [SkillTarget] {
62    SKILL_TARGETS
63}
64
65/// Install the gcode skill for a supported CLI target.
66/// Returns the path where the skill was installed.
67pub 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
74/// Install as a Claude Code plugin with plugin.json + skills/gcode/SKILL.md
75fn 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
87/// Install as a SKILL.md in the CLI's skills directory.
88fn 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}