mod clean;
mod common;
mod status;
mod sync;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use serde::Serialize;
pub use common::OutputFormat;
pub type SkillsFormat = OutputFormat;
#[derive(Parser)]
pub struct SkillsCommand {
#[command(subcommand)]
pub command: SkillsSubcommands,
}
#[derive(Subcommand)]
pub enum SkillsSubcommands {
Sync(sync::SyncCommand),
Clean(clean::CleanCommand),
Status(status::StatusCommand),
}
impl SkillsCommand {
pub fn execute(self) -> Result<()> {
match self.command {
SkillsSubcommands::Sync(cmd) => cmd.execute(),
SkillsSubcommands::Clean(cmd) => cmd.execute(),
SkillsSubcommands::Status(cmd) => cmd.execute(),
}
}
}
pub fn run_sync(
base_dir: Option<&std::path::Path>,
worktrees: bool,
format: OutputFormat,
) -> Result<String> {
let base = resolve_base_dir(base_dir)?;
let source_root = common::resolve_toplevel(&base)?;
let target_root = source_root.clone();
let mut targets = vec![target_root.clone()];
if worktrees {
for wt in common::list_worktrees(&target_root)? {
if !targets.iter().any(|t| t == &wt) {
targets.push(wt);
}
}
}
let report = sync::run_sync(&source_root, &targets, false)?;
render_sync_report(&report, format)
}
pub fn run_clean(
base_dir: Option<&std::path::Path>,
worktrees: bool,
format: OutputFormat,
) -> Result<String> {
let base = resolve_base_dir(base_dir)?;
let target_root = common::resolve_toplevel(&base)?;
let mut targets = vec![target_root.clone()];
if worktrees {
for wt in common::list_worktrees(&target_root)? {
if !targets.iter().any(|t| t == &wt) {
targets.push(wt);
}
}
}
let report = clean::run_clean(&targets, false)?;
render_clean_report(&report, format)
}
pub fn run_status(
base_dir: Option<&std::path::Path>,
worktrees: bool,
format: OutputFormat,
) -> Result<String> {
let base = resolve_base_dir(base_dir)?;
let target_root = common::resolve_toplevel(&base)?;
let mut targets = vec![target_root.clone()];
if worktrees {
for wt in common::list_worktrees(&target_root)? {
if !targets.iter().any(|t| t == &wt) {
targets.push(wt);
}
}
}
let report = status::run_status(&targets)?;
render_status_report(&report, format)
}
fn resolve_base_dir(base_dir: Option<&std::path::Path>) -> Result<std::path::PathBuf> {
match base_dir {
Some(p) => Ok(p.to_path_buf()),
None => std::env::current_dir().context("Failed to determine current directory"),
}
}
#[derive(Serialize)]
struct SyncOutput<'a> {
dry_run: bool,
actions: &'a [sync::SyncAction],
errors: &'a [sync::SyncError],
}
#[derive(Serialize)]
struct CleanOutput<'a> {
dry_run: bool,
actions: &'a [clean::CleanAction],
}
#[derive(Serialize)]
struct StatusOutput<'a> {
targets: &'a [status::TargetStatus],
}
fn render_sync_report(report: &sync::SyncReport, format: OutputFormat) -> Result<String> {
match format {
OutputFormat::Text => Ok(render_sync_text(report)),
OutputFormat::Yaml => {
let output = SyncOutput {
dry_run: false,
actions: &report.actions,
errors: &report.errors,
};
serde_yaml::to_string(&output).context("Failed to serialize sync report as YAML")
}
}
}
fn render_sync_text(report: &sync::SyncReport) -> String {
use std::fmt::Write;
let mut out = String::new();
for action in &report.actions {
match action {
sync::SyncAction::Linked { link, points_to } => {
let _ = writeln!(out, "linked {} -> {}", link.display(), points_to.display());
}
sync::SyncAction::Relinked { link, points_to } => {
let _ = writeln!(
out,
"relinked {} -> {}",
link.display(),
points_to.display()
);
}
sync::SyncAction::Excluded {
exclude_file,
entry,
} => {
let _ = writeln!(out, "excluded {} in {}", entry, exclude_file.display());
}
sync::SyncAction::SkippedSameTarget { target } => {
let _ = writeln!(out, "skipped {} (target equals source)", target.display());
}
}
}
for err in &report.errors {
let _ = writeln!(out, "error: {} -- {}", err.target.display(), err.reason);
}
out
}
fn render_clean_report(report: &clean::CleanReport, format: OutputFormat) -> Result<String> {
match format {
OutputFormat::Text => Ok(render_clean_text(report)),
OutputFormat::Yaml => {
let output = CleanOutput {
dry_run: false,
actions: &report.actions,
};
serde_yaml::to_string(&output).context("Failed to serialize clean report as YAML")
}
}
}
fn render_clean_text(report: &clean::CleanReport) -> String {
use std::fmt::Write;
let mut out = String::new();
for action in &report.actions {
match action {
clean::CleanAction::Unlinked { link } => {
let _ = writeln!(out, "unlinked {}", link.display());
}
clean::CleanAction::Preserved { path, reason } => {
let _ = writeln!(out, "preserved {} ({reason})", path.display());
}
clean::CleanAction::ExcludeRemoved {
exclude_file,
entry,
} => {
let _ = writeln!(
out,
"removed exclude entry {} from {}",
entry,
exclude_file.display()
);
}
clean::CleanAction::DirectoryRemoved { path } => {
let _ = writeln!(out, "removed empty {}", path.display());
}
}
}
out
}
fn render_status_report(report: &status::StatusReport, format: OutputFormat) -> Result<String> {
match format {
OutputFormat::Text => Ok(render_status_text(report)),
OutputFormat::Yaml => {
let output = StatusOutput {
targets: &report.targets,
};
serde_yaml::to_string(&output).context("Failed to serialize status report as YAML")
}
}
}
fn render_status_text(report: &status::StatusReport) -> String {
use std::fmt::Write;
let mut out = String::new();
let mut first = true;
for target in &report.targets {
if target.symlinks.is_empty() && target.exclude_entries.is_empty() {
continue;
}
if !first {
out.push('\n');
}
first = false;
let _ = writeln!(out, "{}", target.root.display());
if !target.symlinks.is_empty() {
out.push_str(" symlinks:\n");
for link in &target.symlinks {
let _ = writeln!(
out,
" {} -> {}",
link.path.display(),
link.points_to.display()
);
}
}
if !target.exclude_entries.is_empty() {
let _ = writeln!(out, " exclude block ({}):", target.exclude_file.display());
for entry in &target.exclude_entries {
let _ = writeln!(out, " {entry}");
}
}
}
out
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod skills_api_tests {
use super::*;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
fn tempdir() -> TempDir {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
fs::create_dir_all(&root).ok();
TempDir::new_in(&root).unwrap()
}
fn init_repo(dir: &Path) {
let status = Command::new("git").arg("init").arg(dir).output().unwrap();
assert!(status.status.success());
}
fn init_repo_with_commit(dir: &Path) {
init_repo(dir);
fs::write(dir.join("README.md"), "readme").unwrap();
let add = Command::new("git")
.args(["add", "README.md"])
.current_dir(dir)
.output()
.unwrap();
assert!(add.status.success());
let commit = Command::new("git")
.args([
"-c",
"user.email=x@x",
"-c",
"user.name=x",
"commit",
"-q",
"-m",
"init",
])
.current_dir(dir)
.output()
.unwrap();
assert!(commit.status.success());
}
fn make_source_skills(root: &Path, names: &[&str]) {
let dir = root.join(".claude/skills");
fs::create_dir_all(&dir).unwrap();
for n in names {
let d = dir.join(n);
fs::create_dir_all(&d).unwrap();
fs::write(d.join("SKILL.md"), format!("# {n}")).unwrap();
}
}
#[test]
fn run_sync_mcp_with_worktrees_links_skills_and_returns_yaml() {
let src = tempdir();
let wt_parent = tempdir();
let linked = wt_parent.path().join("linked");
init_repo_with_commit(src.path());
make_source_skills(src.path(), &["alpha"]);
let add_wt = Command::new("git")
.args(["worktree", "add", "-q"])
.arg(&linked)
.current_dir(src.path())
.output()
.unwrap();
assert!(add_wt.status.success());
let out = run_sync(Some(&linked), true, OutputFormat::Yaml).unwrap();
assert!(out.contains("dry_run: false"), "missing dry_run: {out}");
assert!(out.contains("actions:"), "missing actions: {out}");
}
#[test]
fn run_sync_mcp_same_source_target_reports_skipped() {
let src = tempdir();
init_repo(src.path());
make_source_skills(src.path(), &["alpha"]);
let out = run_sync(Some(src.path()), false, OutputFormat::Text).unwrap();
assert!(out.contains("skipped"), "expected skipped action: {out}");
}
#[test]
fn run_clean_mcp_empty_repo_returns_empty_string_text() {
let tgt = tempdir();
init_repo(tgt.path());
let out = run_clean(Some(tgt.path()), false, OutputFormat::Text).unwrap();
assert!(out.is_empty(), "expected no actions, got: {out}");
}
#[test]
fn run_clean_mcp_yaml_reports_empty_actions() {
let tgt = tempdir();
init_repo(tgt.path());
let out = run_clean(Some(tgt.path()), false, OutputFormat::Yaml).unwrap();
assert!(out.contains("dry_run: false"), "missing dry_run: {out}");
assert!(out.contains("actions:"), "missing actions: {out}");
}
#[test]
fn run_clean_mcp_with_worktrees_covers_all_worktrees() {
let main = tempdir();
init_repo_with_commit(main.path());
let wt_parent = tempdir();
let linked = wt_parent.path().join("linked");
let add_wt = Command::new("git")
.args(["worktree", "add", "-q"])
.arg(&linked)
.current_dir(main.path())
.output()
.unwrap();
assert!(add_wt.status.success());
let out = run_clean(Some(main.path()), true, OutputFormat::Yaml).unwrap();
assert!(out.contains("actions:"), "missing actions: {out}");
}
#[test]
fn run_status_mcp_empty_repo_text_is_empty() {
let tgt = tempdir();
init_repo(tgt.path());
let out = run_status(Some(tgt.path()), false, OutputFormat::Text).unwrap();
assert!(out.is_empty(), "expected no residue, got: {out}");
}
#[test]
fn run_status_mcp_yaml_emits_targets_array() {
let tgt = tempdir();
init_repo(tgt.path());
let out = run_status(Some(tgt.path()), false, OutputFormat::Yaml).unwrap();
assert!(out.contains("targets:"), "missing targets: {out}");
}
#[cfg(unix)]
#[test]
fn run_status_mcp_reports_symlinks_in_text() {
let src = tempdir();
let tgt = tempdir();
init_repo(tgt.path());
let src_skill = src.path().join("alpha");
fs::create_dir_all(&src_skill).unwrap();
let skills_dir = tgt.path().join(".claude/skills");
fs::create_dir_all(&skills_dir).unwrap();
std::os::unix::fs::symlink(&src_skill, skills_dir.join("alpha")).unwrap();
let out = run_status(Some(tgt.path()), false, OutputFormat::Text).unwrap();
assert!(out.contains(".claude/skills/alpha"), "got: {out}");
}
#[test]
fn run_status_mcp_includes_worktrees_when_requested() {
let tgt_main = tempdir();
let wt_parent = tempdir();
let linked = wt_parent.path().join("linked");
init_repo_with_commit(tgt_main.path());
let add_wt = Command::new("git")
.args(["worktree", "add", "-q"])
.arg(&linked)
.current_dir(tgt_main.path())
.output()
.unwrap();
assert!(add_wt.status.success());
let out = run_status(Some(tgt_main.path()), true, OutputFormat::Yaml).unwrap();
assert!(out.contains("targets:"), "missing targets: {out}");
}
#[test]
fn run_sync_mcp_errors_outside_repo() {
let plain = TempDir::new().unwrap();
let err = run_sync(Some(plain.path()), false, OutputFormat::Text).unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("git rev-parse --show-toplevel failed")
|| msg.contains("Failed to run git"),
"unexpected error: {msg}"
);
}
#[test]
fn run_clean_mcp_errors_outside_repo() {
let plain = TempDir::new().unwrap();
let err = run_clean(Some(plain.path()), false, OutputFormat::Text).unwrap_err();
let msg = format!("{err:?}");
assert!(msg.contains("git rev-parse"), "unexpected: {msg}");
}
#[test]
fn run_status_mcp_errors_outside_repo() {
let plain = TempDir::new().unwrap();
let err = run_status(Some(plain.path()), false, OutputFormat::Text).unwrap_err();
let msg = format!("{err:?}");
assert!(msg.contains("git rev-parse"), "unexpected: {msg}");
}
#[test]
fn run_status_mcp_defaults_base_dir_to_cwd() {
let _ = run_status(None, false, OutputFormat::Text);
}
#[test]
fn render_sync_text_covers_all_variants() {
let report = sync::SyncReport {
actions: vec![
sync::SyncAction::Linked {
link: PathBuf::from("/a"),
points_to: PathBuf::from("/b"),
},
sync::SyncAction::Relinked {
link: PathBuf::from("/c"),
points_to: PathBuf::from("/d"),
},
sync::SyncAction::Excluded {
exclude_file: PathBuf::from("/e"),
entry: ".claude/skills/x/".into(),
},
sync::SyncAction::SkippedSameTarget {
target: PathBuf::from("/f"),
},
],
errors: vec![sync::SyncError {
target: PathBuf::from("/g"),
reason: "blocked".into(),
}],
};
let out = render_sync_text(&report);
for s in [
"linked /a -> /b",
"relinked /c -> /d",
"excluded .claude/skills/x/ in /e",
"skipped /f",
"error: /g",
] {
assert!(out.contains(s), "missing {s}: {out}");
}
}
#[test]
fn render_clean_text_covers_all_variants() {
let report = clean::CleanReport {
actions: vec![
clean::CleanAction::Unlinked {
link: PathBuf::from("/a"),
},
clean::CleanAction::Preserved {
path: PathBuf::from("/b"),
reason: "real file".into(),
},
clean::CleanAction::ExcludeRemoved {
exclude_file: PathBuf::from("/c"),
entry: ".claude/skills/x/".into(),
},
clean::CleanAction::DirectoryRemoved {
path: PathBuf::from("/d"),
},
],
};
let out = render_clean_text(&report);
for s in [
"unlinked /a",
"preserved /b (real file)",
"removed exclude entry .claude/skills/x/ from /c",
"removed empty /d",
] {
assert!(out.contains(s), "missing {s}: {out}");
}
}
#[test]
fn render_status_text_covers_mixed_targets() {
let report = status::StatusReport {
targets: vec![
status::TargetStatus {
root: PathBuf::from("/empty"),
symlinks: Vec::new(),
exclude_file: PathBuf::from("/empty/.git/info/exclude"),
exclude_entries: Vec::new(),
},
status::TargetStatus {
root: PathBuf::from("/both"),
symlinks: vec![status::SymlinkInfo {
path: PathBuf::from("/both/.claude/skills/alpha"),
points_to: PathBuf::from("/src/alpha"),
}],
exclude_file: PathBuf::from("/both/.git/info/exclude"),
exclude_entries: vec![".claude/skills/alpha/".into()],
},
],
};
let out = render_status_text(&report);
assert!(out.contains("/both"), "missing /both: {out}");
assert!(out.contains("symlinks:"), "missing symlinks: {out}");
assert!(
out.contains("exclude block"),
"missing exclude block: {out}"
);
assert!(
!out.contains("/empty\n"),
"should skip empty target header: {out}"
);
}
#[test]
fn render_sync_yaml_contains_dry_run_and_actions_keys() {
let report = sync::SyncReport {
actions: Vec::new(),
errors: Vec::new(),
};
let out = render_sync_report(&report, OutputFormat::Yaml).unwrap();
assert!(out.contains("dry_run: false"));
assert!(out.contains("actions:"));
assert!(out.contains("errors:"));
}
#[test]
fn render_clean_yaml_contains_dry_run_and_actions_keys() {
let report = clean::CleanReport {
actions: Vec::new(),
};
let out = render_clean_report(&report, OutputFormat::Yaml).unwrap();
assert!(out.contains("dry_run: false"));
assert!(out.contains("actions:"));
}
#[test]
fn render_status_yaml_contains_targets_key() {
let report = status::StatusReport {
targets: Vec::new(),
};
let out = render_status_report(&report, OutputFormat::Yaml).unwrap();
assert!(out.contains("targets:"));
}
#[allow(dead_code)]
fn _unused(_: PathBuf) {}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
use common::OutputFormat;
fn tempdir() -> TempDir {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
fs::create_dir_all(&root).ok();
TempDir::new_in(&root).unwrap()
}
fn init_repo(dir: &Path) {
let status = Command::new("git").arg("init").arg(dir).output().unwrap();
assert!(status.status.success());
}
#[test]
fn dispatch_sync() {
let src = tempdir();
let tgt = tempdir();
init_repo(src.path());
init_repo(tgt.path());
let skills_dir = src.path().join(".claude/skills/alpha");
fs::create_dir_all(&skills_dir).unwrap();
fs::write(skills_dir.join("SKILL.md"), "# alpha").unwrap();
let cmd = SkillsCommand {
command: SkillsSubcommands::Sync(sync::SyncCommand {
source: Some(src.path().to_path_buf()),
target: Some(tgt.path().to_path_buf()),
worktrees: false,
dry_run: false,
format: OutputFormat::Text,
}),
};
cmd.execute().unwrap();
assert!(tgt.path().join(".claude/skills/alpha").exists());
}
#[test]
fn dispatch_clean() {
let tgt = tempdir();
init_repo(tgt.path());
let cmd = SkillsCommand {
command: SkillsSubcommands::Clean(clean::CleanCommand {
target: Some(tgt.path().to_path_buf()),
worktrees: false,
dry_run: false,
format: OutputFormat::Text,
}),
};
cmd.execute().unwrap();
}
#[test]
fn dispatch_status() {
let tgt = tempdir();
init_repo(tgt.path());
let cmd = SkillsCommand {
command: SkillsSubcommands::Status(status::StatusCommand {
target: Some(tgt.path().to_path_buf()),
worktrees: false,
format: OutputFormat::Text,
}),
};
cmd.execute().unwrap();
}
}