1use include_dir::{include_dir, Dir, DirEntry};
25use std::fs;
26use std::io;
27use std::path::{Path, PathBuf};
28use thiserror::Error;
29
30pub static EMBEDDED_SKILLS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/.claude/skills");
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum InstallTarget {
40 Claude,
42 Codex,
44 Both,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum InstallMode {
51 Copy,
53 Symlink,
55}
56
57#[derive(Debug, Default)]
59pub struct InstallReport {
60 pub installed: Vec<PathBuf>,
62 pub skipped: Vec<PathBuf>,
64 pub overwrote: Vec<PathBuf>,
66 pub errors: Vec<(PathBuf, String)>,
68}
69
70impl InstallReport {
71 pub fn changes(&self) -> usize {
73 self.installed.len() + self.overwrote.len()
74 }
75}
76
77#[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
91pub 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
106pub 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 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
144fn 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
169fn 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
221fn 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
231fn 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
253fn 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 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 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, false,
452 )
453 .unwrap();
454 assert!(report.changes() >= 22);
455 }
456}