Skip to main content

omni_dev/cli/ai/claude/
skills.rs

1//! Claude Code skills management commands.
2
3mod 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
14/// Output format selector for the MCP `claude_skills_*` tools.
15///
16/// Mirrors [`OutputFormat`] but lives at the module boundary so callers
17/// outside the CLI (e.g. MCP handlers) do not depend on the `clap` re-export.
18pub type SkillsFormat = OutputFormat;
19
20/// Worktree-aware distribution of Claude Code skills.
21#[derive(Parser)]
22pub struct SkillsCommand {
23    /// Skills subcommand to execute.
24    #[command(subcommand)]
25    pub command: SkillsSubcommands,
26
27    /// `-C/--repo`, inherited by every skills subcommand.
28    #[command(flatten)]
29    pub repo: crate::cli::repo_arg::RepoArg,
30}
31
32/// Skills subcommands.
33#[derive(Subcommand)]
34pub enum SkillsSubcommands {
35    /// Syncs skills from a source repository into one or more targets (mirrors the `claude_skills_sync` MCP tool).
36    Sync(sync::SyncCommand),
37    /// Removes skill symlinks and managed exclude block previously created by `sync` (mirrors the `claude_skills_clean` MCP tool).
38    Clean(clean::CleanCommand),
39    /// Reports residue left by `sync` — symlinks and managed exclude-block entries (mirrors the `claude_skills_status` MCP tool).
40    Status(status::StatusCommand),
41}
42
43impl SkillsCommand {
44    /// Executes the skills command.
45    ///
46    /// `-C/--repo` is resolved here (`None` = current working directory) and
47    /// threaded explicitly to the leaf.
48    pub fn execute(self) -> Result<()> {
49        let repo = self.repo.path();
50        match self.command {
51            SkillsSubcommands::Sync(cmd) => cmd.execute(repo),
52            SkillsSubcommands::Clean(cmd) => cmd.execute(repo),
53            SkillsSubcommands::Status(cmd) => cmd.execute(repo),
54        }
55    }
56}
57
58/// Syncs Claude skills and returns the report as a formatted string.
59///
60/// Non-interactive wrapper around the `sync` subcommand suitable for MCP
61/// callers. Source defaults to `base_dir` (or the current working directory
62/// when `base_dir` is `None`); target defaults to the source repository.
63/// When `worktrees` is true, every worktree belonging to the target
64/// repository is synced in addition to the target itself.
65///
66/// The operation **mutates the filesystem**: creates symlinks inside
67/// `.claude/skills/` and upserts a managed block inside
68/// `.git/info/exclude`. Always performed as a real (not dry) run — dry-run
69/// mode is a CLI convenience, not useful through MCP.
70///
71/// `base_dir` exists so callers can pass an explicit directory instead of
72/// mutating the process-wide cwd. Production MCP callers pass `None`.
73pub fn run_sync(
74    base_dir: Option<&std::path::Path>,
75    worktrees: bool,
76    format: OutputFormat,
77) -> Result<String> {
78    let base = resolve_base_dir(base_dir)?;
79    let source_root = common::resolve_toplevel(&base)?;
80    let target_root = source_root.clone();
81
82    let mut targets = vec![target_root.clone()];
83    if worktrees {
84        for wt in common::list_worktrees(&target_root)? {
85            if !targets.iter().any(|t| t == &wt) {
86                targets.push(wt);
87            }
88        }
89    }
90
91    let report = sync::run_sync(&source_root, &targets, false)?;
92    render_sync_report(&report, format)
93}
94
95/// Cleans Claude skill residue and returns the report as a formatted string.
96///
97/// Target defaults to `base_dir` (or cwd when `None`). Mutates the filesystem.
98pub fn run_clean(
99    base_dir: Option<&std::path::Path>,
100    worktrees: bool,
101    format: OutputFormat,
102) -> Result<String> {
103    let base = resolve_base_dir(base_dir)?;
104    let target_root = common::resolve_toplevel(&base)?;
105
106    let mut targets = vec![target_root.clone()];
107    if worktrees {
108        for wt in common::list_worktrees(&target_root)? {
109            if !targets.iter().any(|t| t == &wt) {
110                targets.push(wt);
111            }
112        }
113    }
114
115    let report = clean::run_clean(&targets, false)?;
116    render_clean_report(&report, format)
117}
118
119/// Reports Claude skill residue.
120///
121/// Target defaults to `base_dir` (or cwd when `None`). Read-only.
122pub fn run_status(
123    base_dir: Option<&std::path::Path>,
124    worktrees: bool,
125    format: OutputFormat,
126) -> Result<String> {
127    let base = resolve_base_dir(base_dir)?;
128    let target_root = common::resolve_toplevel(&base)?;
129
130    let mut targets = vec![target_root.clone()];
131    if worktrees {
132        for wt in common::list_worktrees(&target_root)? {
133            if !targets.iter().any(|t| t == &wt) {
134                targets.push(wt);
135            }
136        }
137    }
138
139    let report = status::run_status(&targets)?;
140    render_status_report(&report, format)
141}
142
143fn resolve_base_dir(base_dir: Option<&std::path::Path>) -> Result<std::path::PathBuf> {
144    match base_dir {
145        Some(p) => Ok(p.to_path_buf()),
146        None => std::env::current_dir().context("Failed to determine current directory"),
147    }
148}
149
150#[derive(Serialize)]
151struct SyncOutput<'a> {
152    dry_run: bool,
153    actions: &'a [sync::SyncAction],
154    errors: &'a [sync::SyncError],
155}
156
157#[derive(Serialize)]
158struct CleanOutput<'a> {
159    dry_run: bool,
160    actions: &'a [clean::CleanAction],
161}
162
163#[derive(Serialize)]
164struct StatusOutput<'a> {
165    targets: &'a [status::TargetStatus],
166}
167
168fn render_sync_report(report: &sync::SyncReport, format: OutputFormat) -> Result<String> {
169    match format {
170        OutputFormat::Text => Ok(render_sync_text(report)),
171        OutputFormat::Yaml => {
172            let output = SyncOutput {
173                dry_run: false,
174                actions: &report.actions,
175                errors: &report.errors,
176            };
177            serde_yaml::to_string(&output).context("Failed to serialize sync report as YAML")
178        }
179    }
180}
181
182fn render_sync_text(report: &sync::SyncReport) -> String {
183    use std::fmt::Write;
184    let mut out = String::new();
185    for action in &report.actions {
186        match action {
187            sync::SyncAction::Linked { link, points_to } => {
188                let _ = writeln!(out, "linked {} -> {}", link.display(), points_to.display());
189            }
190            sync::SyncAction::Relinked { link, points_to } => {
191                let _ = writeln!(
192                    out,
193                    "relinked {} -> {}",
194                    link.display(),
195                    points_to.display()
196                );
197            }
198            sync::SyncAction::Excluded {
199                exclude_file,
200                entry,
201            } => {
202                let _ = writeln!(out, "excluded {} in {}", entry, exclude_file.display());
203            }
204            sync::SyncAction::SkippedSameTarget { target } => {
205                let _ = writeln!(out, "skipped {} (target equals source)", target.display());
206            }
207        }
208    }
209    for err in &report.errors {
210        let _ = writeln!(out, "error: {} -- {}", err.target.display(), err.reason);
211    }
212    out
213}
214
215fn render_clean_report(report: &clean::CleanReport, format: OutputFormat) -> Result<String> {
216    match format {
217        OutputFormat::Text => Ok(render_clean_text(report)),
218        OutputFormat::Yaml => {
219            let output = CleanOutput {
220                dry_run: false,
221                actions: &report.actions,
222            };
223            serde_yaml::to_string(&output).context("Failed to serialize clean report as YAML")
224        }
225    }
226}
227
228fn render_clean_text(report: &clean::CleanReport) -> String {
229    use std::fmt::Write;
230    let mut out = String::new();
231    for action in &report.actions {
232        match action {
233            clean::CleanAction::Unlinked { link } => {
234                let _ = writeln!(out, "unlinked {}", link.display());
235            }
236            clean::CleanAction::Preserved { path, reason } => {
237                let _ = writeln!(out, "preserved {} ({reason})", path.display());
238            }
239            clean::CleanAction::ExcludeRemoved {
240                exclude_file,
241                entry,
242            } => {
243                let _ = writeln!(
244                    out,
245                    "removed exclude entry {} from {}",
246                    entry,
247                    exclude_file.display()
248                );
249            }
250            clean::CleanAction::DirectoryRemoved { path } => {
251                let _ = writeln!(out, "removed empty {}", path.display());
252            }
253        }
254    }
255    out
256}
257
258fn render_status_report(report: &status::StatusReport, format: OutputFormat) -> Result<String> {
259    match format {
260        OutputFormat::Text => Ok(render_status_text(report)),
261        OutputFormat::Yaml => {
262            let output = StatusOutput {
263                targets: &report.targets,
264            };
265            serde_yaml::to_string(&output).context("Failed to serialize status report as YAML")
266        }
267    }
268}
269
270fn render_status_text(report: &status::StatusReport) -> String {
271    report
272        .targets
273        .iter()
274        .filter(|t| !(t.symlinks.is_empty() && t.exclude_entries.is_empty()))
275        .map(render_status_target)
276        .collect::<Vec<_>>()
277        .join("\n")
278}
279
280fn render_status_target(target: &status::TargetStatus) -> String {
281    use std::fmt::Write;
282    let mut out = String::new();
283    let _ = writeln!(out, "{}", target.root.display());
284    if !target.symlinks.is_empty() {
285        out.push_str("  symlinks:\n");
286        for link in &target.symlinks {
287            let _ = writeln!(
288                out,
289                "    {} -> {}",
290                link.path.display(),
291                link.points_to.display()
292            );
293        }
294    }
295    if !target.exclude_entries.is_empty() {
296        let _ = writeln!(out, "  exclude block ({}):", target.exclude_file.display());
297        for entry in &target.exclude_entries {
298            let _ = writeln!(out, "    {entry}");
299        }
300    }
301    out
302}
303
304#[cfg(test)]
305#[allow(clippy::unwrap_used, clippy::expect_used)]
306mod skills_api_tests {
307    use super::*;
308
309    use std::fs;
310    use std::path::{Path, PathBuf};
311
312    use tempfile::TempDir;
313
314    use super::common::test_git::{init_repo, init_repo_with_commit, worktree_add};
315
316    fn tempdir() -> TempDir {
317        // Anchor tmp at an absolute path so concurrent chdir-ing tests can't
318        // cause a relative "tmp" to resolve inside someone else's tempdir.
319        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
320        fs::create_dir_all(&root).ok();
321        TempDir::new_in(&root).unwrap()
322    }
323
324    fn make_source_skills(root: &Path, names: &[&str]) {
325        let dir = root.join(".claude/skills");
326        fs::create_dir_all(&dir).unwrap();
327        for n in names {
328            let d = dir.join(n);
329            fs::create_dir_all(&d).unwrap();
330            fs::write(d.join("SKILL.md"), format!("# {n}")).unwrap();
331        }
332    }
333
334    #[test]
335    fn run_sync_mcp_with_worktrees_links_skills_and_returns_yaml() {
336        let src = tempdir();
337        let wt_parent = tempdir();
338        let linked = wt_parent.path().join("linked");
339        init_repo_with_commit(src.path());
340        make_source_skills(src.path(), &["alpha"]);
341        worktree_add(src.path(), &linked);
342
343        let out = run_sync(Some(&linked), true, OutputFormat::Yaml).unwrap();
344        assert!(out.contains("dry_run: false"), "missing dry_run: {out}");
345        assert!(out.contains("actions:"), "missing actions: {out}");
346    }
347
348    #[test]
349    fn run_sync_mcp_same_source_target_reports_skipped() {
350        let src = tempdir();
351        init_repo(src.path());
352        make_source_skills(src.path(), &["alpha"]);
353        let out = run_sync(Some(src.path()), false, OutputFormat::Text).unwrap();
354        assert!(out.contains("skipped"), "expected skipped action: {out}");
355    }
356
357    #[test]
358    fn run_clean_mcp_empty_repo_returns_empty_string_text() {
359        let tgt = tempdir();
360        init_repo(tgt.path());
361        let out = run_clean(Some(tgt.path()), false, OutputFormat::Text).unwrap();
362        assert!(out.is_empty(), "expected no actions, got: {out}");
363    }
364
365    #[test]
366    fn run_clean_mcp_yaml_reports_empty_actions() {
367        let tgt = tempdir();
368        init_repo(tgt.path());
369        let out = run_clean(Some(tgt.path()), false, OutputFormat::Yaml).unwrap();
370        assert!(out.contains("dry_run: false"), "missing dry_run: {out}");
371        assert!(out.contains("actions:"), "missing actions: {out}");
372    }
373
374    #[test]
375    fn run_clean_mcp_with_worktrees_covers_all_worktrees() {
376        let main = tempdir();
377        init_repo_with_commit(main.path());
378        let wt_parent = tempdir();
379        let linked = wt_parent.path().join("linked");
380        worktree_add(main.path(), &linked);
381
382        let out = run_clean(Some(main.path()), true, OutputFormat::Yaml).unwrap();
383        assert!(out.contains("actions:"), "missing actions: {out}");
384    }
385
386    #[test]
387    fn run_status_mcp_empty_repo_text_is_empty() {
388        let tgt = tempdir();
389        init_repo(tgt.path());
390        let out = run_status(Some(tgt.path()), false, OutputFormat::Text).unwrap();
391        assert!(out.is_empty(), "expected no residue, got: {out}");
392    }
393
394    #[test]
395    fn run_status_mcp_yaml_emits_targets_array() {
396        let tgt = tempdir();
397        init_repo(tgt.path());
398        let out = run_status(Some(tgt.path()), false, OutputFormat::Yaml).unwrap();
399        assert!(out.contains("targets:"), "missing targets: {out}");
400    }
401
402    #[cfg(unix)]
403    #[test]
404    fn run_status_mcp_reports_symlinks_in_text() {
405        let src = tempdir();
406        let tgt = tempdir();
407        init_repo(tgt.path());
408        let src_skill = src.path().join("alpha");
409        fs::create_dir_all(&src_skill).unwrap();
410        let skills_dir = tgt.path().join(".claude/skills");
411        fs::create_dir_all(&skills_dir).unwrap();
412        std::os::unix::fs::symlink(&src_skill, skills_dir.join("alpha")).unwrap();
413
414        let out = run_status(Some(tgt.path()), false, OutputFormat::Text).unwrap();
415        assert!(out.contains(".claude/skills/alpha"), "got: {out}");
416    }
417
418    #[test]
419    fn run_status_mcp_includes_worktrees_when_requested() {
420        let tgt_main = tempdir();
421        let wt_parent = tempdir();
422        let linked = wt_parent.path().join("linked");
423        init_repo_with_commit(tgt_main.path());
424        worktree_add(tgt_main.path(), &linked);
425
426        let out = run_status(Some(tgt_main.path()), true, OutputFormat::Yaml).unwrap();
427        assert!(out.contains("targets:"), "missing targets: {out}");
428    }
429
430    #[test]
431    fn run_sync_mcp_errors_outside_repo() {
432        let plain = TempDir::new().unwrap();
433        let err = run_sync(Some(plain.path()), false, OutputFormat::Text).unwrap_err();
434        let msg = format!("{err:?}");
435        assert!(
436            msg.contains("git rev-parse --show-toplevel failed")
437                || msg.contains("Failed to run git"),
438            "unexpected error: {msg}"
439        );
440    }
441
442    #[test]
443    fn run_clean_mcp_errors_outside_repo() {
444        let plain = TempDir::new().unwrap();
445        let err = run_clean(Some(plain.path()), false, OutputFormat::Text).unwrap_err();
446        let msg = format!("{err:?}");
447        assert!(msg.contains("git rev-parse"), "unexpected: {msg}");
448    }
449
450    #[test]
451    fn run_status_mcp_errors_outside_repo() {
452        let plain = TempDir::new().unwrap();
453        let err = run_status(Some(plain.path()), false, OutputFormat::Text).unwrap_err();
454        let msg = format!("{err:?}");
455        assert!(msg.contains("git rev-parse"), "unexpected: {msg}");
456    }
457
458    #[test]
459    fn run_status_mcp_defaults_base_dir_to_cwd() {
460        // Just verify None-path resolves to cwd: cwd may or may not be a repo,
461        // but this at least exercises resolve_base_dir's None branch.
462        let _ = run_status(None, false, OutputFormat::Text);
463    }
464
465    #[test]
466    fn render_sync_text_covers_all_variants() {
467        let report = sync::SyncReport {
468            actions: vec![
469                sync::SyncAction::Linked {
470                    link: PathBuf::from("/a"),
471                    points_to: PathBuf::from("/b"),
472                },
473                sync::SyncAction::Relinked {
474                    link: PathBuf::from("/c"),
475                    points_to: PathBuf::from("/d"),
476                },
477                sync::SyncAction::Excluded {
478                    exclude_file: PathBuf::from("/e"),
479                    entry: ".claude/skills/x/".into(),
480                },
481                sync::SyncAction::SkippedSameTarget {
482                    target: PathBuf::from("/f"),
483                },
484            ],
485            errors: vec![sync::SyncError {
486                target: PathBuf::from("/g"),
487                reason: "blocked".into(),
488            }],
489        };
490        let out = render_sync_text(&report);
491        for s in [
492            "linked /a -> /b",
493            "relinked /c -> /d",
494            "excluded .claude/skills/x/ in /e",
495            "skipped /f",
496            "error: /g",
497        ] {
498            assert!(out.contains(s), "missing {s}: {out}");
499        }
500    }
501
502    #[test]
503    fn render_clean_text_covers_all_variants() {
504        let report = clean::CleanReport {
505            actions: vec![
506                clean::CleanAction::Unlinked {
507                    link: PathBuf::from("/a"),
508                },
509                clean::CleanAction::Preserved {
510                    path: PathBuf::from("/b"),
511                    reason: "real file".into(),
512                },
513                clean::CleanAction::ExcludeRemoved {
514                    exclude_file: PathBuf::from("/c"),
515                    entry: ".claude/skills/x/".into(),
516                },
517                clean::CleanAction::DirectoryRemoved {
518                    path: PathBuf::from("/d"),
519                },
520            ],
521        };
522        let out = render_clean_text(&report);
523        for s in [
524            "unlinked /a",
525            "preserved /b (real file)",
526            "removed exclude entry .claude/skills/x/ from /c",
527            "removed empty /d",
528        ] {
529            assert!(out.contains(s), "missing {s}: {out}");
530        }
531    }
532
533    #[test]
534    fn render_status_text_covers_mixed_targets() {
535        let report = status::StatusReport {
536            targets: vec![
537                status::TargetStatus {
538                    root: PathBuf::from("/empty"),
539                    symlinks: Vec::new(),
540                    exclude_file: PathBuf::from("/empty/.git/info/exclude"),
541                    exclude_entries: Vec::new(),
542                },
543                status::TargetStatus {
544                    root: PathBuf::from("/first"),
545                    symlinks: Vec::new(),
546                    exclude_file: PathBuf::from("/first/.git/info/exclude"),
547                    exclude_entries: vec![".claude/skills/beta/".into()],
548                },
549                status::TargetStatus {
550                    root: PathBuf::from("/both"),
551                    symlinks: vec![status::SymlinkInfo {
552                        path: PathBuf::from("/both/.claude/skills/alpha"),
553                        points_to: PathBuf::from("/src/alpha"),
554                    }],
555                    exclude_file: PathBuf::from("/both/.git/info/exclude"),
556                    exclude_entries: vec![".claude/skills/alpha/".into()],
557                },
558            ],
559        };
560        let out = render_status_text(&report);
561        assert!(out.contains("/first"), "missing /first: {out}");
562        assert!(out.contains("/both"), "missing /both: {out}");
563        assert!(out.contains("symlinks:"), "missing symlinks: {out}");
564        assert!(
565            out.contains("exclude block"),
566            "missing exclude block: {out}"
567        );
568        // Two rendered targets must be separated by a blank line (the join).
569        assert!(
570            out.contains("\n\n"),
571            "missing blank-line separator between targets: {out}"
572        );
573        assert!(
574            !out.contains("/empty\n"),
575            "should skip empty target header: {out}"
576        );
577    }
578
579    #[test]
580    fn render_sync_yaml_contains_dry_run_and_actions_keys() {
581        let report = sync::SyncReport {
582            actions: Vec::new(),
583            errors: Vec::new(),
584        };
585        let out = render_sync_report(&report, OutputFormat::Yaml).unwrap();
586        assert!(out.contains("dry_run: false"));
587        assert!(out.contains("actions:"));
588        assert!(out.contains("errors:"));
589    }
590
591    #[test]
592    fn render_clean_yaml_contains_dry_run_and_actions_keys() {
593        let report = clean::CleanReport {
594            actions: Vec::new(),
595        };
596        let out = render_clean_report(&report, OutputFormat::Yaml).unwrap();
597        assert!(out.contains("dry_run: false"));
598        assert!(out.contains("actions:"));
599    }
600
601    #[test]
602    fn render_status_yaml_contains_targets_key() {
603        let report = status::StatusReport {
604            targets: Vec::new(),
605        };
606        let out = render_status_report(&report, OutputFormat::Yaml).unwrap();
607        assert!(out.contains("targets:"));
608    }
609
610    // Prevent clippy from complaining about unused imports in this block.
611    #[allow(dead_code)]
612    fn _unused(_: PathBuf) {}
613}
614
615#[cfg(test)]
616#[allow(clippy::unwrap_used, clippy::expect_used)]
617mod tests {
618    use super::*;
619
620    use std::fs;
621    use std::path::Path;
622
623    use tempfile::TempDir;
624
625    use super::common::test_git::{init_repo, init_repo_with_commit, worktree_add};
626    use common::OutputFormat;
627
628    fn tempdir() -> TempDir {
629        // Anchor tmp at an absolute path so concurrent chdir-ing tests can't
630        // cause a relative "tmp" to resolve inside someone else's tempdir.
631        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
632        fs::create_dir_all(&root).ok();
633        TempDir::new_in(&root).unwrap()
634    }
635
636    #[test]
637    fn dispatch_sync() {
638        let src = tempdir();
639        let tgt = tempdir();
640        init_repo(src.path());
641        init_repo(tgt.path());
642        let skills_dir = src.path().join(".claude/skills/alpha");
643        fs::create_dir_all(&skills_dir).unwrap();
644        fs::write(skills_dir.join("SKILL.md"), "# alpha").unwrap();
645
646        let cmd = SkillsCommand {
647            repo: crate::cli::repo_arg::RepoArg::default(),
648            command: SkillsSubcommands::Sync(sync::SyncCommand {
649                source: Some(src.path().to_path_buf()),
650                target: Some(tgt.path().to_path_buf()),
651                worktrees: false,
652                dry_run: false,
653                format: OutputFormat::Text,
654            }),
655        };
656        cmd.execute().unwrap();
657        assert!(tgt.path().join(".claude/skills/alpha").exists());
658    }
659
660    #[test]
661    fn dispatch_clean() {
662        let tgt = tempdir();
663        init_repo(tgt.path());
664
665        let cmd = SkillsCommand {
666            repo: crate::cli::repo_arg::RepoArg::default(),
667            command: SkillsSubcommands::Clean(clean::CleanCommand {
668                target: Some(tgt.path().to_path_buf()),
669                worktrees: false,
670                dry_run: false,
671                format: OutputFormat::Text,
672            }),
673        };
674        cmd.execute().unwrap();
675    }
676
677    #[test]
678    fn dispatch_status() {
679        let tgt = tempdir();
680        init_repo(tgt.path());
681
682        let cmd = SkillsCommand {
683            repo: crate::cli::repo_arg::RepoArg::default(),
684            command: SkillsSubcommands::Status(status::StatusCommand {
685                target: Some(tgt.path().to_path_buf()),
686                worktrees: false,
687                format: OutputFormat::Text,
688            }),
689        };
690        cmd.execute().unwrap();
691    }
692
693    fn parse(argv: &[&str]) -> SkillsCommand {
694        SkillsCommand::try_parse_from(std::iter::once("skills").chain(argv.iter().copied()))
695            .unwrap()
696    }
697
698    fn seed_skill(root: &Path, name: &str) {
699        let dir = root.join(".claude/skills").join(name);
700        fs::create_dir_all(&dir).unwrap();
701        fs::write(dir.join("SKILL.md"), format!("# {name}")).unwrap();
702    }
703
704    #[test]
705    fn repo_flag_parses_before_and_after_the_leaf() {
706        for flag in ["-C", "--repo"] {
707            for argv in [["status", flag, "/tmp/r"], [flag, "/tmp/r", "status"]] {
708                let cmd = parse(&argv);
709                assert_eq!(cmd.repo.path(), Some(Path::new("/tmp/r")), "{argv:?}");
710            }
711        }
712        assert!(parse(&["status"]).repo.path().is_none());
713    }
714
715    /// `-C` is the default `sync` source and the base for a relative
716    /// `--target`, with no `chdir` — the process CWD is not a repo under test.
717    #[test]
718    fn sync_uses_repo_as_source_and_base_for_relative_target() {
719        let parent = tempdir();
720        let src = parent.path().join("src");
721        let tgt = parent.path().join("tgt");
722        for d in [&src, &tgt] {
723            fs::create_dir_all(d).unwrap();
724            init_repo(d);
725        }
726        seed_skill(&src, "alpha");
727
728        let cmd = parse(&["sync", "-C", src.to_str().unwrap(), "--target", "../tgt"]);
729        cmd.execute().unwrap();
730        assert!(tgt.join(".claude/skills/alpha").exists());
731    }
732
733    #[test]
734    fn clean_acts_on_repo_not_cwd() {
735        let src = tempdir();
736        let tgt = tempdir();
737        init_repo(src.path());
738        init_repo(tgt.path());
739        seed_skill(src.path(), "alpha");
740        parse(&[
741            "sync",
742            "--source",
743            src.path().to_str().unwrap(),
744            "--target",
745            tgt.path().to_str().unwrap(),
746        ])
747        .execute()
748        .unwrap();
749        let link = tgt.path().join(".claude/skills/alpha");
750        assert!(link.symlink_metadata().is_ok());
751
752        parse(&["clean", "-C", tgt.path().to_str().unwrap()])
753            .execute()
754            .unwrap();
755        assert!(link.symlink_metadata().is_err(), "symlink not cleaned");
756    }
757
758    /// The process CWD (the crate root) is a git repo, so `status` could only
759    /// fail here by resolving `-C` — a non-repo directory — instead.
760    #[test]
761    fn status_resolves_repo_flag_instead_of_cwd() {
762        let plain = TempDir::new().unwrap();
763        let err = parse(&["status", "-C", plain.path().to_str().unwrap()])
764            .execute()
765            .unwrap_err();
766        assert!(format!("{err:?}").contains("git rev-parse"), "{err:?}");
767    }
768
769    /// A relative `--source` and `--target` both resolve against `-C`.
770    #[test]
771    fn sync_resolves_relative_source_against_repo() {
772        let parent = tempdir();
773        for name in ["src", "tgt"] {
774            let d = parent.path().join(name);
775            fs::create_dir_all(&d).unwrap();
776            init_repo(&d);
777        }
778        seed_skill(&parent.path().join("src"), "alpha");
779
780        parse(&[
781            "sync",
782            "-C",
783            parent.path().to_str().unwrap(),
784            "--source",
785            "src",
786            "--target",
787            "tgt",
788        ])
789        .execute()
790        .unwrap();
791        assert!(parent.path().join("tgt/.claude/skills/alpha").exists());
792    }
793
794    /// `--worktrees` enumerates the worktrees of the `-C` repository.
795    #[test]
796    fn clean_with_worktrees_covers_the_repo_flag_worktrees() {
797        let src = tempdir();
798        let main = tempdir();
799        let wt_parent = tempdir();
800        let linked = wt_parent.path().join("linked");
801        init_repo(src.path());
802        init_repo_with_commit(main.path());
803        worktree_add(main.path(), &linked);
804        seed_skill(src.path(), "alpha");
805        parse(&[
806            "sync",
807            "--source",
808            src.path().to_str().unwrap(),
809            "--target",
810            linked.to_str().unwrap(),
811        ])
812        .execute()
813        .unwrap();
814        let link = linked.join(".claude/skills/alpha");
815        assert!(link.symlink_metadata().is_ok());
816
817        parse(&["clean", "--worktrees", "-C", main.path().to_str().unwrap()])
818            .execute()
819            .unwrap();
820        assert!(
821            link.symlink_metadata().is_err(),
822            "linked worktree not cleaned"
823        );
824    }
825}