Skip to main content

heliosdb_proxy/
skills.rs

1//! Embedded HeliosProxy skill-bundle deployer.
2//!
3//! Lets users who installed via `cargo install heliosdb-proxy`
4//! (no git clone, no repo on disk) deploy the `.claude/skills/`
5//! bundle into their Claude Code or Codex environment via:
6//!
7//! ```text
8//! heliosdb-proxy install skills              # copy into both ~/.claude and ~/.codex
9//! heliosdb-proxy install skills --symlink    # symlink (refreshes on next run after upgrade)
10//! heliosdb-proxy install skills --target claude --dry-run
11//! ```
12//!
13//! ## Modes
14//!
15//! - **Copy** (default): every skill file is written under
16//!   `<target>/skills/heliosproxy-<name>/SKILL.md`. Stable across
17//!   binary uninstalls.
18//! - **Symlink**: the bundle is first extracted to
19//!   `~/.local/share/heliosdb-proxy/skills/`, then each
20//!   `<target>/skills/heliosproxy-<name>` is a symlink into that
21//!   cache. Re-running after a binary upgrade overwrites the cache;
22//!   the symlinks resolve to the fresh content.
23
24use include_dir::{include_dir, Dir, DirEntry};
25use std::fs;
26use std::io;
27use std::path::{Path, PathBuf};
28use thiserror::Error;
29
30/// The 22-skill bundle, embedded at compile time.
31///
32/// Resolved relative to `CARGO_MANIFEST_DIR`. The directory must
33/// exist at build time. If you change the bundle layout, both this
34/// const and the deployer below need an audit.
35pub static EMBEDDED_SKILLS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/.claude/skills");
36
37/// Where to deploy.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum InstallTarget {
40    /// `~/.claude/skills/`
41    Claude,
42    /// `~/.codex/skills/`
43    Codex,
44    /// Both — install to whichever target dir(s) exist.
45    Both,
46}
47
48/// How to deploy.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum InstallMode {
51    /// Each skill is a real directory tree owned by the target.
52    Copy,
53    /// Each skill is a symlink into the binary-managed cache.
54    Symlink,
55}
56
57/// Outcome of an install run.
58#[derive(Debug, Default)]
59pub struct InstallReport {
60    /// New entries created.
61    pub installed: Vec<PathBuf>,
62    /// Pre-existing entries left untouched (no `--force`).
63    pub skipped: Vec<PathBuf>,
64    /// Pre-existing entries replaced (with `--force`).
65    pub overwrote: Vec<PathBuf>,
66    /// Per-entry errors (used when one entry fails but others succeed).
67    pub errors: Vec<(PathBuf, String)>,
68}
69
70impl InstallReport {
71    /// Total entries acted on (installed + overwrote).
72    pub fn changes(&self) -> usize {
73        self.installed.len() + self.overwrote.len()
74    }
75}
76
77/// Errors that abort the whole run.
78#[derive(Debug, Error)]
79pub enum InstallError {
80    #[error("$HOME is not set")]
81    NoHome,
82    #[error(
83        "no valid install target — neither {claude} nor {codex} exists; \
84         create the parent directory (`mkdir -p ~/.claude` or `~/.codex`) and retry"
85    )]
86    NoTargetDir { claude: String, codex: String },
87    #[error("io: {0}")]
88    Io(#[from] io::Error),
89}
90
91/// Install the embedded bundle into the user's Claude / Codex skills directory.
92///
93/// Returns a [`InstallReport`] summarising what happened. On
94/// `dry_run = true`, no filesystem writes occur but the report
95/// reflects what would have been done.
96pub fn install_skills(
97    target: InstallTarget,
98    mode: InstallMode,
99    force: bool,
100    dry_run: bool,
101) -> Result<InstallReport, InstallError> {
102    let home = std::env::var("HOME").map_err(|_| InstallError::NoHome)?;
103    install_skills_at(&PathBuf::from(home), target, mode, force, dry_run)
104}
105
106/// Same as [`install_skills`] but takes the home directory explicitly —
107/// mostly for tests, which can't rely on the process-global `$HOME`.
108pub fn install_skills_at(
109    home: &Path,
110    target: InstallTarget,
111    mode: InstallMode,
112    force: bool,
113    dry_run: bool,
114) -> Result<InstallReport, InstallError> {
115    let dirs = resolve_targets(home, target)?;
116
117    // For symlink mode, extract the bundle into a stable on-disk
118    // cache once, then point every per-target symlink at it.
119    let cache_dir = if mode == InstallMode::Symlink {
120        let cache = home.join(".local/share/heliosdb-proxy/skills");
121        if !dry_run {
122            extract_bundle_to(&cache)?;
123        }
124        Some(cache)
125    } else {
126        None
127    };
128
129    let mut report = InstallReport::default();
130    for dest_root in dirs {
131        deploy_to(
132            &dest_root,
133            cache_dir.as_deref(),
134            mode,
135            force,
136            dry_run,
137            &mut report,
138        )?;
139    }
140
141    Ok(report)
142}
143
144/// Resolve the requested install target into concrete `<dir>/skills` paths.
145fn resolve_targets(home: &Path, target: InstallTarget) -> Result<Vec<PathBuf>, InstallError> {
146    let claude_root = home.join(".claude");
147    let codex_root = home.join(".codex");
148
149    let want_claude = matches!(target, InstallTarget::Claude | InstallTarget::Both);
150    let want_codex = matches!(target, InstallTarget::Codex | InstallTarget::Both);
151
152    let mut out = Vec::new();
153    if want_claude && claude_root.exists() {
154        out.push(claude_root.join("skills"));
155    }
156    if want_codex && codex_root.exists() {
157        out.push(codex_root.join("skills"));
158    }
159
160    if out.is_empty() {
161        return Err(InstallError::NoTargetDir {
162            claude: claude_root.display().to_string(),
163            codex: codex_root.display().to_string(),
164        });
165    }
166    Ok(out)
167}
168
169/// Deploy every top-level entry from the embedded bundle into `dest_root`.
170fn deploy_to(
171    dest_root: &Path,
172    cache_dir: Option<&Path>,
173    mode: InstallMode,
174    force: bool,
175    dry_run: bool,
176    report: &mut InstallReport,
177) -> Result<(), InstallError> {
178    if !dry_run {
179        fs::create_dir_all(dest_root)?;
180    }
181
182    for entry in EMBEDDED_SKILLS.entries() {
183        let name = match entry.path().file_name().and_then(|n| n.to_str()) {
184            Some(n) => n,
185            None => continue,
186        };
187        let dest = dest_root.join(name);
188
189        let pre_exists = dest.exists() || dest.is_symlink();
190        if pre_exists && !force {
191            report.skipped.push(dest);
192            continue;
193        }
194        if pre_exists {
195            if !dry_run {
196                remove_path(&dest)?;
197            }
198            report.overwrote.push(dest.clone());
199        }
200
201        match mode {
202            InstallMode::Copy => {
203                if !dry_run {
204                    copy_entry(entry, &dest)?;
205                }
206            }
207            InstallMode::Symlink => {
208                let cache = cache_dir.expect("cache_dir set when symlink mode");
209                let src = cache.join(name);
210                if !dry_run {
211                    create_symlink(&src, &dest)?;
212                }
213            }
214        }
215        report.installed.push(dest);
216    }
217
218    Ok(())
219}
220
221/// Remove a file, directory, or symlink uniformly.
222fn remove_path(p: &Path) -> io::Result<()> {
223    let meta = fs::symlink_metadata(p)?;
224    if meta.file_type().is_dir() {
225        fs::remove_dir_all(p)
226    } else {
227        fs::remove_file(p)
228    }
229}
230
231/// Recursively materialise an embedded entry to disk.
232fn copy_entry(entry: &DirEntry<'_>, dest: &Path) -> io::Result<()> {
233    match entry {
234        DirEntry::Dir(d) => {
235            fs::create_dir_all(dest)?;
236            for child in d.entries() {
237                let child_name = child
238                    .path()
239                    .file_name()
240                    .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing file name"))?;
241                copy_entry(child, &dest.join(child_name))?;
242            }
243        }
244        DirEntry::File(f) => {
245            if let Some(parent) = dest.parent() {
246                fs::create_dir_all(parent)?;
247            }
248            fs::write(dest, f.contents())?;
249        }
250    }
251    Ok(())
252}
253
254/// Extract the entire embedded bundle to `target`, replacing any
255/// existing content. Used by symlink mode as the symlink target.
256fn extract_bundle_to(target: &Path) -> io::Result<()> {
257    if target.exists() {
258        fs::remove_dir_all(target)?;
259    }
260    fs::create_dir_all(target)?;
261    EMBEDDED_SKILLS.extract(target)?;
262    Ok(())
263}
264
265#[cfg(unix)]
266fn create_symlink(src: &Path, dst: &Path) -> io::Result<()> {
267    std::os::unix::fs::symlink(src, dst)
268}
269
270#[cfg(windows)]
271fn create_symlink(src: &Path, dst: &Path) -> io::Result<()> {
272    if src.is_dir() {
273        std::os::windows::fs::symlink_dir(src, dst)
274    } else {
275        std::os::windows::fs::symlink_file(src, dst)
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use tempfile::TempDir;
283
284    #[test]
285    fn embedded_bundle_has_overview_and_template() {
286        // Sanity: the include_dir!() macro picked up real content.
287        assert!(EMBEDDED_SKILLS.get_dir("heliosproxy-overview").is_some());
288        assert!(EMBEDDED_SKILLS.get_file("_template.md").is_some());
289        assert!(EMBEDDED_SKILLS.get_file("_index/verb-map.md").is_some());
290    }
291
292    #[test]
293    fn embedded_bundle_has_22_skills() {
294        let n = EMBEDDED_SKILLS
295            .entries()
296            .iter()
297            .filter(|e| matches!(e, DirEntry::Dir(d) if d.path().file_name().and_then(|f| f.to_str()).map(|n| n.starts_with("heliosproxy-")).unwrap_or(false)))
298            .count();
299        assert_eq!(n, 22, "expected 22 heliosproxy-* skill directories in the bundle");
300    }
301
302    #[test]
303    fn resolve_targets_errors_when_no_dirs_exist() {
304        let tmp = TempDir::new().unwrap();
305        let err = resolve_targets(tmp.path(), InstallTarget::Both).unwrap_err();
306        assert!(matches!(err, InstallError::NoTargetDir { .. }));
307    }
308
309    #[test]
310    fn resolve_targets_picks_existing_dirs() {
311        let tmp = TempDir::new().unwrap();
312        fs::create_dir_all(tmp.path().join(".claude")).unwrap();
313        let dirs = resolve_targets(tmp.path(), InstallTarget::Both).unwrap();
314        assert_eq!(dirs, vec![tmp.path().join(".claude/skills")]);
315    }
316
317    #[test]
318    fn install_copy_mode_writes_skill_files() {
319        let tmp = TempDir::new().unwrap();
320        fs::create_dir_all(tmp.path().join(".claude")).unwrap();
321        let report =
322            install_skills_at(tmp.path(), InstallTarget::Claude, InstallMode::Copy, false, false)
323                .unwrap();
324        assert!(report.changes() >= 22);
325        let f = tmp.path().join(".claude/skills/heliosproxy-overview/SKILL.md");
326        assert!(f.exists());
327        let body = fs::read_to_string(&f).unwrap();
328        assert!(body.contains("HeliosProxy"));
329    }
330
331    #[test]
332    fn install_skips_existing_without_force() {
333        let tmp = TempDir::new().unwrap();
334        fs::create_dir_all(tmp.path().join(".claude/skills/heliosproxy-overview")).unwrap();
335        let report =
336            install_skills_at(tmp.path(), InstallTarget::Claude, InstallMode::Copy, false, false)
337                .unwrap();
338        assert!(report.skipped.iter().any(|p| p.ends_with("heliosproxy-overview")));
339    }
340
341    #[test]
342    fn install_force_overwrites() {
343        let tmp = TempDir::new().unwrap();
344        let pre = tmp.path().join(".claude/skills/heliosproxy-overview");
345        fs::create_dir_all(&pre).unwrap();
346        fs::write(pre.join("stale.txt"), b"old").unwrap();
347        let report =
348            install_skills_at(tmp.path(), InstallTarget::Claude, InstallMode::Copy, true, false)
349                .unwrap();
350        assert!(report.overwrote.iter().any(|p| p.ends_with("heliosproxy-overview")));
351        assert!(!pre.join("stale.txt").exists());
352        assert!(pre.join("SKILL.md").exists());
353    }
354
355    #[test]
356    fn dry_run_writes_nothing() {
357        let tmp = TempDir::new().unwrap();
358        fs::create_dir_all(tmp.path().join(".claude")).unwrap();
359        let report =
360            install_skills_at(tmp.path(), InstallTarget::Claude, InstallMode::Copy, false, true)
361                .unwrap();
362        assert!(report.changes() >= 22);
363        assert!(!tmp.path().join(".claude/skills/heliosproxy-overview").exists());
364    }
365
366    #[cfg(unix)]
367    #[test]
368    fn install_symlink_mode_creates_symlinks() {
369        let tmp = TempDir::new().unwrap();
370        fs::create_dir_all(tmp.path().join(".claude")).unwrap();
371        let report = install_skills_at(
372            tmp.path(),
373            InstallTarget::Claude,
374            InstallMode::Symlink,
375            false,
376            false,
377        )
378        .unwrap();
379        assert!(report.changes() >= 22);
380        let link = tmp.path().join(".claude/skills/heliosproxy-overview");
381        let meta = fs::symlink_metadata(&link).unwrap();
382        assert!(meta.file_type().is_symlink());
383        let target = fs::read_link(&link).unwrap();
384        assert!(
385            target
386                .to_string_lossy()
387                .contains(".local/share/heliosdb-proxy/skills"),
388            "symlink target unexpected: {}",
389            target.display()
390        );
391        let cache = tmp
392            .path()
393            .join(".local/share/heliosdb-proxy/skills/heliosproxy-overview/SKILL.md");
394        assert!(cache.exists());
395    }
396
397    #[cfg(unix)]
398    #[test]
399    fn install_symlink_then_force_replaces_link() {
400        // Re-run scenario: simulate the binary being upgraded — operator
401        // re-runs the command, and the prior symlink should be replaced
402        // and pointed at the freshly extracted cache.
403        let tmp = TempDir::new().unwrap();
404        fs::create_dir_all(tmp.path().join(".claude")).unwrap();
405        install_skills_at(
406            tmp.path(),
407            InstallTarget::Claude,
408            InstallMode::Symlink,
409            false,
410            false,
411        )
412        .unwrap();
413        let report = install_skills_at(
414            tmp.path(),
415            InstallTarget::Claude,
416            InstallMode::Symlink,
417            true,  // force on the second run
418            false,
419        )
420        .unwrap();
421        assert!(report.changes() >= 22);
422    }
423}