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
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
254fn 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 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 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, false,
419 )
420 .unwrap();
421 assert!(report.changes() >= 22);
422 }
423}