Skip to main content

omni_dev/cli/ai/claude/skills/
mod.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
28/// Skills subcommands.
29#[derive(Subcommand)]
30pub enum SkillsSubcommands {
31    /// Syncs skills from a source repository into one or more targets.
32    Sync(sync::SyncCommand),
33    /// Removes skill symlinks and managed exclude block previously created by `sync`.
34    Clean(clean::CleanCommand),
35    /// Reports residue left by `sync` — symlinks and managed exclude-block entries.
36    Status(status::StatusCommand),
37}
38
39impl SkillsCommand {
40    /// Executes the skills command.
41    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
50/// Syncs Claude skills and returns the report as a formatted string.
51///
52/// Non-interactive wrapper around the `sync` subcommand suitable for MCP
53/// callers. Source defaults to `base_dir` (or the current working directory
54/// when `base_dir` is `None`); target defaults to the source repository.
55/// When `worktrees` is true, every worktree belonging to the target
56/// repository is synced in addition to the target itself.
57///
58/// The operation **mutates the filesystem**: creates symlinks inside
59/// `.claude/skills/` and upserts a managed block inside
60/// `.git/info/exclude`. Always performed as a real (not dry) run — dry-run
61/// mode is a CLI convenience, not useful through MCP.
62///
63/// `base_dir` exists so callers can pass an explicit directory instead of
64/// mutating the process-wide cwd. Production MCP callers pass `None`.
65pub 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
87/// Cleans Claude skill residue and returns the report as a formatted string.
88///
89/// Target defaults to `base_dir` (or cwd when `None`). Mutates the filesystem.
90pub 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
111/// Reports Claude skill residue.
112///
113/// Target defaults to `base_dir` (or cwd when `None`). Read-only.
114pub 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        // Anchor tmp at an absolute path so concurrent chdir-ing tests can't
309        // cause a relative "tmp" to resolve inside someone else's tempdir.
310        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        // Just verify None-path resolves to cwd: cwd may or may not be a repo,
501        // but this at least exercises resolve_base_dir's None branch.
502        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    // Prevent clippy from complaining about unused imports in this block.
639    #[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        // Anchor tmp at an absolute path so concurrent chdir-ing tests can't
658        // cause a relative "tmp" to resolve inside someone else's tempdir.
659        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}