use crate::lockfile::Lockfile;
use crate::manifest::{Manifest, SkillSpec};
use crate::vendor::{self, MaterializedSkill};
use crate::{resolver, store};
use anyhow::{bail, Context, Result};
use clap::{Args, Parser, Subcommand};
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Parser)]
#[command(name = "spm", version, about = "Manage AI skills declared in ai.json")]
pub struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Init {
#[arg(long = "target", value_delimiter = ',', default_value = "claude")]
targets: Vec<String>,
},
Add {
git: String,
#[command(flatten)]
version: VersionArg,
#[arg(long)]
path: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long, conflicts_with = "name")]
all: bool,
},
Remove { name: String },
Update {
name: Option<String>,
},
Install,
List,
Status,
Clean,
}
#[derive(Args)]
#[group(multiple = false)]
struct VersionArg {
#[arg(long)]
tag: Option<String>,
#[arg(long)]
branch: Option<String>,
#[arg(long)]
commit: Option<String>,
}
pub fn run() -> Result<()> {
let cli = Cli::parse();
let root = std::env::current_dir()?;
match cli.command {
Command::Init { targets } => init(&root, targets),
Command::Add {
git,
version,
path,
name,
all,
} => add(&root, git, version, path, name, all),
Command::Remove { name } => remove(&root, &name),
Command::Update { name } => update(&root, name),
Command::Install => {
let n = sync(&root, false, None)?;
println!("installed {n} skill(s)");
Ok(())
}
Command::List => list(&root),
Command::Status => status(&root),
Command::Clean => clean(&root),
}
}
fn clean(root: &Path) -> Result<()> {
let manifest = Manifest::load(root)?;
let lock = Lockfile::load_or_default(root)?;
for target in &manifest.targets {
vendor::for_target(target)?.clean(root, &lock.id)?;
}
println!(
"cleaned generated config for target(s): {}",
manifest.targets.join(", ")
);
Ok(())
}
fn init(root: &Path, targets: Vec<String>) -> Result<()> {
if Manifest::exists(root) {
bail!("{} already exists", Manifest::path_in(root).display());
}
if targets.is_empty() {
bail!("at least one --target is required");
}
for target in &targets {
vendor::for_target(target)?; }
let manifest = Manifest {
targets,
skills: BTreeMap::new(),
};
manifest.save(root)?;
println!("created {}", Manifest::path_in(root).display());
Ok(())
}
fn add(
root: &Path,
git: String,
version: VersionArg,
path: Option<String>,
name: Option<String>,
all: bool,
) -> Result<()> {
let mut manifest = Manifest::load(root)?;
if let Some(sub) = &path {
crate::manifest::validate_subpath(sub)?;
}
if all {
add_all(root, &mut manifest, git, version, path)?;
} else {
let name = name.unwrap_or_else(|| default_name(&git));
crate::manifest::validate_skill_name(&name)?;
let spec = SkillSpec {
git,
tag: version.tag,
branch: version.branch,
commit: version.commit,
path,
};
spec.version()?; manifest.skills.insert(name.clone(), spec);
manifest.save(root)?;
sync(root, false, None)?;
println!("added {name}");
}
Ok(())
}
fn add_all(
root: &Path,
manifest: &mut Manifest,
git: String,
version: VersionArg,
path: Option<String>,
) -> Result<()> {
let container = SkillSpec {
git: git.clone(),
tag: version.tag,
branch: version.branch,
commit: version.commit,
path: path.clone(),
};
container.version()?;
let locked =
resolver::resolve(&container).context("resolving container to enumerate skills")?;
let ensured = store::ensure(&locked).context("fetching container to enumerate skills")?;
let subs = crate::skillcheck::child_skills(&ensured.path);
if subs.is_empty() {
bail!(
"--path `{}` is not a container of skills (no immediate subdirectory has a SKILL.md). \
Drop --all to add it as a single skill.",
path.as_deref().unwrap_or(".")
);
}
for sub in &subs {
crate::manifest::validate_skill_name(sub)?;
if manifest.skills.contains_key(sub) {
bail!(
"a skill named `{sub}` already exists in {}; \
remove or rename it before adding this container with --all",
Manifest::path_in(root).display()
);
}
}
for sub in &subs {
let subpath = crate::skillcheck::join_subpath(path.as_deref(), sub);
manifest.skills.insert(
sub.clone(),
SkillSpec {
git: git.clone(),
tag: container.tag.clone(),
branch: container.branch.clone(),
commit: container.commit.clone(),
path: Some(subpath),
},
);
}
manifest.save(root)?;
sync(root, false, None)?;
println!("added {} skill(s): {}", subs.len(), subs.join(", "));
Ok(())
}
fn remove(root: &Path, name: &str) -> Result<()> {
let mut manifest = Manifest::load(root)?;
if manifest.skills.remove(name).is_none() {
bail!(
"no skill named `{name}` in {}",
Manifest::path_in(root).display()
);
}
manifest.save(root)?;
sync(root, false, None)?;
println!("removed {name}");
Ok(())
}
fn update(root: &Path, name: Option<String>) -> Result<()> {
sync(root, true, name.as_deref())?;
println!("updated");
Ok(())
}
fn list(root: &Path) -> Result<()> {
let manifest = Manifest::load(root)?;
let lock = Lockfile::load_or_default(root)?;
if manifest.skills.is_empty() {
println!("no skills declared");
return Ok(());
}
for (name, spec) in &manifest.skills {
let pinned = lock
.skills
.get(name)
.map(|l| format!("{} @ {}", l.reference, &l.commit[..l.commit.len().min(8)]))
.unwrap_or_else(|| "not installed".into());
println!("{name:<24} {} ({pinned})", spec.git);
}
Ok(())
}
fn status(root: &Path) -> Result<()> {
let manifest = Manifest::load(root)?;
let lock = Lockfile::load_or_default(root)?;
let expected: Vec<String> = lock.skills.keys().cloned().collect();
println!("project: {}", root.display());
println!("targets: {}", manifest.targets.join(", "));
if expected.is_empty() {
if manifest.skills.is_empty() {
println!("\nno skills declared — add one with `spm add <git-url>`");
return Ok(());
}
bail!(
"ai.json declares {} skill(s) but ai.lock has none — run `spm install` here",
manifest.skills.len()
);
}
let width = expected.iter().map(String::len).max().unwrap_or(0);
let mut incomplete = false;
for target in &manifest.targets {
let vendor = vendor::for_target(target)?;
let st = vendor.status(root, &expected)?;
println!(
"\n[{target}] {}/{} installed {}",
st.present.len(),
expected.len(),
st.location.display()
);
for name in &expected {
let missing = st.missing.iter().any(|m| m == name);
if missing {
incomplete = true;
}
println!(
" {name:<width$} {}",
if missing { "MISSING" } else { "ok" }
);
}
for s in &st.stale {
println!(" {s:<width$} stale (not in ai.lock)");
}
for note in &st.notes {
incomplete = true;
println!(" ! {note}");
}
}
if incomplete {
bail!(
"some declared skills are not materialized in this checkout — run `spm install` here"
);
}
println!("\nall declared skills are materialized in this checkout");
Ok(())
}
fn sync(root: &Path, force_refresh: bool, only: Option<&str>) -> Result<usize> {
let manifest = Manifest::load(root)?;
if manifest.targets.is_empty() {
bail!("ai.json declares no targets");
}
let vendors = manifest
.targets
.iter()
.map(|t| vendor::for_target(t))
.collect::<Result<Vec<_>>>()?;
let mut prev = Lockfile::load_or_default(root)?;
let id = if prev.id.is_empty() {
crate::lockfile::generate_id(root)
} else {
prev.id.clone()
};
let mut lock = Lockfile {
id,
..Default::default()
};
let width = manifest.skills.keys().map(String::len).max().unwrap_or(0);
if manifest.skills.is_empty() {
println!("no skills declared");
} else {
println!(
"resolving {} skill(s) for: {}",
manifest.skills.len(),
manifest.targets.join(", ")
);
}
for (name, spec) in &manifest.skills {
let requested = spec.version()?; let reference = requested.label();
let refresh = force_refresh && only.is_none_or(|o| o == name);
let reuse = prev.skills.remove(name).filter(|l| {
!refresh && l.git == spec.git && l.reference == reference && l.path == spec.path
});
let (locked, how) = match reuse {
Some(l) => (l, "locked"),
None => (
resolver::resolve(spec).with_context(|| format!("resolving skill `{name}`"))?,
"resolved",
),
};
let short = &locked.commit[..locked.commit.len().min(8)];
println!(" {name:<width$} {reference} @ {short} ({how})");
lock.skills.insert(name.clone(), locked);
}
lock.save(root)?;
if !manifest.skills.is_empty() {
println!("fetching skills into store");
}
let mut materialized: Vec<MaterializedSkill> = Vec::new();
for (name, locked) in &lock.skills {
let ensured = store::ensure(locked).with_context(|| format!("fetching skill `{name}`"))?;
println!(
" {name:<width$} {}",
if ensured.fetched { "fetched" } else { "cached" }
);
crate::skillcheck::warn_if_not_loadable(
name,
&locked.git,
&locked.reference,
locked.path.as_deref(),
&ensured.path,
);
materialized.push(MaterializedSkill {
name: name.clone(),
path: ensured.path,
});
}
if !manifest.skills.is_empty() {
println!("materializing: {}", manifest.targets.join(", "));
}
for vendor in &vendors {
vendor.materialize(root, &lock.id, &materialized)?;
}
Ok(lock.skills.len())
}
fn default_name(git: &str) -> String {
git.trim_end_matches('/')
.rsplit(['/', ':'])
.next()
.unwrap_or(git)
.trim_end_matches(".git")
.to_string()
}
#[cfg(test)]
mod tests {
use super::default_name;
#[test]
fn default_name_handles_url_forms() {
assert_eq!(default_name("https://github.com/org/repo"), "repo");
assert_eq!(default_name("https://github.com/org/repo.git"), "repo");
assert_eq!(default_name("git@github.com:org/repo.git"), "repo");
assert_eq!(default_name("ssh://git@host/org/repo.git"), "repo");
assert_eq!(default_name("git@host:repo.git"), "repo");
}
}