use super::{MaterializedSkill, Vendor};
use crate::{fsutil, gitignore, jsonutil};
use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
const MARKETPLACE: &str = "spm";
const MANAGED_DIR: [&str; 2] = [".spm", "claude"];
const GITIGNORE_ENTRY: &str = ".spm/";
const GITIGNORE_COMMENT: &str =
"# spm-managed Claude marketplace — materialized locally by `spm`, not committed.";
pub struct Claude;
impl Vendor for Claude {
fn name(&self) -> &'static str {
"claude"
}
fn materialize(
&self,
project_root: &Path,
_project_id: &str,
skills: &[MaterializedSkill],
) -> Result<()> {
let market_dir = managed_dir(project_root);
if market_dir.exists() {
std::fs::remove_dir_all(&market_dir)?;
}
let plugin_dir = market_dir.join("plugin");
let skills_dir = plugin_dir.join("skills");
std::fs::create_dir_all(&skills_dir)?;
for s in skills {
let dest = skills_dir.join(&s.name);
fsutil::copy_tree(&s.path, &dest)
.with_context(|| format!("copying skill `{}` into plugin", s.name))?;
}
jsonutil::write(
&plugin_dir.join(".claude-plugin/plugin.json"),
&json!({ "name": MARKETPLACE }),
)?;
jsonutil::write(
&market_dir.join(".claude-plugin/marketplace.json"),
&json!({
"name": MARKETPLACE,
"owner": { "name": "spm" },
"plugins": [ { "name": MARKETPLACE, "source": "./plugin" } ],
}),
)?;
patch_settings(project_root, &market_dir, skills)?;
gitignore::ensure(project_root, GITIGNORE_COMMENT, GITIGNORE_ENTRY)?;
Ok(())
}
fn clean(&self, project_root: &Path, _project_id: &str) -> Result<()> {
let market_dir = managed_dir(project_root);
if market_dir.exists() {
std::fs::remove_dir_all(&market_dir)?;
}
let path = settings_path(project_root);
if path.exists() {
let mut root = jsonutil::read_object(&path)?;
jsonutil::remove_nested(&mut root, "extraKnownMarketplaces", MARKETPLACE);
jsonutil::remove_nested(&mut root, "enabledPlugins", &plugin_key());
jsonutil::write(&path, &root)?;
}
gitignore::remove(project_root, GITIGNORE_COMMENT, GITIGNORE_ENTRY)
}
fn status(&self, project_root: &Path, expected: &[String]) -> Result<super::VendorStatus> {
let market_dir = managed_dir(project_root);
let skills_dir = market_dir.join("plugin").join("skills");
let mut st = super::classify(&skills_dir, expected);
if !expected.is_empty() {
match registered_marketplace_path(project_root) {
None => st
.notes
.push("no spm marketplace registered in .claude/settings.local.json".into()),
Some(p) if Path::new(&p) != market_dir => st.notes.push(format!(
".claude/settings.local.json marketplace points at {p}, not this checkout ({})",
market_dir.display()
)),
Some(_) => {}
}
}
Ok(st)
}
}
fn registered_marketplace_path(project_root: &Path) -> Option<String> {
let root = jsonutil::read_object(&settings_path(project_root)).ok()?;
root.get("extraKnownMarketplaces")?
.get(MARKETPLACE)?
.get("source")?
.get("path")?
.as_str()
.map(str::to_owned)
}
fn managed_dir(project_root: &Path) -> PathBuf {
MANAGED_DIR
.iter()
.fold(project_root.to_path_buf(), |p, seg| p.join(seg))
}
fn patch_settings(
project_root: &Path,
market_dir: &Path,
skills: &[MaterializedSkill],
) -> Result<()> {
let path = settings_path(project_root);
let mut root = jsonutil::read_object(&path)?;
jsonutil::object_mut(&mut root, "extraKnownMarketplaces").insert(
MARKETPLACE.to_string(),
json!({ "source": { "source": "directory", "path": market_dir.to_string_lossy() } }),
);
let enabled = jsonutil::object_mut(&mut root, "enabledPlugins");
if skills.is_empty() {
enabled.remove(&plugin_key());
} else {
enabled.insert(plugin_key(), Value::Bool(true));
}
jsonutil::write(&path, &root)
}
fn plugin_key() -> String {
format!("{MARKETPLACE}@{MARKETPLACE}")
}
fn settings_path(project_root: &Path) -> PathBuf {
project_root.join(".claude").join("settings.local.json")
}