use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::diagnostic::{PathSegment, SchemaDiagnostic, Severity, key_path};
pub(crate) const PLUGIN_MANIFEST: &str = ".claude-plugin/plugin.json";
pub const MARKETPLACE_MANIFEST: &str = ".claude-plugin/marketplace.json";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComponentLists {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skills: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agents: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commands: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub hooks: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub mcp: bool,
}
impl ComponentLists {
fn has_components(&self) -> bool {
self.skills.is_some() || self.agents.is_some() || self.commands.is_some() || self.hooks
}
fn from_object(
object: &serde_json::Map<String, Value>,
file: &str,
diagnostics: &mut Vec<(String, SchemaDiagnostic)>,
) -> Self {
let mut list = |key: &str| {
object
.get(key)
.map(|value| declared_paths(value, key, file, diagnostics))
};
Self {
skills: list("skills"),
agents: list("agents"),
commands: list("commands"),
hooks: object.contains_key("hooks"),
mcp: object.contains_key("mcpServers"),
}
}
}
fn declared_paths(
value: &Value,
key: &str,
file: &str,
diagnostics: &mut Vec<(String, SchemaDiagnostic)>,
) -> Vec<String> {
let entries: Vec<&Value> = match value {
Value::Array(values) => values.iter().collect(),
other => vec![other],
};
let mut paths = Vec::new();
for entry in entries {
let raw = entry
.as_str()
.or_else(|| entry.get("path").and_then(Value::as_str));
match raw {
Some(raw) if raw == "." || raw.starts_with("./") => paths.push(raw.to_owned()),
_ => diagnostics.push((
file.to_owned(),
SchemaDiagnostic::new(
"source-manifest.path-invalid",
Severity::Warning,
key_path(key),
format!("`{key}` entry {entry} is not a `./` path and was left out"),
),
)),
}
}
paths
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginManifest {
pub name: Option<String>,
pub version: Option<String>,
pub description: Option<String>,
pub lists: ComponentLists,
}
fn parse_object(
bytes: &[u8],
file: &str,
) -> Result<serde_json::Map<String, Value>, SchemaDiagnostic> {
let fail = |message: String| {
SchemaDiagnostic::new(
"source-manifest.invalid-json",
Severity::Error,
Vec::new(),
message,
)
};
match serde_json::from_slice::<Value>(bytes) {
Ok(Value::Object(object)) => Ok(object),
Ok(_) => Err(fail(format!("`{file}` must be a JSON object"))),
Err(error) => Err(fail(format!("failed to parse `{file}`: {error}"))),
}
}
fn text(object: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
object.get(key).and_then(Value::as_str).map(str::to_owned)
}
impl PluginManifest {
pub fn parse(
bytes: &[u8],
diagnostics: &mut Vec<(String, SchemaDiagnostic)>,
) -> Result<Self, SchemaDiagnostic> {
let object = parse_object(bytes, PLUGIN_MANIFEST)?;
let name = text(&object, "name");
if let Some(name) = name.as_deref().filter(|name| !is_plugin_name(name)) {
diagnostics.push((
PLUGIN_MANIFEST.to_owned(),
SchemaDiagnostic::new(
"plugin.name-invalid",
Severity::Warning,
vec![PathSegment::Key("name".into())],
format!(
"plugin name `{name}` is not usable ({PLUGIN_NAME_RULE}); the marketplace entry's or the repository's name is used"
),
),
));
}
Ok(Self {
name: name.filter(|name| is_plugin_name(name)),
version: text(&object, "version"),
description: text(&object, "description"),
lists: ComponentLists::from_object(&object, PLUGIN_MANIFEST, diagnostics),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum PluginSource {
Local { path: String },
Remote {
repository: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
path: Option<String>,
checkout: Checkout,
},
Unsupported { reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum Checkout {
Sha { sha: String },
Ref { name: String },
DefaultBranch,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MarketplaceEntry {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
pub source: PluginSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strict: Option<bool>,
#[serde(default)]
pub lists: ComponentLists,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Marketplace {
pub name: Option<String>,
pub plugins: Vec<MarketplaceEntry>,
}
impl Marketplace {
pub fn parse(
bytes: &[u8],
diagnostics: &mut Vec<(String, SchemaDiagnostic)>,
) -> Result<Self, SchemaDiagnostic> {
let object = parse_object(bytes, MARKETPLACE_MANIFEST)?;
let plugin_root = object
.get("metadata")
.and_then(|metadata| metadata.get("pluginRoot"))
.and_then(Value::as_str);
let mut plugins = Vec::new();
for (index, entry) in object
.get("plugins")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
{
let Some(entry) = entry.as_object() else {
continue;
};
let Some(name) = text(entry, "name") else {
diagnostics.push((
MARKETPLACE_MANIFEST.to_owned(),
SchemaDiagnostic::new(
"marketplace.plugin-unnamed",
Severity::Warning,
vec![
PathSegment::Key("plugins".into()),
PathSegment::Index(index),
],
"a plugin entry without a `name` was left out",
),
));
continue;
};
if !is_plugin_name(&name) {
diagnostics.push((
MARKETPLACE_MANIFEST.to_owned(),
SchemaDiagnostic::new(
"marketplace.plugin-name-invalid",
Severity::Warning,
vec![
PathSegment::Key("plugins".into()),
PathSegment::Index(index),
PathSegment::Key("name".into()),
],
format!(
"plugin name `{name}` is not usable ({PLUGIN_NAME_RULE}); the entry was left out"
),
),
));
continue;
}
plugins.push(MarketplaceEntry {
source: parse_source(entry.get("source"), plugin_root),
description: text(entry, "description"),
version: text(entry, "version"),
strict: entry.get("strict").and_then(Value::as_bool),
lists: ComponentLists::from_object(entry, MARKETPLACE_MANIFEST, diagnostics),
name,
});
}
Ok(Self {
name: text(&object, "name"),
plugins,
})
}
}
pub const PLUGIN_NAME_RULE: &str =
"letters, digits, `-`, `_` and `.`, starting with a letter or digit";
pub fn is_plugin_name(name: &str) -> bool {
name.bytes()
.next()
.is_some_and(|byte| byte.is_ascii_alphanumeric())
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
}
fn unsupported(reason: impl Into<String>) -> PluginSource {
PluginSource::Unsupported {
reason: reason.into(),
}
}
fn local_path(raw: &str) -> Option<String> {
let trimmed = raw.strip_prefix("./").unwrap_or(raw).trim_end_matches('/');
let trimmed = if trimmed == "." { "" } else { trimmed };
if trimmed
.split('/')
.any(|part| matches!(part, ".." | "") && !trimmed.is_empty())
|| trimmed.starts_with('/')
|| trimmed.contains('\\')
{
None
} else {
Some(trimmed.to_owned())
}
}
fn parse_source(source: Option<&Value>, plugin_root: Option<&str>) -> PluginSource {
let Some(source) = source else {
return unsupported("the entry has no `source`");
};
if let Some(raw) = source.as_str() {
let relative = if raw == "." || raw.starts_with("./") {
raw.to_owned()
} else if let Some(root) = plugin_root {
format!("{}/{raw}", root.trim_end_matches('/'))
} else {
return unsupported(format!("`{raw}` is not a `./` path"));
};
return match local_path(&relative) {
Some(path) => PluginSource::Local { path },
None => unsupported(format!("`{raw}` leaves the marketplace")),
};
}
let Some(object) = source.as_object() else {
return unsupported("`source` must be a path or an object");
};
let field = |key: &str| object.get(key).and_then(Value::as_str);
let checkout = || -> Result<Checkout, String> {
match object.get("sha").filter(|sha| !sha.is_null()) {
Some(sha) => match sha.as_str().filter(|sha| is_full_sha(sha)) {
Some(sha) => Ok(Checkout::Sha {
sha: sha.to_owned(),
}),
None => Err("`sha` is not a full 40-character commit".to_owned()),
},
None => match field("ref") {
Some(name) => match git_argument_refusal("ref", name) {
Some(reason) => Err(reason),
None => Ok(Checkout::Ref {
name: name.to_owned(),
}),
},
None => Ok(Checkout::DefaultBranch),
},
}
};
let remote = |repository: &str, path: Option<String>| {
let repository = repository_url(repository);
if let Some(reason) = repository_refusal(&repository) {
return unsupported(reason);
}
match checkout() {
Ok(checkout) => PluginSource::Remote {
repository,
path,
checkout,
},
Err(reason) => unsupported(reason),
}
};
let kind = field("source").unwrap_or_default();
match kind {
"github" => match field("repo") {
Some(repo) => remote(repo, None),
None => unsupported("a `github` source needs `repo`"),
},
"url" => match field("url") {
Some(url) => remote(url, None),
None => unsupported("a `url` source needs `url`"),
},
"git-subdir" => match (field("url"), field("path").and_then(local_path)) {
(Some(url), Some(path)) => remote(url, Some(path).filter(|path| !path.is_empty())),
_ => unsupported("a `git-subdir` source needs `url` and an in-repository `path`"),
},
"" => unsupported("`source` has no `source` type"),
other => unsupported(format!("`{other}` sources cannot be imported")),
}
}
fn git_argument_refusal(what: &str, value: &str) -> Option<String> {
if value.is_empty() {
Some(format!("the {what} is empty"))
} else if value.starts_with('-') {
Some(format!(
"{what} `{value}` starts with `-`, which git reads as an option"
))
} else if value
.chars()
.any(|character| character.is_whitespace() || character.is_control())
{
Some(format!(
"{what} `{value}` holds whitespace or a control character"
))
} else {
None
}
}
fn repository_refusal(repository: &str) -> Option<String> {
if let Some(reason) = git_argument_refusal("repository", repository) {
return Some(reason);
}
if repository.starts_with("file://") || repository.starts_with('/') {
return Some(format!(
"repository `{repository}` is a local repository; a marketplace entry names only network repositories"
));
}
let scp_like = repository
.split_once(':')
.is_some_and(|(user_host, path)| user_host.contains('@') && !path.is_empty());
(!(repository.starts_with("https://") || repository.starts_with("ssh://") || scp_like)).then(
|| {
format!(
"repository `{repository}` is not an `https://` or `ssh://` URL or a `user@host:path` address"
)
},
)
}
fn is_full_sha(value: &str) -> bool {
value.len() == 40
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn repository_url(value: &str) -> String {
let value = value.strip_suffix(".git").unwrap_or(value);
let shorthand = !value.contains("://")
&& !value.starts_with("git@")
&& value.matches('/').count() == 1
&& !value.starts_with('/');
if shorthand {
format!("https://github.com/{value}")
} else {
value.to_owned()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginSpec {
pub name: Option<String>,
pub version: Option<String>,
pub description: Option<String>,
pub skills: SkillSources,
pub agents: Option<Vec<String>>,
pub commands: Option<Vec<String>>,
pub hooks: bool,
pub mcp: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillSources {
Walk,
Declared { default: bool, listed: Vec<String> },
}
impl PluginSpec {
pub(crate) fn is_bare_tree(&self) -> bool {
self.skills == SkillSources::Walk
}
pub fn walk() -> Self {
Self {
name: None,
version: None,
description: None,
skills: SkillSources::Walk,
agents: None,
commands: None,
hooks: false,
mcp: false,
}
}
}
pub(crate) fn resolve_spec(
manifest: Option<&PluginManifest>,
entry: Option<&MarketplaceEntry>,
entry_at_root: bool,
) -> Result<PluginSpec, String> {
let name = manifest
.and_then(|manifest| manifest.name.clone())
.or_else(|| entry.map(|entry| entry.name.clone()));
let version = manifest
.and_then(|manifest| manifest.version.clone())
.or_else(|| entry.and_then(|entry| entry.version.clone()));
let description = manifest
.and_then(|manifest| manifest.description.clone())
.or_else(|| entry.and_then(|entry| entry.description.clone()));
let empty = ComponentLists::default();
let entry_lists = entry.map_or(&empty, |entry| &entry.lists);
let (lists, default_skills) = match manifest {
None => (
entry_lists.clone(),
!(entry_at_root && entry_lists.skills.is_some()),
),
Some(manifest) => {
if entry.is_some_and(|entry| entry.strict == Some(false))
&& entry_lists.has_components()
{
return Err("conflicting manifests: both plugin.json and the marketplace entry (with `strict: false`) specify components".to_owned());
}
let append = |own: &Option<Vec<String>>, entry: &Option<Vec<String>>| {
(own.is_some() || entry.is_some()).then(|| {
own.iter()
.flatten()
.chain(entry.iter().flatten())
.cloned()
.collect()
})
};
(
ComponentLists {
skills: append(&manifest.lists.skills, &entry_lists.skills),
agents: append(&manifest.lists.agents, &entry_lists.agents),
commands: append(&manifest.lists.commands, &entry_lists.commands),
hooks: manifest.lists.hooks || entry_lists.hooks,
mcp: manifest.lists.mcp,
},
true,
)
}
};
Ok(PluginSpec {
name,
version,
description,
skills: SkillSources::Declared {
default: default_skills,
listed: lists.skills.unwrap_or_default(),
},
agents: lists.agents,
commands: lists.commands,
hooks: lists.hooks,
mcp: lists.mcp,
})
}