use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MarketplaceManifest {
pub name: String,
#[serde(default)]
pub plugins: Vec<PluginEntry>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PluginEntry {
pub name: String,
#[serde(default)]
pub source: PluginSource,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum PluginSource {
Inline(String),
External(ExternalSource),
Unknown(serde_json::Value),
}
impl Default for PluginSource {
fn default() -> Self {
PluginSource::Inline("./".into())
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "source", rename_all = "kebab-case")]
pub enum ExternalSource {
Url {
url: String,
#[serde(flatten)]
pin: GitPin,
},
Git {
url: String,
#[serde(flatten)]
pin: GitPin,
},
Github {
repo: String,
#[serde(flatten)]
pin: GitPin,
},
GitSubdir {
url: String,
path: String,
#[serde(flatten)]
pin: GitPin,
},
Local { path: String },
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct GitPin {
#[serde(default)]
pub branch: Option<String>,
#[serde(default)]
pub tag: Option<String>,
#[serde(default)]
pub commit: Option<String>,
#[serde(default, rename = "ref")]
pub git_ref: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct PluginManifest {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub skills: Option<PathOrList>,
#[serde(default)]
pub commands: Option<PathOrList>,
#[serde(default)]
pub hooks: Option<HooksField>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum PathOrList {
One(String),
Many(Vec<String>),
}
impl PathOrList {
pub fn first(&self) -> &str {
match self {
PathOrList::One(s) => s.as_str(),
PathOrList::Many(v) => v.first().map(String::as_str).unwrap_or(""),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum HooksField {
Path(String),
Inline(CCHooksMap),
}
pub type CCHooksMap = std::collections::BTreeMap<String, Vec<CCHookGroup>>;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CCHookGroup {
#[serde(default)]
pub matcher: Option<String>,
pub hooks: Vec<CCHookSpec>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CCHookSpec {
#[serde(default = "default_hook_type", rename = "type")]
pub kind: String,
pub command: String,
#[serde(default)]
pub timeout: Option<u64>,
}
fn default_hook_type() -> String {
"command".to_string()
}
impl PluginManifest {
pub fn skills_path(&self) -> &str {
self.skills.as_ref().map(|p| p.first()).filter(|s| !s.is_empty()).unwrap_or("skills")
}
pub fn skills_paths(&self) -> Vec<&str> {
match &self.skills {
None => vec!["skills"],
Some(PathOrList::One(s)) if s.is_empty() => vec!["skills"],
Some(PathOrList::One(s)) => vec![s.as_str()],
Some(PathOrList::Many(v)) => {
let paths: Vec<&str> = v.iter().map(String::as_str).filter(|s| !s.is_empty()).collect();
if paths.is_empty() { vec!["skills"] } else { paths }
}
}
}
pub fn commands_path(&self) -> &str {
self.commands.as_ref().map(|p| p.first()).filter(|s| !s.is_empty()).unwrap_or("commands")
}
pub fn hooks_path(&self) -> &str {
match &self.hooks {
Some(HooksField::Path(p)) => p.as_str(),
_ => "hooks.json",
}
}
pub fn inline_cc_hooks(&self) -> Option<&CCHooksMap> {
match &self.hooks {
Some(HooksField::Inline(m)) => Some(m),
_ => None,
}
}
}
pub fn load_marketplace_manifest(marketplace_root: &Path) -> Result<Option<MarketplaceManifest>> {
for rel in [".atomcode-plugin/marketplace.json", ".claude-plugin/marketplace.json"] {
let path = marketplace_root.join(rel);
if path.exists() {
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
let manifest: MarketplaceManifest = serde_json::from_str(&raw)
.with_context(|| format!("parse {}", path.display()))?;
return Ok(Some(manifest));
}
}
Ok(None)
}
pub fn load_plugin_manifest(plugin_dir: &Path) -> Result<PluginManifest> {
for rel in [
".atomcode-plugin/plugin.json",
".claude-plugin/plugin.json",
"plugin.json",
] {
let path = plugin_dir.join(rel);
if path.exists() {
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
let manifest: PluginManifest = serde_json::from_str(&raw)
.with_context(|| format!("parse {}", path.display()))?;
return Ok(manifest);
}
}
Ok(PluginManifest::default())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loads_atomcode_manifest_with_priority_over_claude() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".atomcode-plugin")).unwrap();
std::fs::create_dir_all(tmp.path().join(".claude-plugin")).unwrap();
std::fs::write(
tmp.path().join(".atomcode-plugin/marketplace.json"),
r#"{"name":"atom","plugins":[{"name":"a"}]}"#,
)
.unwrap();
std::fs::write(
tmp.path().join(".claude-plugin/marketplace.json"),
r#"{"name":"claude","plugins":[{"name":"c"}]}"#,
)
.unwrap();
let m = load_marketplace_manifest(tmp.path()).unwrap().unwrap();
assert_eq!(m.name, "atom");
assert_eq!(m.plugins[0].name, "a");
}
#[test]
fn missing_manifest_returns_none() {
let tmp = tempfile::tempdir().unwrap();
assert!(load_marketplace_manifest(tmp.path()).unwrap().is_none());
}
#[test]
fn malformed_manifest_returns_err() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".atomcode-plugin")).unwrap();
std::fs::write(
tmp.path().join(".atomcode-plugin/marketplace.json"),
"{ not json",
)
.unwrap();
assert!(load_marketplace_manifest(tmp.path()).is_err());
}
#[test]
fn plugin_manifest_defaults() {
let m = PluginManifest::default();
assert_eq!(m.skills_path(), "skills");
assert_eq!(m.commands_path(), "commands");
assert_eq!(m.hooks_path(), "hooks.json");
}
#[test]
fn plugin_manifest_loads_overrides() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("plugin.json"),
r#"{"name":"p","skills":"my_skills"}"#,
)
.unwrap();
let m = load_plugin_manifest(tmp.path()).unwrap();
assert_eq!(m.skills_path(), "my_skills");
assert_eq!(m.commands_path(), "commands");
}
#[test]
fn parses_inline_source_string() {
let raw = r#"{"name":"mp","plugins":[{"name":"p","source":"./sub"}]}"#;
let m: MarketplaceManifest = serde_json::from_str(raw).unwrap();
match &m.plugins[0].source {
PluginSource::Inline(s) => assert_eq!(s, "./sub"),
_ => panic!("expected Inline"),
}
}
#[test]
fn parses_object_source_url_with_extra_fields() {
let raw = r#"{
"name": "ascend",
"owner": {"name": "x"},
"plugins": [{
"name": "ascend",
"source": {"source": "url", "url": "https://example.com/r.git"},
"description": "d",
"version": "1.0.0",
"author": {"name": "a"}
}]
}"#;
let m: MarketplaceManifest = serde_json::from_str(raw).unwrap();
match &m.plugins[0].source {
PluginSource::External(ExternalSource::Url { url, .. }) => {
assert_eq!(url, "https://example.com/r.git");
}
_ => panic!("expected External::Url"),
}
}
#[test]
fn parses_object_source_github_with_branch_pin() {
let raw = r#"{"name":"mp","plugins":[{"name":"p","source":{"source":"github","repo":"o/r","branch":"dev"}}]}"#;
let m: MarketplaceManifest = serde_json::from_str(raw).unwrap();
match &m.plugins[0].source {
PluginSource::External(ExternalSource::Github { repo, pin }) => {
assert_eq!(repo, "o/r");
assert_eq!(pin.branch.as_deref(), Some("dev"));
}
_ => panic!("expected External::Github"),
}
}
#[test]
fn parses_object_source_local() {
let raw = r#"{"name":"mp","plugins":[{"name":"p","source":{"source":"local","path":"/tmp/x"}}]}"#;
let m: MarketplaceManifest = serde_json::from_str(raw).unwrap();
match &m.plugins[0].source {
PluginSource::External(ExternalSource::Local { path }) => assert_eq!(path, "/tmp/x"),
_ => panic!("expected External::Local"),
}
}
#[test]
fn parses_object_source_git_alias() {
let raw = r#"{"name":"mp","plugins":[{"name":"p","source":{"source":"git","url":"u"}}]}"#;
let m: MarketplaceManifest = serde_json::from_str(raw).unwrap();
assert!(matches!(
m.plugins[0].source,
PluginSource::External(ExternalSource::Git { .. })
));
}
#[test]
fn parses_git_subdir_shorthand_with_ref() {
let raw = r#"{"name":"mp","plugins":[{"name":"p","source":{
"source":"git-subdir","url":"openclaw/openclaw",
"path":".agents/skills/autoreview","ref":"main"}}]}"#;
let m: MarketplaceManifest = serde_json::from_str(raw).unwrap();
match &m.plugins[0].source {
PluginSource::External(ExternalSource::GitSubdir { url, path, pin }) => {
assert_eq!(url, "openclaw/openclaw");
assert_eq!(path, ".agents/skills/autoreview");
assert_eq!(pin.git_ref.as_deref(), Some("main"));
}
other => panic!("expected External::GitSubdir, got {other:?}"),
}
}
#[test]
fn parses_git_subdir_full_url() {
let raw = r#"{"name":"mp","plugins":[{"name":"p","source":{
"source":"git-subdir","url":"https://example.com/r.git",
"path":"plugins/foo"}}]}"#;
let m: MarketplaceManifest = serde_json::from_str(raw).unwrap();
assert!(matches!(
m.plugins[0].source,
PluginSource::External(ExternalSource::GitSubdir { .. })
));
}
#[test]
fn unknown_source_type_becomes_unknown_variant() {
let raw = r#"{"name":"p","source":{"source":"npm","package":"@x/y"}}"#;
let e: PluginEntry = serde_json::from_str(raw).unwrap();
assert!(matches!(e.source, PluginSource::Unknown(_)));
}
#[test]
fn catalog_with_one_unknown_entry_still_parses() {
let raw = r#"{"name":"mp","plugins":[
{"name":"a","source":"plugins/a"},
{"name":"b","source":{"source":"npm","package":"@x/y"}},
{"name":"c","source":{"source":"git-subdir","url":"o/r","path":"sub","ref":"main"}}
]}"#;
let m: MarketplaceManifest = serde_json::from_str(raw).unwrap();
assert_eq!(m.plugins.len(), 3);
assert!(matches!(m.plugins[0].source, PluginSource::Inline(_)));
assert!(matches!(m.plugins[1].source, PluginSource::Unknown(_)));
assert!(matches!(
m.plugins[2].source,
PluginSource::External(ExternalSource::GitSubdir { .. })
));
}
#[test]
fn plugin_manifest_missing_returns_default() {
let tmp = tempfile::tempdir().unwrap();
let m = load_plugin_manifest(tmp.path()).unwrap();
assert_eq!(m.skills_path(), "skills");
}
#[test]
fn skills_paths_default_returns_single_skills() {
let m = PluginManifest::default();
assert_eq!(m.skills_paths(), vec!["skills"]);
}
#[test]
fn skills_paths_single_string() {
let m: PluginManifest = serde_json::from_str(r#"{"skills":"my_skills"}"#).unwrap();
assert_eq!(m.skills_paths(), vec!["my_skills"]);
}
#[test]
fn skills_paths_cc_array() {
let m: PluginManifest =
serde_json::from_str(r#"{"skills":["./skills/foo","./skills/bar"]}"#).unwrap();
assert_eq!(m.skills_paths(), vec!["./skills/foo", "./skills/bar"]);
}
#[test]
fn skills_paths_empty_array_falls_back() {
let m: PluginManifest = serde_json::from_str(r#"{"skills":[]}"#).unwrap();
assert_eq!(m.skills_paths(), vec!["skills"]);
}
}