use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::anyhow;
use clap::Subcommand;
use newt_skills::InstallMode;
#[derive(Subcommand, Debug)]
pub enum SkillsCmd {
List,
Install {
source: PathBuf,
#[arg(long)]
into: Option<PathBuf>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
link: bool,
#[arg(long)]
force: bool,
},
Share {
name: String,
#[arg(long)]
to: PathBuf,
#[arg(long)]
from: Option<PathBuf>,
#[arg(long)]
link: bool,
#[arg(long)]
force: bool,
},
}
pub fn run(cmd: SkillsCmd, config_path: Option<&Path>) -> anyhow::Result<()> {
let cfg = match config_path {
Some(p) => newt_core::Config::load(p)?,
None => newt_core::Config::resolve().unwrap_or_default(),
};
let search = cfg.skill_search_dirs();
let mut out = std::io::stdout();
run_with(cmd, &search, &mut out)
}
fn mode(link: bool) -> InstallMode {
if link {
InstallMode::Link
} else {
InstallMode::Copy
}
}
fn verb(mode: InstallMode) -> &'static str {
match mode {
InstallMode::Link => "linked",
InstallMode::Copy => "copied",
}
}
fn run_with(cmd: SkillsCmd, search: &[PathBuf], out: &mut dyn Write) -> anyhow::Result<()> {
match cmd {
SkillsCmd::List => cmd_list(search, out),
SkillsCmd::Install {
source,
into,
name,
link,
force,
} => {
let dest_root = into
.or_else(|| search.first().cloned())
.ok_or_else(|| anyhow!("no destination — pass --into <dir>"))?;
let dest = newt_skills::install_skill(
&source,
&dest_root,
name.as_deref(),
mode(link),
force,
)?;
writeln!(
out,
"{} {} → {}",
verb(mode(link)),
source.display(),
dest.display()
)?;
Ok(())
}
SkillsCmd::Share {
name,
to,
from,
link,
force,
} => {
let src = match &from {
Some(dir) => dir.join(&name),
None => find_on_path(search, &name).ok_or_else(|| {
anyhow!("no skill '{name}' on the search path — run `newt skills list`")
})?,
};
if !src.join("SKILL.md").exists() {
return Err(anyhow!("no skill '{name}' in {}", src.display()));
}
let dest = newt_skills::install_skill(&src, &to, Some(&name), mode(link), force)?;
writeln!(out, "{} '{name}' → {}", verb(mode(link)), dest.display())?;
Ok(())
}
}
}
fn find_on_path(search: &[PathBuf], name: &str) -> Option<PathBuf> {
search
.iter()
.map(|d| d.join(name))
.find(|p| p.join("SKILL.md").is_file())
}
fn cmd_list(search: &[PathBuf], out: &mut dyn Write) -> anyhow::Result<()> {
let (skills, shadowed) = newt_skills::discover_paths_with_shadows(search);
let path: Vec<String> = search.iter().map(|d| d.display().to_string()).collect();
writeln!(out, "Search path: {}", path.join(", "))?;
if skills.is_empty() {
writeln!(
out,
"No skills found. Add one with `newt skills install <path>`, or add a \
directory to [skills].search in ~/.newt/config.toml."
)?;
} else {
writeln!(out, "Skills:")?;
for s in &skills {
let from = s
.dir
.parent()
.map(|p| p.display().to_string())
.unwrap_or_default();
writeln!(out, " {}: {} [{from}]", s.name, s.description)?;
}
}
for s in &shadowed {
writeln!(
out,
" note: '{}' in {} is shadowed by an earlier directory on the search path",
s.name,
s.dir
.parent()
.map(|p| p.display().to_string())
.unwrap_or_default()
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn make_skill(root: &Path, name: &str, desc: &str) {
let dir = root.join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: {desc}\n---\nBody.\n"),
)
.unwrap();
}
#[test]
fn install_defaults_into_first_search_dir() {
let tmp = tempdir().unwrap();
let newt = tmp.path().join("newt");
fs::create_dir_all(&newt).unwrap();
let ext = tmp.path().join("external");
make_skill(&ext, "release", "cut a release");
let mut out = Vec::new();
run_with(
SkillsCmd::Install {
source: ext.join("release"),
into: None,
name: None,
link: false,
force: false,
},
std::slice::from_ref(&newt),
&mut out,
)
.unwrap();
assert!(newt.join("release").join("SKILL.md").is_file());
}
#[test]
fn install_into_explicit_dir() {
let tmp = tempdir().unwrap();
let ext = tmp.path().join("external");
make_skill(&ext, "release", "cut a release");
let target = tmp.path().join("elsewhere");
let mut out = Vec::new();
run_with(
SkillsCmd::Install {
source: ext.join("release"),
into: Some(target.clone()),
name: Some("renamed".into()),
link: false,
force: false,
},
&[tmp.path().join("newt")],
&mut out,
)
.unwrap();
assert!(target.join("renamed").join("SKILL.md").is_file());
}
#[test]
fn share_resolves_source_from_search_path() {
let tmp = tempdir().unwrap();
let newt = tmp.path().join("newt");
make_skill(&newt, "commit-style", "how we commit");
let claude = tmp.path().join("claude");
let mut out = Vec::new();
run_with(
SkillsCmd::Share {
name: "commit-style".into(),
to: claude.clone(),
from: None,
link: false,
force: false,
},
&[newt],
&mut out,
)
.unwrap();
assert!(claude.join("commit-style").join("SKILL.md").is_file());
assert!(String::from_utf8(out)
.unwrap()
.contains("copied 'commit-style'"));
}
#[test]
fn share_with_explicit_from_dir() {
let tmp = tempdir().unwrap();
let src = tmp.path().join("src");
make_skill(&src, "judge", "scoring");
let dst = tmp.path().join("dst");
let mut out = Vec::new();
run_with(
SkillsCmd::Share {
name: "judge".into(),
to: dst.clone(),
from: Some(src),
link: false,
force: false,
},
&[tmp.path().join("newt")], &mut out,
)
.unwrap();
assert!(dst.join("judge").join("SKILL.md").is_file());
}
#[test]
fn share_missing_skill_errors() {
let tmp = tempdir().unwrap();
let newt = tmp.path().join("newt");
fs::create_dir_all(&newt).unwrap();
let mut out = Vec::new();
let err = run_with(
SkillsCmd::Share {
name: "ghost".into(),
to: tmp.path().join("claude"),
from: None,
link: false,
force: false,
},
&[newt],
&mut out,
)
.unwrap_err();
assert!(err.to_string().contains("no skill 'ghost'"));
}
#[test]
fn list_reports_search_path_and_shadowing() {
let tmp = tempdir().unwrap();
let newt = tmp.path().join("newt");
let claude = tmp.path().join("claude");
make_skill(&newt, "commit-style", "newt copy");
make_skill(&claude, "commit-style", "claude copy"); make_skill(&claude, "judge", "scoring");
let mut out = Vec::new();
cmd_list(&[newt.clone(), claude.clone()], &mut out).unwrap();
let log = String::from_utf8(out).unwrap();
assert!(log.contains("Search path:"));
assert!(log.contains("commit-style: newt copy"));
assert!(log.contains("judge: scoring"));
assert!(log.contains("is shadowed by an earlier directory"));
}
#[test]
fn list_empty_search_path() {
let tmp = tempdir().unwrap();
let newt = tmp.path().join("newt");
fs::create_dir_all(&newt).unwrap();
let mut out = Vec::new();
cmd_list(&[newt], &mut out).unwrap();
assert!(String::from_utf8(out).unwrap().contains("No skills found"));
}
}