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.path().file_name().ok_or_else(|| {
238                    io::Error::new(io::ErrorKind::InvalidData, "missing file name")
239                })?;
240                copy_entry(child, &dest.join(child_name))?;
241            }
242        }
243        DirEntry::File(f) => {
244            if let Some(parent) = dest.parent() {
245                fs::create_dir_all(parent)?;
246            }
247            fs::write(dest, f.contents())?;
248        }
249    }
250    Ok(())
251}
252
253/// Extract the entire embedded bundle to `target`, replacing any
254/// existing content. Used by symlink mode as the symlink target.
255fn extract_bundle_to(target: &Path) -> io::Result<()> {
256    if target.exists() {
257        fs::remove_dir_all(target)?;
258    }
259    fs::create_dir_all(target)?;
260    EMBEDDED_SKILLS.extract(target)?;
261    Ok(())
262}
263
264#[cfg(unix)]
265fn create_symlink(src: &Path, dst: &Path) -> io::Result<()> {
266    std::os::unix::fs::symlink(src, dst)
267}
268
269#[cfg(windows)]
270fn create_symlink(src: &Path, dst: &Path) -> io::Result<()> {
271    if src.is_dir() {
272        std::os::windows::fs::symlink_dir(src, dst)
273    } else {
274        std::os::windows::fs::symlink_file(src, dst)
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use tempfile::TempDir;
282
283    #[test]
284    fn embedded_bundle_has_overview_and_template() {
285        // Sanity: the include_dir!() macro picked up real content.
286        assert!(EMBEDDED_SKILLS.get_dir("heliosproxy-overview").is_some());
287        assert!(EMBEDDED_SKILLS.get_file("_template.md").is_some());
288        assert!(EMBEDDED_SKILLS.get_file("_index/verb-map.md").is_some());
289    }
290
291    #[test]
292    fn embedded_bundle_has_22_skills() {
293        let n = EMBEDDED_SKILLS
294            .entries()
295            .iter()
296            .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)))
297            .count();
298        assert_eq!(
299            n, 22,
300            "expected 22 heliosproxy-* skill directories in the bundle"
301        );
302    }
303
304    #[test]
305    fn resolve_targets_errors_when_no_dirs_exist() {
306        let tmp = TempDir::new().unwrap();
307        let err = resolve_targets(tmp.path(), InstallTarget::Both).unwrap_err();
308        assert!(matches!(err, InstallError::NoTargetDir { .. }));
309    }
310
311    #[test]
312    fn resolve_targets_picks_existing_dirs() {
313        let tmp = TempDir::new().unwrap();
314        fs::create_dir_all(tmp.path().join(".claude")).unwrap();
315        let dirs = resolve_targets(tmp.path(), InstallTarget::Both).unwrap();
316        assert_eq!(dirs, vec![tmp.path().join(".claude/skills")]);
317    }
318
319    #[test]
320    fn install_copy_mode_writes_skill_files() {
321        let tmp = TempDir::new().unwrap();
322        fs::create_dir_all(tmp.path().join(".claude")).unwrap();
323        let report = install_skills_at(
324            tmp.path(),
325            InstallTarget::Claude,
326            InstallMode::Copy,
327            false,
328            false,
329        )
330        .unwrap();
331        assert!(report.changes() >= 22);
332        let f = tmp
333            .path()
334            .join(".claude/skills/heliosproxy-overview/SKILL.md");
335        assert!(f.exists());
336        let body = fs::read_to_string(&f).unwrap();
337        assert!(body.contains("HeliosProxy"));
338    }
339
340    #[test]
341    fn install_skips_existing_without_force() {
342        let tmp = TempDir::new().unwrap();
343        fs::create_dir_all(tmp.path().join(".claude/skills/heliosproxy-overview")).unwrap();
344        let report = install_skills_at(
345            tmp.path(),
346            InstallTarget::Claude,
347            InstallMode::Copy,
348            false,
349            false,
350        )
351        .unwrap();
352        assert!(report
353            .skipped
354            .iter()
355            .any(|p| p.ends_with("heliosproxy-overview")));
356    }
357
358    #[test]
359    fn install_force_overwrites() {
360        let tmp = TempDir::new().unwrap();
361        let pre = tmp.path().join(".claude/skills/heliosproxy-overview");
362        fs::create_dir_all(&pre).unwrap();
363        fs::write(pre.join("stale.txt"), b"old").unwrap();
364        let report = install_skills_at(
365            tmp.path(),
366            InstallTarget::Claude,
367            InstallMode::Copy,
368            true,
369            false,
370        )
371        .unwrap();
372        assert!(report
373            .overwrote
374            .iter()
375            .any(|p| p.ends_with("heliosproxy-overview")));
376        assert!(!pre.join("stale.txt").exists());
377        assert!(pre.join("SKILL.md").exists());
378    }
379
380    #[test]
381    fn dry_run_writes_nothing() {
382        let tmp = TempDir::new().unwrap();
383        fs::create_dir_all(tmp.path().join(".claude")).unwrap();
384        let report = install_skills_at(
385            tmp.path(),
386            InstallTarget::Claude,
387            InstallMode::Copy,
388            false,
389            true,
390        )
391        .unwrap();
392        assert!(report.changes() >= 22);
393        assert!(!tmp
394            .path()
395            .join(".claude/skills/heliosproxy-overview")
396            .exists());
397    }
398
399    #[cfg(unix)]
400    #[test]
401    fn install_symlink_mode_creates_symlinks() {
402        let tmp = TempDir::new().unwrap();
403        fs::create_dir_all(tmp.path().join(".claude")).unwrap();
404        let report = install_skills_at(
405            tmp.path(),
406            InstallTarget::Claude,
407            InstallMode::Symlink,
408            false,
409            false,
410        )
411        .unwrap();
412        assert!(report.changes() >= 22);
413        let link = tmp.path().join(".claude/skills/heliosproxy-overview");
414        let meta = fs::symlink_metadata(&link).unwrap();
415        assert!(meta.file_type().is_symlink());
416        let target = fs::read_link(&link).unwrap();
417        assert!(
418            target
419                .to_string_lossy()
420                .contains(".local/share/heliosdb-proxy/skills"),
421            "symlink target unexpected: {}",
422            target.display()
423        );
424        let cache = tmp
425            .path()
426            .join(".local/share/heliosdb-proxy/skills/heliosproxy-overview/SKILL.md");
427        assert!(cache.exists());
428    }
429
430    #[cfg(unix)]
431    #[test]
432    fn install_symlink_then_force_replaces_link() {
433        // Re-run scenario: simulate the binary being upgraded — operator
434        // re-runs the command, and the prior symlink should be replaced
435        // and pointed at the freshly extracted cache.
436        let tmp = TempDir::new().unwrap();
437        fs::create_dir_all(tmp.path().join(".claude")).unwrap();
438        install_skills_at(
439            tmp.path(),
440            InstallTarget::Claude,
441            InstallMode::Symlink,
442            false,
443            false,
444        )
445        .unwrap();
446        let report = install_skills_at(
447            tmp.path(),
448            InstallTarget::Claude,
449            InstallMode::Symlink,
450            true, // force on the second run
451            false,
452        )
453        .unwrap();
454        assert!(report.changes() >= 22);
455    }
456}