use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IndexEntry {
pub plugin: String,
pub marketplace: String,
pub repo: String,
pub description: String,
pub category: Option<String>,
}
#[derive(Deserialize)]
struct Manifest {
#[serde(default)]
name: Option<String>,
#[serde(default)]
plugins: Vec<PluginEntry>,
}
#[derive(Deserialize)]
struct PluginEntry {
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
category: Option<String>,
#[serde(default)]
source: Option<serde_json::Value>,
}
pub fn normalize_manifest(json: &str, repo: &str) -> anyhow::Result<Vec<IndexEntry>> {
let m: Manifest = serde_json::from_str(json)?;
let marketplace = m.name.unwrap_or_else(|| {
repo.rsplit('/').next().unwrap_or(repo).to_string()
});
let entries = m
.plugins
.into_iter()
.filter(|p| !p.source.as_ref().is_some_and(crate::vendor::is_command_source))
.filter_map(|p| {
let plugin = p.name?;
Some(IndexEntry {
plugin,
marketplace: marketplace.clone(),
repo: repo.to_string(),
description: p.description.unwrap_or_default(),
category: p.category,
})
})
.collect();
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
const MANIFEST: &str = r#"{
"name": "official",
"plugins": [
{ "name": "pyright-lsp", "description": "Python LSP", "source": "./plugins/pyright-lsp", "category": "language" },
{ "name": "github", "description": "GitHub MCP", "source": { "source": "url", "url": "https://x/y.git" } },
{ "description": "no name, skipped" }
]
}"#;
#[test]
fn normalizes_all_source_shapes_and_skips_nameless() {
let entries = normalize_manifest(MANIFEST, "anthropics/claude-plugins-official").unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0], IndexEntry {
plugin: "pyright-lsp".into(),
marketplace: "official".into(),
repo: "anthropics/claude-plugins-official".into(),
description: "Python LSP".into(),
category: Some("language".into()),
});
assert_eq!(entries[1].repo, "anthropics/claude-plugins-official");
assert_eq!(entries[1].category, None);
}
#[test]
fn skips_command_sourced_plugins() {
let j = r#"{ "name": "official", "plugins": [
{ "name": "normal", "description": "d", "source": "./plugins/normal" },
{ "name": "xcode", "description": "d", "source": { "source": "command", "command": "xcrun agent plugin path --plugin xcode" } }
] }"#;
let entries = normalize_manifest(j, "anthropics/claude-plugins-official").unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].plugin, "normal");
}
#[test]
fn falls_back_to_repo_segment_when_manifest_unnamed() {
let j = r#"{ "plugins": [ { "name": "p", "description": "d" } ] }"#;
let entries = normalize_manifest(j, "obra/superpowers-marketplace").unwrap();
assert_eq!(entries[0].marketplace, "superpowers-marketplace");
}
}