1mod clean;
4mod common;
5mod status;
6mod sync;
7
8use anyhow::{Context, Result};
9use clap::{Parser, Subcommand};
10use serde::Serialize;
11
12pub use common::OutputFormat;
13
14pub type SkillsFormat = OutputFormat;
19
20#[derive(Parser)]
22pub struct SkillsCommand {
23 #[command(subcommand)]
25 pub command: SkillsSubcommands,
26}
27
28#[derive(Subcommand)]
30pub enum SkillsSubcommands {
31 Sync(sync::SyncCommand),
33 Clean(clean::CleanCommand),
35 Status(status::StatusCommand),
37}
38
39impl SkillsCommand {
40 pub fn execute(self) -> Result<()> {
42 match self.command {
43 SkillsSubcommands::Sync(cmd) => cmd.execute(),
44 SkillsSubcommands::Clean(cmd) => cmd.execute(),
45 SkillsSubcommands::Status(cmd) => cmd.execute(),
46 }
47 }
48}
49
50pub fn run_sync(
66 base_dir: Option<&std::path::Path>,
67 worktrees: bool,
68 format: OutputFormat,
69) -> Result<String> {
70 let base = resolve_base_dir(base_dir)?;
71 let source_root = common::resolve_toplevel(&base)?;
72 let target_root = source_root.clone();
73
74 let mut targets = vec![target_root.clone()];
75 if worktrees {
76 for wt in common::list_worktrees(&target_root)? {
77 if !targets.iter().any(|t| t == &wt) {
78 targets.push(wt);
79 }
80 }
81 }
82
83 let report = sync::run_sync(&source_root, &targets, false)?;
84 render_sync_report(&report, format)
85}
86
87pub fn run_clean(
91 base_dir: Option<&std::path::Path>,
92 worktrees: bool,
93 format: OutputFormat,
94) -> Result<String> {
95 let base = resolve_base_dir(base_dir)?;
96 let target_root = common::resolve_toplevel(&base)?;
97
98 let mut targets = vec![target_root.clone()];
99 if worktrees {
100 for wt in common::list_worktrees(&target_root)? {
101 if !targets.iter().any(|t| t == &wt) {
102 targets.push(wt);
103 }
104 }
105 }
106
107 let report = clean::run_clean(&targets, false)?;
108 render_clean_report(&report, format)
109}
110
111pub fn run_status(
115 base_dir: Option<&std::path::Path>,
116 worktrees: bool,
117 format: OutputFormat,
118) -> Result<String> {
119 let base = resolve_base_dir(base_dir)?;
120 let target_root = common::resolve_toplevel(&base)?;
121
122 let mut targets = vec![target_root.clone()];
123 if worktrees {
124 for wt in common::list_worktrees(&target_root)? {
125 if !targets.iter().any(|t| t == &wt) {
126 targets.push(wt);
127 }
128 }
129 }
130
131 let report = status::run_status(&targets)?;
132 render_status_report(&report, format)
133}
134
135fn resolve_base_dir(base_dir: Option<&std::path::Path>) -> Result<std::path::PathBuf> {
136 match base_dir {
137 Some(p) => Ok(p.to_path_buf()),
138 None => std::env::current_dir().context("Failed to determine current directory"),
139 }
140}
141
142#[derive(Serialize)]
143struct SyncOutput<'a> {
144 dry_run: bool,
145 actions: &'a [sync::SyncAction],
146 errors: &'a [sync::SyncError],
147}
148
149#[derive(Serialize)]
150struct CleanOutput<'a> {
151 dry_run: bool,
152 actions: &'a [clean::CleanAction],
153}
154
155#[derive(Serialize)]
156struct StatusOutput<'a> {
157 targets: &'a [status::TargetStatus],
158}
159
160fn render_sync_report(report: &sync::SyncReport, format: OutputFormat) -> Result<String> {
161 match format {
162 OutputFormat::Text => Ok(render_sync_text(report)),
163 OutputFormat::Yaml => {
164 let output = SyncOutput {
165 dry_run: false,
166 actions: &report.actions,
167 errors: &report.errors,
168 };
169 serde_yaml::to_string(&output).context("Failed to serialize sync report as YAML")
170 }
171 }
172}
173
174fn render_sync_text(report: &sync::SyncReport) -> String {
175 use std::fmt::Write;
176 let mut out = String::new();
177 for action in &report.actions {
178 match action {
179 sync::SyncAction::Linked { link, points_to } => {
180 let _ = writeln!(out, "linked {} -> {}", link.display(), points_to.display());
181 }
182 sync::SyncAction::Relinked { link, points_to } => {
183 let _ = writeln!(
184 out,
185 "relinked {} -> {}",
186 link.display(),
187 points_to.display()
188 );
189 }
190 sync::SyncAction::Excluded {
191 exclude_file,
192 entry,
193 } => {
194 let _ = writeln!(out, "excluded {} in {}", entry, exclude_file.display());
195 }
196 sync::SyncAction::SkippedSameTarget { target } => {
197 let _ = writeln!(out, "skipped {} (target equals source)", target.display());
198 }
199 }
200 }
201 for err in &report.errors {
202 let _ = writeln!(out, "error: {} -- {}", err.target.display(), err.reason);
203 }
204 out
205}
206
207fn render_clean_report(report: &clean::CleanReport, format: OutputFormat) -> Result<String> {
208 match format {
209 OutputFormat::Text => Ok(render_clean_text(report)),
210 OutputFormat::Yaml => {
211 let output = CleanOutput {
212 dry_run: false,
213 actions: &report.actions,
214 };
215 serde_yaml::to_string(&output).context("Failed to serialize clean report as YAML")
216 }
217 }
218}
219
220fn render_clean_text(report: &clean::CleanReport) -> String {
221 use std::fmt::Write;
222 let mut out = String::new();
223 for action in &report.actions {
224 match action {
225 clean::CleanAction::Unlinked { link } => {
226 let _ = writeln!(out, "unlinked {}", link.display());
227 }
228 clean::CleanAction::Preserved { path, reason } => {
229 let _ = writeln!(out, "preserved {} ({reason})", path.display());
230 }
231 clean::CleanAction::ExcludeRemoved {
232 exclude_file,
233 entry,
234 } => {
235 let _ = writeln!(
236 out,
237 "removed exclude entry {} from {}",
238 entry,
239 exclude_file.display()
240 );
241 }
242 clean::CleanAction::DirectoryRemoved { path } => {
243 let _ = writeln!(out, "removed empty {}", path.display());
244 }
245 }
246 }
247 out
248}
249
250fn render_status_report(report: &status::StatusReport, format: OutputFormat) -> Result<String> {
251 match format {
252 OutputFormat::Text => Ok(render_status_text(report)),
253 OutputFormat::Yaml => {
254 let output = StatusOutput {
255 targets: &report.targets,
256 };
257 serde_yaml::to_string(&output).context("Failed to serialize status report as YAML")
258 }
259 }
260}
261
262fn render_status_text(report: &status::StatusReport) -> String {
263 use std::fmt::Write;
264 let mut out = String::new();
265 let mut first = true;
266 for target in &report.targets {
267 if target.symlinks.is_empty() && target.exclude_entries.is_empty() {
268 continue;
269 }
270 if !first {
271 out.push('\n');
272 }
273 first = false;
274 let _ = writeln!(out, "{}", target.root.display());
275 if !target.symlinks.is_empty() {
276 out.push_str(" symlinks:\n");
277 for link in &target.symlinks {
278 let _ = writeln!(
279 out,
280 " {} -> {}",
281 link.path.display(),
282 link.points_to.display()
283 );
284 }
285 }
286 if !target.exclude_entries.is_empty() {
287 let _ = writeln!(out, " exclude block ({}):", target.exclude_file.display());
288 for entry in &target.exclude_entries {
289 let _ = writeln!(out, " {entry}");
290 }
291 }
292 }
293 out
294}
295
296#[cfg(test)]
297#[allow(clippy::unwrap_used, clippy::expect_used)]
298mod skills_api_tests {
299 use super::*;
300
301 use std::fs;
302 use std::path::{Path, PathBuf};
303 use std::process::Command;
304
305 use tempfile::TempDir;
306
307 fn tempdir() -> TempDir {
308 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
311 fs::create_dir_all(&root).ok();
312 TempDir::new_in(&root).unwrap()
313 }
314
315 fn init_repo(dir: &Path) {
316 let status = Command::new("git").arg("init").arg(dir).output().unwrap();
317 assert!(status.status.success());
318 }
319
320 fn init_repo_with_commit(dir: &Path) {
321 init_repo(dir);
322 fs::write(dir.join("README.md"), "readme").unwrap();
323 let add = Command::new("git")
324 .args(["add", "README.md"])
325 .current_dir(dir)
326 .output()
327 .unwrap();
328 assert!(add.status.success());
329 let commit = Command::new("git")
330 .args([
331 "-c",
332 "user.email=x@x",
333 "-c",
334 "user.name=x",
335 "commit",
336 "-q",
337 "-m",
338 "init",
339 ])
340 .current_dir(dir)
341 .output()
342 .unwrap();
343 assert!(commit.status.success());
344 }
345
346 fn make_source_skills(root: &Path, names: &[&str]) {
347 let dir = root.join(".claude/skills");
348 fs::create_dir_all(&dir).unwrap();
349 for n in names {
350 let d = dir.join(n);
351 fs::create_dir_all(&d).unwrap();
352 fs::write(d.join("SKILL.md"), format!("# {n}")).unwrap();
353 }
354 }
355
356 #[test]
357 fn run_sync_mcp_with_worktrees_links_skills_and_returns_yaml() {
358 let src = tempdir();
359 let wt_parent = tempdir();
360 let linked = wt_parent.path().join("linked");
361 init_repo_with_commit(src.path());
362 make_source_skills(src.path(), &["alpha"]);
363 let add_wt = Command::new("git")
364 .args(["worktree", "add", "-q"])
365 .arg(&linked)
366 .current_dir(src.path())
367 .output()
368 .unwrap();
369 assert!(add_wt.status.success());
370
371 let out = run_sync(Some(&linked), true, OutputFormat::Yaml).unwrap();
372 assert!(out.contains("dry_run: false"), "missing dry_run: {out}");
373 assert!(out.contains("actions:"), "missing actions: {out}");
374 }
375
376 #[test]
377 fn run_sync_mcp_same_source_target_reports_skipped() {
378 let src = tempdir();
379 init_repo(src.path());
380 make_source_skills(src.path(), &["alpha"]);
381 let out = run_sync(Some(src.path()), false, OutputFormat::Text).unwrap();
382 assert!(out.contains("skipped"), "expected skipped action: {out}");
383 }
384
385 #[test]
386 fn run_clean_mcp_empty_repo_returns_empty_string_text() {
387 let tgt = tempdir();
388 init_repo(tgt.path());
389 let out = run_clean(Some(tgt.path()), false, OutputFormat::Text).unwrap();
390 assert!(out.is_empty(), "expected no actions, got: {out}");
391 }
392
393 #[test]
394 fn run_clean_mcp_yaml_reports_empty_actions() {
395 let tgt = tempdir();
396 init_repo(tgt.path());
397 let out = run_clean(Some(tgt.path()), false, OutputFormat::Yaml).unwrap();
398 assert!(out.contains("dry_run: false"), "missing dry_run: {out}");
399 assert!(out.contains("actions:"), "missing actions: {out}");
400 }
401
402 #[test]
403 fn run_clean_mcp_with_worktrees_covers_all_worktrees() {
404 let main = tempdir();
405 init_repo_with_commit(main.path());
406 let wt_parent = tempdir();
407 let linked = wt_parent.path().join("linked");
408 let add_wt = Command::new("git")
409 .args(["worktree", "add", "-q"])
410 .arg(&linked)
411 .current_dir(main.path())
412 .output()
413 .unwrap();
414 assert!(add_wt.status.success());
415
416 let out = run_clean(Some(main.path()), true, OutputFormat::Yaml).unwrap();
417 assert!(out.contains("actions:"), "missing actions: {out}");
418 }
419
420 #[test]
421 fn run_status_mcp_empty_repo_text_is_empty() {
422 let tgt = tempdir();
423 init_repo(tgt.path());
424 let out = run_status(Some(tgt.path()), false, OutputFormat::Text).unwrap();
425 assert!(out.is_empty(), "expected no residue, got: {out}");
426 }
427
428 #[test]
429 fn run_status_mcp_yaml_emits_targets_array() {
430 let tgt = tempdir();
431 init_repo(tgt.path());
432 let out = run_status(Some(tgt.path()), false, OutputFormat::Yaml).unwrap();
433 assert!(out.contains("targets:"), "missing targets: {out}");
434 }
435
436 #[cfg(unix)]
437 #[test]
438 fn run_status_mcp_reports_symlinks_in_text() {
439 let src = tempdir();
440 let tgt = tempdir();
441 init_repo(tgt.path());
442 let src_skill = src.path().join("alpha");
443 fs::create_dir_all(&src_skill).unwrap();
444 let skills_dir = tgt.path().join(".claude/skills");
445 fs::create_dir_all(&skills_dir).unwrap();
446 std::os::unix::fs::symlink(&src_skill, skills_dir.join("alpha")).unwrap();
447
448 let out = run_status(Some(tgt.path()), false, OutputFormat::Text).unwrap();
449 assert!(out.contains(".claude/skills/alpha"), "got: {out}");
450 }
451
452 #[test]
453 fn run_status_mcp_includes_worktrees_when_requested() {
454 let tgt_main = tempdir();
455 let wt_parent = tempdir();
456 let linked = wt_parent.path().join("linked");
457 init_repo_with_commit(tgt_main.path());
458 let add_wt = Command::new("git")
459 .args(["worktree", "add", "-q"])
460 .arg(&linked)
461 .current_dir(tgt_main.path())
462 .output()
463 .unwrap();
464 assert!(add_wt.status.success());
465
466 let out = run_status(Some(tgt_main.path()), true, OutputFormat::Yaml).unwrap();
467 assert!(out.contains("targets:"), "missing targets: {out}");
468 }
469
470 #[test]
471 fn run_sync_mcp_errors_outside_repo() {
472 let plain = TempDir::new().unwrap();
473 let err = run_sync(Some(plain.path()), false, OutputFormat::Text).unwrap_err();
474 let msg = format!("{err:?}");
475 assert!(
476 msg.contains("git rev-parse --show-toplevel failed")
477 || msg.contains("Failed to run git"),
478 "unexpected error: {msg}"
479 );
480 }
481
482 #[test]
483 fn run_clean_mcp_errors_outside_repo() {
484 let plain = TempDir::new().unwrap();
485 let err = run_clean(Some(plain.path()), false, OutputFormat::Text).unwrap_err();
486 let msg = format!("{err:?}");
487 assert!(msg.contains("git rev-parse"), "unexpected: {msg}");
488 }
489
490 #[test]
491 fn run_status_mcp_errors_outside_repo() {
492 let plain = TempDir::new().unwrap();
493 let err = run_status(Some(plain.path()), false, OutputFormat::Text).unwrap_err();
494 let msg = format!("{err:?}");
495 assert!(msg.contains("git rev-parse"), "unexpected: {msg}");
496 }
497
498 #[test]
499 fn run_status_mcp_defaults_base_dir_to_cwd() {
500 let _ = run_status(None, false, OutputFormat::Text);
503 }
504
505 #[test]
506 fn render_sync_text_covers_all_variants() {
507 let report = sync::SyncReport {
508 actions: vec![
509 sync::SyncAction::Linked {
510 link: PathBuf::from("/a"),
511 points_to: PathBuf::from("/b"),
512 },
513 sync::SyncAction::Relinked {
514 link: PathBuf::from("/c"),
515 points_to: PathBuf::from("/d"),
516 },
517 sync::SyncAction::Excluded {
518 exclude_file: PathBuf::from("/e"),
519 entry: ".claude/skills/x/".into(),
520 },
521 sync::SyncAction::SkippedSameTarget {
522 target: PathBuf::from("/f"),
523 },
524 ],
525 errors: vec![sync::SyncError {
526 target: PathBuf::from("/g"),
527 reason: "blocked".into(),
528 }],
529 };
530 let out = render_sync_text(&report);
531 for s in [
532 "linked /a -> /b",
533 "relinked /c -> /d",
534 "excluded .claude/skills/x/ in /e",
535 "skipped /f",
536 "error: /g",
537 ] {
538 assert!(out.contains(s), "missing {s}: {out}");
539 }
540 }
541
542 #[test]
543 fn render_clean_text_covers_all_variants() {
544 let report = clean::CleanReport {
545 actions: vec![
546 clean::CleanAction::Unlinked {
547 link: PathBuf::from("/a"),
548 },
549 clean::CleanAction::Preserved {
550 path: PathBuf::from("/b"),
551 reason: "real file".into(),
552 },
553 clean::CleanAction::ExcludeRemoved {
554 exclude_file: PathBuf::from("/c"),
555 entry: ".claude/skills/x/".into(),
556 },
557 clean::CleanAction::DirectoryRemoved {
558 path: PathBuf::from("/d"),
559 },
560 ],
561 };
562 let out = render_clean_text(&report);
563 for s in [
564 "unlinked /a",
565 "preserved /b (real file)",
566 "removed exclude entry .claude/skills/x/ from /c",
567 "removed empty /d",
568 ] {
569 assert!(out.contains(s), "missing {s}: {out}");
570 }
571 }
572
573 #[test]
574 fn render_status_text_covers_mixed_targets() {
575 let report = status::StatusReport {
576 targets: vec![
577 status::TargetStatus {
578 root: PathBuf::from("/empty"),
579 symlinks: Vec::new(),
580 exclude_file: PathBuf::from("/empty/.git/info/exclude"),
581 exclude_entries: Vec::new(),
582 },
583 status::TargetStatus {
584 root: PathBuf::from("/both"),
585 symlinks: vec![status::SymlinkInfo {
586 path: PathBuf::from("/both/.claude/skills/alpha"),
587 points_to: PathBuf::from("/src/alpha"),
588 }],
589 exclude_file: PathBuf::from("/both/.git/info/exclude"),
590 exclude_entries: vec![".claude/skills/alpha/".into()],
591 },
592 ],
593 };
594 let out = render_status_text(&report);
595 assert!(out.contains("/both"), "missing /both: {out}");
596 assert!(out.contains("symlinks:"), "missing symlinks: {out}");
597 assert!(
598 out.contains("exclude block"),
599 "missing exclude block: {out}"
600 );
601 assert!(
602 !out.contains("/empty\n"),
603 "should skip empty target header: {out}"
604 );
605 }
606
607 #[test]
608 fn render_sync_yaml_contains_dry_run_and_actions_keys() {
609 let report = sync::SyncReport {
610 actions: Vec::new(),
611 errors: Vec::new(),
612 };
613 let out = render_sync_report(&report, OutputFormat::Yaml).unwrap();
614 assert!(out.contains("dry_run: false"));
615 assert!(out.contains("actions:"));
616 assert!(out.contains("errors:"));
617 }
618
619 #[test]
620 fn render_clean_yaml_contains_dry_run_and_actions_keys() {
621 let report = clean::CleanReport {
622 actions: Vec::new(),
623 };
624 let out = render_clean_report(&report, OutputFormat::Yaml).unwrap();
625 assert!(out.contains("dry_run: false"));
626 assert!(out.contains("actions:"));
627 }
628
629 #[test]
630 fn render_status_yaml_contains_targets_key() {
631 let report = status::StatusReport {
632 targets: Vec::new(),
633 };
634 let out = render_status_report(&report, OutputFormat::Yaml).unwrap();
635 assert!(out.contains("targets:"));
636 }
637
638 #[allow(dead_code)]
640 fn _unused(_: PathBuf) {}
641}
642
643#[cfg(test)]
644#[allow(clippy::unwrap_used, clippy::expect_used)]
645mod tests {
646 use super::*;
647
648 use std::fs;
649 use std::path::Path;
650 use std::process::Command;
651
652 use tempfile::TempDir;
653
654 use common::OutputFormat;
655
656 fn tempdir() -> TempDir {
657 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
660 fs::create_dir_all(&root).ok();
661 TempDir::new_in(&root).unwrap()
662 }
663
664 fn init_repo(dir: &Path) {
665 let status = Command::new("git").arg("init").arg(dir).output().unwrap();
666 assert!(status.status.success());
667 }
668
669 #[test]
670 fn dispatch_sync() {
671 let src = tempdir();
672 let tgt = tempdir();
673 init_repo(src.path());
674 init_repo(tgt.path());
675 let skills_dir = src.path().join(".claude/skills/alpha");
676 fs::create_dir_all(&skills_dir).unwrap();
677 fs::write(skills_dir.join("SKILL.md"), "# alpha").unwrap();
678
679 let cmd = SkillsCommand {
680 command: SkillsSubcommands::Sync(sync::SyncCommand {
681 source: Some(src.path().to_path_buf()),
682 target: Some(tgt.path().to_path_buf()),
683 worktrees: false,
684 dry_run: false,
685 format: OutputFormat::Text,
686 }),
687 };
688 cmd.execute().unwrap();
689 assert!(tgt.path().join(".claude/skills/alpha").exists());
690 }
691
692 #[test]
693 fn dispatch_clean() {
694 let tgt = tempdir();
695 init_repo(tgt.path());
696
697 let cmd = SkillsCommand {
698 command: SkillsSubcommands::Clean(clean::CleanCommand {
699 target: Some(tgt.path().to_path_buf()),
700 worktrees: false,
701 dry_run: false,
702 format: OutputFormat::Text,
703 }),
704 };
705 cmd.execute().unwrap();
706 }
707
708 #[test]
709 fn dispatch_status() {
710 let tgt = tempdir();
711 init_repo(tgt.path());
712
713 let cmd = SkillsCommand {
714 command: SkillsSubcommands::Status(status::StatusCommand {
715 target: Some(tgt.path().to_path_buf()),
716 worktrees: false,
717 format: OutputFormat::Text,
718 }),
719 };
720 cmd.execute().unwrap();
721 }
722}