use std::collections::BTreeMap;
use serde_json::{Map, Value};
use crate::error::{Error, Result};
pub const AGENTGEAR_CLIENT_TOKEN: &str = "${AGENTGEAR_CLIENT}";
pub(crate) fn expand_client(s: &str, client: &str) -> String {
s.replace(AGENTGEAR_CLIENT_TOKEN, client)
}
#[derive(Debug, Clone, Default)]
pub struct PluginComponents {
pub mcp_servers: Vec<McpServer>,
pub hooks: Vec<HookBinding>,
pub commands: Vec<MarkdownDoc>,
pub agents: Vec<MarkdownDoc>,
pub skills: Vec<SkillDir>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpServer {
pub name: String,
pub kind: McpKind,
pub command: String,
pub args: Vec<String>,
pub env: BTreeMap<String, String>,
}
impl McpServer {
pub fn is_portable(&self) -> bool {
const VAR: &str = "${CLAUDE_PLUGIN_ROOT}";
!self.command.contains(VAR) && !self.args.iter().any(|a| a.contains(VAR))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpKind {
Stdio,
Http {
url: String,
},
Sse {
url: String,
},
}
#[derive(Debug, Clone)]
pub struct HookBinding {
pub event: String,
pub matcher: Option<String>,
pub command: String,
}
impl HookBinding {
pub fn is_portable(&self) -> bool {
!self.command.contains("${CLAUDE_PLUGIN_ROOT}")
}
}
#[derive(Debug, Clone)]
pub struct MarkdownDoc {
pub name: String,
pub rel: String,
pub frontmatter: BTreeMap<String, Value>,
pub body: String,
pub raw: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct SkillDir {
pub name: String,
pub files: Vec<(String, Vec<u8>)>,
}
impl PluginComponents {
pub fn with_client(mut self, client: &str) -> Self {
for hook in &mut self.hooks {
if hook.command.contains(AGENTGEAR_CLIENT_TOKEN) {
hook.command = expand_client(&hook.command, client);
}
}
for server in &mut self.mcp_servers {
if server.command.contains(AGENTGEAR_CLIENT_TOKEN) {
server.command = expand_client(&server.command, client);
}
for arg in &mut server.args {
if arg.contains(AGENTGEAR_CLIENT_TOKEN) {
*arg = expand_client(arg, client);
}
}
}
self
}
pub(crate) fn parse(entries: &[(String, Vec<u8>)]) -> Result<Self> {
let mut out = PluginComponents::default();
let lookup = |rel: &str| entries.iter().find(|(r, _)| norm(r) == rel).map(|(_, b)| b.as_slice());
parse_mcp(&lookup, &mut out.mcp_servers)?;
for rel in ["hooks/hooks.json", ".claude-plugin/hooks.json"] {
if let Some(bytes) = lookup(rel) {
parse_hooks(rel, bytes, &mut out.hooks)?;
}
}
for (rel, bytes) in entries {
let n = norm(rel);
if n.starts_with("commands/") && n.ends_with(".md") {
out.commands.push(markdown_doc(&n, bytes));
} else if n.starts_with("agents/") && n.ends_with(".md") {
out.agents.push(markdown_doc(&n, bytes));
}
}
parse_skills(entries, &mut out.skills);
Ok(out)
}
}
fn norm(rel: &str) -> String {
rel.replace('\\', "/")
}
fn parse_mcp<'a>(lookup: &impl Fn(&str) -> Option<&'a [u8]>, out: &mut Vec<McpServer>) -> Result<()> {
let mut seen = std::collections::BTreeSet::new();
if let Some(bytes) = lookup(".claude-plugin/plugin.json") {
let json = parse_json(".claude-plugin/plugin.json", bytes)?;
match json.get("mcpServers") {
Some(Value::Object(map)) => push_servers(map, &mut seen, out),
Some(Value::String(path)) => {
if let Some(bytes) = lookup(&norm(path)) {
let doc = parse_json(path, bytes)?;
if let Some(map) = mcp_object_in(&doc) {
push_servers(map, &mut seen, out);
}
}
}
_ => {}
}
}
for rel in [".mcp.json", ".claude-plugin/.mcp.json"] {
if let Some(bytes) = lookup(rel) {
let doc = parse_json(rel, bytes)?;
if let Some(map) = mcp_object_in(&doc) {
push_servers(map, &mut seen, out);
}
}
}
Ok(())
}
fn push_servers(map: &Map<String, Value>, seen: &mut std::collections::BTreeSet<String>, out: &mut Vec<McpServer>) {
for (name, spec) in map {
if seen.insert(name.clone())
&& let Some(server) = server_from_spec(name, spec)
{
out.push(server);
}
}
}
fn mcp_object_in(json: &Value) -> Option<&Map<String, Value>> {
match json.get("mcpServers") {
Some(v) => v.as_object(),
None => json.as_object(),
}
}
fn server_from_spec(name: &str, spec: &Value) -> Option<McpServer> {
let obj = spec.as_object()?;
let url = || obj.get("url").and_then(Value::as_str).unwrap_or_default().to_string();
let kind = match obj.get("type").and_then(Value::as_str) {
Some("http") => McpKind::Http { url: url() },
Some("sse") => McpKind::Sse { url: url() },
_ => McpKind::Stdio, };
let command = obj.get("command").and_then(Value::as_str).unwrap_or_default().to_string();
let args = obj
.get("args")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
.unwrap_or_default();
let env = obj
.get("env")
.and_then(Value::as_object)
.map(|m| m.iter().filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))).collect())
.unwrap_or_default();
Some(McpServer { name: name.to_string(), kind, command, args, env })
}
fn parse_hooks(rel: &str, bytes: &[u8], out: &mut Vec<HookBinding>) -> Result<()> {
let json = parse_json(rel, bytes)?;
let Some(events) = json.get("hooks").and_then(Value::as_object) else {
return Ok(());
};
for (event, groups) in events {
let Some(groups) = groups.as_array() else { continue };
for group in groups {
let matcher = group.get("matcher").and_then(Value::as_str).map(str::to_string);
let Some(handlers) = group.get("hooks").and_then(Value::as_array) else { continue };
for handler in handlers {
if let Some(command) = handler.get("command").and_then(Value::as_str) {
out.push(HookBinding { event: event.clone(), matcher: matcher.clone(), command: command.to_string() });
}
}
}
}
Ok(())
}
fn markdown_doc(rel: &str, bytes: &[u8]) -> MarkdownDoc {
let name = rel.rsplit('/').next().unwrap_or(rel).strip_suffix(".md").unwrap_or(rel).to_string();
let text = String::from_utf8_lossy(bytes);
let (frontmatter, body) = split_frontmatter(&text);
MarkdownDoc { name, rel: rel.to_string(), frontmatter, body, raw: bytes.to_vec() }
}
fn split_frontmatter(text: &str) -> (BTreeMap<String, Value>, String) {
let rest = match text.strip_prefix("---\n").or_else(|| text.strip_prefix("---\r\n")) {
Some(rest) => rest,
None => return (BTreeMap::new(), text.to_string()),
};
let mut map = BTreeMap::new();
let mut pos = 0usize;
while pos < rest.len() {
let nl = rest[pos..].find('\n').map(|i| pos + i);
let line = rest[pos..nl.unwrap_or(rest.len())].trim_end_matches('\r');
let mut next = nl.map_or(rest.len(), |i| i + 1);
if line.trim() == "---" {
return (map, rest.get(next..).unwrap_or_default().to_string());
}
if let Some((k, v)) = line.split_once(':') {
let key = k.trim().to_string();
let raw_value = v.trim();
if let Some(strip_trailing_newline) = block_scalar_chomp(raw_value) {
let (block, after) = read_block_scalar(rest, next, strip_trailing_newline);
map.insert(key, Value::String(block));
next = after;
} else {
let value = raw_value.trim_matches('"').trim_matches('\'');
map.insert(key, Value::String(value.to_string()));
}
}
pos = next;
}
(BTreeMap::new(), text.to_string())
}
fn block_scalar_chomp(value: &str) -> Option<bool> {
let mut chars = value.chars();
match chars.next()? {
'|' | '>' => {}
_ => return None,
}
let modifiers = chars.as_str();
if modifiers.is_empty() {
return Some(false);
}
modifiers.chars().all(|c| c.is_ascii_digit() || c == '-' || c == '+').then(|| modifiers.contains('-'))
}
fn read_block_scalar(rest: &str, start: usize, strip_trailing_newline: bool) -> (String, usize) {
let mut pos = start;
let mut lines: Vec<&str> = Vec::new();
while pos < rest.len() {
let nl = rest[pos..].find('\n').map(|i| pos + i);
let line = rest[pos..nl.unwrap_or(rest.len())].trim_end_matches('\r');
let indent = line.len() - line.trim_start_matches(' ').len();
if !line.trim().is_empty() && indent == 0 {
break; }
lines.push(line);
pos = nl.map_or(rest.len(), |i| i + 1);
}
while lines.last().is_some_and(|l| l.trim().is_empty()) {
lines.pop();
}
if lines.is_empty() {
return (String::new(), pos);
}
let indent = lines.iter().filter(|l| !l.trim().is_empty()).map(|l| l.len() - l.trim_start_matches(' ').len()).min().unwrap_or(0);
let joined = lines.iter().map(|l| l.get(indent.min(l.len())..).unwrap_or("")).collect::<Vec<_>>().join("\n");
(if strip_trailing_newline { joined } else { format!("{joined}\n") }, pos)
}
fn parse_skills(entries: &[(String, Vec<u8>)], out: &mut Vec<SkillDir>) {
let mut by_name: BTreeMap<String, Vec<(String, Vec<u8>)>> = BTreeMap::new();
for (rel, bytes) in entries {
let n = norm(rel);
let Some(rest) = n.strip_prefix("skills/") else { continue };
let Some((skill, within)) = rest.split_once('/') else { continue };
by_name.entry(skill.to_string()).or_default().push((within.to_string(), bytes.clone()));
}
for (name, files) in by_name {
out.push(SkillDir { name, files });
}
}
fn parse_json(what: &str, bytes: &[u8]) -> Result<Value> {
serde_json::from_slice(bytes).map_err(|source| Error::Json { what: what.to_string(), source })
}
#[cfg(test)]
#[path = "../tests/unit/components.rs"]
mod components_tests;