Marketplace: repo tree view and catalog parsing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 08:48:31 -07:00
co-authored by Claude Opus 5.5
parent d419d0a6b4
commit 2e62728b06
4 changed files with 1181 additions and 0 deletions
+1
View File
@@ -7,6 +7,7 @@ mod docker;
pub mod file_viewer;
mod install_helper;
mod logging;
mod marketplace;
mod models;
mod project_lock;
mod storage;
+947
View File
@@ -0,0 +1,947 @@
//! Reading a marketplace repo: which items it offers, and the files of one item.
//!
//! Layout (spec §1): `agents/<key>.md`, `skills/<key>/SKILL.md`,
//! `commands/<key>.md`, `hooks/<key>/hook.json`, and `plugins/` as a standard
//! Claude Code marketplace. Every item is validated here — key pattern,
//! symlinks, size and file-count limits, plugin sources that stay inside
//! `plugins/` — so nothing downstream ever sees a name or a file it would
//! have to distrust. A broken item is listed with its reason; it never stops
//! the rest of the repo from loading.
use sha2::{Digest, Sha256};
use crate::marketplace::tree::{hex, EntryKind, TreeView};
use crate::models::marketplace::{is_valid_item_key, CatalogItem, ItemKind};
pub const MAX_ITEM_BYTES: u64 = 2 * 1024 * 1024;
pub const MAX_ITEM_FILES: usize = 200;
/// Preview text is truncated to this many bytes (on a char boundary).
const MAX_PREVIEW_BYTES: usize = 64 * 1024;
const PLUGIN_CATALOG_PATH: &str = "plugins/.claude-plugin/marketplace.json";
/// Hook events Claude Code understands. A `hook.json` naming anything else is
/// invalid rather than silently ignored by Claude Code at runtime.
const HOOK_EVENTS: &[&str] = &[
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"PermissionRequest",
"Notification",
"UserPromptSubmit",
"SessionStart",
"SessionEnd",
"Stop",
"SubagentStart",
"SubagentStop",
"PreCompact",
];
/// One file of an item, path relative to the item root (for single-file
/// items: the file name).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ItemFile {
pub rel_path: String,
pub data: Vec<u8>,
pub executable: bool,
}
pub fn hook_dir(key: &str) -> String {
format!("/home/claude/.claude/triple-c/hooks/{}", key)
}
// ─────────────────────────────────────────────────────────────────────────────
// Parsing helpers
// ─────────────────────────────────────────────────────────────────────────────
/// Minimal YAML front matter: `key: value` lines between leading `---`
/// fences. Returns `(fields, body)`. Quotes around values are stripped. No
/// front matter → no fields, the whole text is the body.
fn front_matter(text: &str) -> (Vec<(String, String)>, &str) {
let rest = match text
.strip_prefix("---\n")
.or_else(|| text.strip_prefix("---\r\n"))
{
Some(rest) => rest,
None => return (Vec::new(), text),
};
let mut fields = Vec::new();
let mut offset = 0;
for line in rest.split_inclusive('\n') {
offset += line.len();
let trimmed = line.trim_end_matches(['\n', '\r']);
if trimmed == "---" {
return (fields, &rest[offset..]);
}
if let Some((k, v)) = trimmed.split_once(':') {
let k = k.trim();
if !k.is_empty() && !k.starts_with(' ') && !line.starts_with(' ') {
let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
fields.push((k.to_string(), v));
}
}
}
// Unterminated front matter: treat the whole file as body.
(Vec::new(), text)
}
fn field<'a>(fields: &'a [(String, String)], name: &str) -> Option<&'a str> {
fields
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
.filter(|v| !v.is_empty())
}
fn truncate_preview(text: &str) -> String {
if text.len() <= MAX_PREVIEW_BYTES {
return text.to_string();
}
let mut cut = MAX_PREVIEW_BYTES;
while !text.is_char_boundary(cut) {
cut -= 1;
}
format!("{}\n…(truncated)", &text[..cut])
}
fn read_utf8(tree: &dyn TreeView, path: &str) -> Result<Option<String>, String> {
match tree.read_file(path)? {
None => Ok(None),
Some(bytes) => String::from_utf8(bytes)
.map(Some)
.map_err(|_| format!("{} is not UTF-8 text", path)),
}
}
/// The shared reason a key fails [`is_valid_item_key`], used wherever a name
/// (agent/skill/command/hook/plugin key) is rejected so the wording doesn't
/// drift between the three call sites.
fn invalid_name_reason(key: &str) -> String {
format!(
"{:?} is not a valid name (letters, digits, '.', '_' and '-', starting with a letter or digit, at most 64)",
key
)
}
/// Normalise a plugin `source` into a path under `plugins/`, refusing
/// anything that is not a plain relative path staying inside `plugins/`.
fn plugin_source_path(source: &serde_json::Value) -> Result<String, String> {
let source = source.as_str().ok_or_else(|| {
"remote plugin sources are not supported — the plugin must live in this repo's plugins/ folder"
.to_string()
})?;
if source.starts_with('/') || source.contains('\\') || source.contains(':') {
return Err(format!(
"plugin source {:?} must be a relative path inside plugins/",
source
));
}
let mut parts = Vec::new();
for part in source.split('/') {
match part {
"" | "." => {}
".." => {
return Err(format!(
"plugin source {:?} must stay inside plugins/",
source
));
}
p => parts.push(p),
}
}
if parts.is_empty() {
return Err(format!(
"plugin source {:?} must name a folder inside plugins/",
source
));
}
Ok(format!("plugins/{}", parts.join("/")))
}
fn read_plugin_catalog(tree: &dyn TreeView) -> Result<Option<Vec<serde_json::Value>>, String> {
let Some(text) = read_utf8(tree, PLUGIN_CATALOG_PATH)? else {
return Ok(None);
};
let json: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| format!("{} is not valid JSON: {}", PLUGIN_CATALOG_PATH, e))?;
let plugins = json
.get("plugins")
.and_then(|p| p.as_array())
.ok_or_else(|| format!("{} has no \"plugins\" array", PLUGIN_CATALOG_PATH))?;
Ok(Some(plugins.clone()))
}
/// Plugins only: the plugin's entry from `plugins/.claude-plugin/marketplace.json`.
pub fn plugin_catalog_entry(tree: &dyn TreeView, key: &str) -> Result<serde_json::Value, String> {
let entries =
read_plugin_catalog(tree)?.ok_or_else(|| format!("{} is missing", PLUGIN_CATALOG_PATH))?;
entries
.into_iter()
.find(|e| e.get("name").and_then(|n| n.as_str()) == Some(key))
.ok_or_else(|| format!("plugin {} is not in {}", key, PLUGIN_CATALOG_PATH))
}
/// Repo path of an item: a file for agents/commands, a folder otherwise.
fn item_path(tree: &dyn TreeView, kind: ItemKind, key: &str) -> Result<String, String> {
if !is_valid_item_key(key) {
return Err(invalid_name_reason(key));
}
Ok(match kind {
ItemKind::Agent => format!("agents/{}.md", key),
ItemKind::Command => format!("commands/{}.md", key),
ItemKind::Skill => format!("skills/{}", key),
ItemKind::Hook => format!("hooks/{}", key),
ItemKind::Plugin => {
let entry = plugin_catalog_entry(tree, key)?;
plugin_source_path(entry.get("source").unwrap_or(&serde_json::Value::Null))?
}
})
}
/// Recursively collect a folder's files, enforcing the item rules.
fn collect_dir(
tree: &dyn TreeView,
root: &str,
rel: &str,
out: &mut Vec<ItemFile>,
total: &mut u64,
) -> Result<(), String> {
let path = if rel.is_empty() {
root.to_string()
} else {
format!("{}/{}", root, rel)
};
let entries = tree
.list_dir(&path)?
.ok_or_else(|| format!("{} is not a folder", path))?;
for entry in entries {
let child_rel = if rel.is_empty() {
entry.name.clone()
} else {
format!("{}/{}", rel, entry.name)
};
match entry.kind {
EntryKind::Symlink => {
return Err(format!(
"contains a symlink ({}), which is not allowed",
child_rel
));
}
EntryKind::Other => {
return Err(format!(
"contains a submodule or special entry ({})",
child_rel
));
}
EntryKind::Dir => collect_dir(tree, root, &child_rel, out, total)?,
EntryKind::File => {
let data = tree
.read_file(&format!("{}/{}", root, child_rel))?
.ok_or_else(|| format!("{} vanished while reading", child_rel))?;
*total += data.len() as u64;
if out.len() + 1 > MAX_ITEM_FILES {
return Err(format!("has more than {} files", MAX_ITEM_FILES));
}
if *total > MAX_ITEM_BYTES {
return Err(format!(
"is larger than {} MiB",
MAX_ITEM_BYTES / (1024 * 1024)
));
}
out.push(ItemFile {
rel_path: child_rel,
data,
executable: entry.executable,
});
}
}
}
Ok(())
}
/// Kind of the entry at `path`, looked up through its parent listing.
fn entry_kind(tree: &dyn TreeView, path: &str) -> Result<Option<(EntryKind, bool)>, String> {
let (parent, name) = match path.rsplit_once('/') {
Some((p, n)) => (p, n),
None => ("", path),
};
Ok(tree
.list_dir(parent)?
.and_then(|entries| entries.into_iter().find(|e| e.name == name))
.map(|e| (e.kind, e.executable)))
}
/// All files of one item. Err if the item is missing/invalid or breaks the limits.
pub fn item_files(tree: &dyn TreeView, kind: ItemKind, key: &str) -> Result<Vec<ItemFile>, String> {
let path = item_path(tree, kind, key)?;
match kind {
ItemKind::Agent | ItemKind::Command => {
let (entry, executable) =
entry_kind(tree, &path)?.ok_or_else(|| format!("{} is missing", path))?;
match entry {
EntryKind::File => {}
EntryKind::Symlink => {
return Err(format!("{} is a symlink, which is not allowed", path))
}
_ => return Err(format!("{} is not a regular file", path)),
}
let data = tree
.read_file(&path)?
.ok_or_else(|| format!("{} is missing", path))?;
if data.len() as u64 > MAX_ITEM_BYTES {
return Err(format!(
"is larger than {} MiB",
MAX_ITEM_BYTES / (1024 * 1024)
));
}
Ok(vec![ItemFile {
rel_path: format!("{}.md", key),
data,
executable,
}])
}
ItemKind::Skill | ItemKind::Hook | ItemKind::Plugin => {
match entry_kind(tree, &path)? {
Some((EntryKind::Dir, _)) => {}
Some((EntryKind::Symlink, _)) => {
return Err(format!("{} is a symlink, which is not allowed", path))
}
Some(_) => return Err(format!("{} is not a folder", path)),
None => return Err(format!("{} is missing", path)),
}
let mut out = Vec::new();
let mut total = 0u64;
collect_dir(tree, &path, "", &mut out, &mut total)?;
let required = match kind {
ItemKind::Skill => Some("SKILL.md"),
ItemKind::Hook => Some("hook.json"),
_ => None,
};
if let Some(required) = required {
if !out.iter().any(|f| f.rel_path == required) {
return Err(format!("{} has no {}", path, required));
}
}
Ok(out)
}
}
}
/// Content fingerprint for update detection: changes iff the item's files or,
/// for plugins, its catalog entry change. `Ok(None)` when the item is absent.
pub fn item_fingerprint(
tree: &dyn TreeView,
kind: ItemKind,
key: &str,
) -> Result<Option<String>, String> {
if kind == ItemKind::Plugin {
let entry = match plugin_catalog_entry(tree, key) {
Ok(entry) => entry,
Err(_) => return Ok(None),
};
let path = match plugin_source_path(entry.get("source").unwrap_or(&serde_json::Value::Null))
{
Ok(path) => path,
Err(_) => return Ok(None),
};
let Some(dir_id) = tree.entry_id(&path)? else {
return Ok(None);
};
let mut hasher = Sha256::new();
hasher.update(dir_id.as_bytes());
hasher.update([0]);
// serde_json's Map is ordered by key (no preserve_order), so this is canonical.
hasher.update(entry.to_string().as_bytes());
return Ok(Some(hex(&hasher.finalize())));
}
if !is_valid_item_key(key) {
return Ok(None);
}
let path = item_path(tree, kind, key)?;
tree.entry_id(&path)
}
fn substitute_hook_dir(value: &mut serde_json::Value, dir: &str) {
match value {
serde_json::Value::String(s) => {
if s.contains("${HOOK_DIR}") {
*s = s.replace("${HOOK_DIR}", dir);
}
}
serde_json::Value::Array(items) => {
items.iter_mut().for_each(|v| substitute_hook_dir(v, dir))
}
serde_json::Value::Object(map) => {
map.values_mut().for_each(|v| substitute_hook_dir(v, dir))
}
_ => {}
}
}
/// Validate a `hooks` object and return the command strings it runs.
fn validate_hooks(hooks: &serde_json::Value) -> Result<Vec<String>, String> {
let map = hooks
.as_object()
.ok_or_else(|| "\"hooks\" must be an object keyed by event name".to_string())?;
if map.is_empty() {
return Err("\"hooks\" is empty".to_string());
}
let mut commands = Vec::new();
for (event, matchers) in map {
if !HOOK_EVENTS.contains(&event.as_str()) {
return Err(format!("unknown hook event {:?}", event));
}
let matchers = matchers
.as_array()
.ok_or_else(|| format!("\"{}\" must be an array", event))?;
for matcher in matchers {
let handlers = matcher
.get("hooks")
.and_then(|h| h.as_array())
.ok_or_else(|| format!("each \"{}\" entry needs a \"hooks\" array", event))?;
for handler in handlers {
let kind = handler.get("type").and_then(|t| t.as_str()).unwrap_or("");
if kind.is_empty() {
return Err(format!("a \"{}\" hook has no \"type\"", event));
}
if kind == "command" {
let command = handler
.get("command")
.and_then(|c| c.as_str())
.filter(|c| !c.trim().is_empty())
.ok_or_else(|| {
format!("a \"{}\" command hook has no \"command\"", event)
})?;
commands.push(command.to_string());
}
}
}
}
Ok(commands)
}
fn read_hook_json(tree: &dyn TreeView, key: &str) -> Result<serde_json::Value, String> {
let path = format!("hooks/{}/hook.json", key);
let text = read_utf8(tree, &path)?.ok_or_else(|| format!("{} is missing", path))?;
serde_json::from_str(&text).map_err(|e| format!("{} is not valid JSON: {}", path, e))
}
/// Hooks only: the parsed `hooks` object with `${HOOK_DIR}` substituted.
pub fn rendered_hook_settings(tree: &dyn TreeView, key: &str) -> Result<serde_json::Value, String> {
if !is_valid_item_key(key) {
return Err(format!("{:?} is not a valid hook name", key));
}
let json = read_hook_json(tree, key)?;
let mut hooks = json
.get("hooks")
.cloned()
.ok_or_else(|| format!("hooks/{}/hook.json has no \"hooks\" object", key))?;
validate_hooks(&hooks)?;
substitute_hook_dir(&mut hooks, &hook_dir(key));
Ok(hooks)
}
// ─────────────────────────────────────────────────────────────────────────────
// Catalog
// ─────────────────────────────────────────────────────────────────────────────
fn item(kind: ItemKind, key: &str, path: String) -> CatalogItem {
CatalogItem {
kind,
key: key.to_string(),
name: key.to_string(),
description: String::new(),
path,
invalid: None,
hook_commands: Vec::new(),
preview: String::new(),
}
}
/// Fill name/description/preview from a markdown file with front matter.
fn describe_markdown(it: &mut CatalogItem, text: &str, first_line_fallback: bool) {
let (fields, body) = front_matter(text);
if let Some(name) = field(&fields, "name") {
it.name = name.to_string();
}
if let Some(desc) = field(&fields, "description") {
it.description = desc.to_string();
} else if first_line_fallback {
if let Some(line) = body.lines().map(str::trim).find(|l| !l.is_empty()) {
it.description = line.trim_start_matches('#').trim().to_string();
}
}
it.preview = truncate_preview(body.trim_start_matches(['\n', '\r']));
}
/// Mark `it` invalid when its files break the rules.
fn validate_files(tree: &dyn TreeView, it: &mut CatalogItem) {
if it.invalid.is_some() {
return;
}
if let Err(reason) = item_files(tree, it.kind, &it.key) {
it.invalid = Some(reason);
}
}
fn parse_single_files(
tree: &dyn TreeView,
kind: ItemKind,
folder: &str,
out: &mut Vec<CatalogItem>,
) {
let entries = match tree.list_dir(folder) {
Ok(Some(entries)) => entries,
Ok(None) => return,
Err(e) => {
let mut it = item(kind, folder, folder.to_string());
it.invalid = Some(e);
out.push(it);
return;
}
};
for entry in entries {
let Some(stem) = entry.name.strip_suffix(".md") else {
continue;
};
let mut it = item(kind, stem, format!("{}/{}", folder, entry.name));
if !is_valid_item_key(stem) {
it.invalid = Some(invalid_name_reason(stem));
out.push(it);
continue;
}
match entry.kind {
EntryKind::File => match read_utf8(tree, &it.path) {
Ok(Some(text)) => describe_markdown(&mut it, &text, kind == ItemKind::Command),
Ok(None) => it.invalid = Some(format!("{} is missing", it.path)),
Err(e) => it.invalid = Some(e),
},
EntryKind::Symlink => {
it.invalid = Some(format!("{} is a symlink, which is not allowed", it.path))
}
_ => it.invalid = Some(format!("{} is not a regular file", it.path)),
}
validate_files(tree, &mut it);
out.push(it);
}
}
fn parse_folders(tree: &dyn TreeView, kind: ItemKind, folder: &str, out: &mut Vec<CatalogItem>) {
let entries = match tree.list_dir(folder) {
Ok(Some(entries)) => entries,
Ok(None) => return,
Err(e) => {
let mut it = item(kind, folder, folder.to_string());
it.invalid = Some(e);
out.push(it);
return;
}
};
for entry in entries {
if entry.kind == EntryKind::File {
continue; // e.g. a README.md next to the item folders
}
let mut it = item(kind, &entry.name, format!("{}/{}", folder, entry.name));
if !is_valid_item_key(&entry.name) {
it.invalid = Some(invalid_name_reason(&entry.name));
out.push(it);
continue;
}
if entry.kind == EntryKind::Symlink {
it.invalid = Some(format!("{} is a symlink, which is not allowed", it.path));
out.push(it);
continue;
}
match kind {
ItemKind::Skill => match read_utf8(tree, &format!("{}/SKILL.md", it.path)) {
Ok(Some(text)) => describe_markdown(&mut it, &text, false),
Ok(None) => it.invalid = Some(format!("{} has no SKILL.md", it.path)),
Err(e) => it.invalid = Some(e),
},
ItemKind::Hook => match read_hook_json(tree, &entry.name) {
Ok(json) => {
if let Some(name) = json
.get("name")
.and_then(|n| n.as_str())
.filter(|n| !n.is_empty())
{
it.name = name.to_string();
}
if let Some(desc) = json.get("description").and_then(|d| d.as_str()) {
it.description = desc.to_string();
}
match rendered_hook_settings(tree, &entry.name) {
Ok(hooks) => match validate_hooks(&hooks) {
Ok(commands) => it.hook_commands = commands,
Err(e) => it.invalid = Some(e),
},
Err(e) => it.invalid = Some(e),
}
}
Err(e) => it.invalid = Some(e),
},
_ => {}
}
validate_files(tree, &mut it);
out.push(it);
}
}
fn parse_plugins(tree: &dyn TreeView, out: &mut Vec<CatalogItem>) {
let entries = match read_plugin_catalog(tree) {
Ok(Some(entries)) => entries,
Ok(None) => return,
Err(e) => {
let mut it = item(ItemKind::Plugin, "catalog", PLUGIN_CATALOG_PATH.to_string());
it.name = PLUGIN_CATALOG_PATH.to_string();
it.invalid = Some(e);
out.push(it);
return;
}
};
for entry in entries {
let key = entry
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let mut it = item(ItemKind::Plugin, &key, PLUGIN_CATALOG_PATH.to_string());
if let Some(desc) = entry.get("description").and_then(|d| d.as_str()) {
it.description = desc.to_string();
}
if !is_valid_item_key(&key) {
it.invalid = Some(format!("plugin name {:?} is not a valid name", key));
out.push(it);
continue;
}
match plugin_source_path(entry.get("source").unwrap_or(&serde_json::Value::Null)) {
Ok(path) => {
it.path = path.clone();
if let Ok(Some(children)) = tree.list_dir(&path) {
it.preview = children
.iter()
.map(|c| {
if c.kind == EntryKind::Dir {
format!("{}/", c.name)
} else {
c.name.clone()
}
})
.collect::<Vec<_>>()
.join("\n");
}
}
Err(e) => it.invalid = Some(e),
}
validate_files(tree, &mut it);
out.push(it);
}
}
/// Parse every item in the repo. Never fails as a whole; broken items carry `invalid`.
/// Order: agents, skills, commands, hooks, plugins; each in listing order.
pub fn parse_catalog(tree: &dyn TreeView) -> Vec<CatalogItem> {
let mut out = Vec::new();
parse_single_files(tree, ItemKind::Agent, "agents", &mut out);
parse_folders(tree, ItemKind::Skill, "skills", &mut out);
parse_single_files(tree, ItemKind::Command, "commands", &mut out);
parse_folders(tree, ItemKind::Hook, "hooks", &mut out);
parse_plugins(tree, &mut out);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::marketplace::tree::MemTree;
const HOOK_JSON: &str = r#"{
"name": "notify-on-stop",
"description": "Ping when Claude stops",
"hooks": { "Stop": [ { "hooks": [ { "type": "command", "command": "${HOOK_DIR}/notify.sh" } ] } ] }
}"#;
const PLUGIN_CATALOG: &str = r#"{
"name": "example",
"owner": { "name": "t" },
"plugins": [
{ "name": "example-plugin", "source": "./example-plugin", "description": "Adds a skill" }
]
}"#;
fn full_repo() -> MemTree {
MemTree::new()
.file("README.md", "# repo")
.file(
"agents/code-reviewer.md",
"---\nname: code-reviewer\ndescription: Reviews diffs\n---\nYou review code.\n",
)
.file(
"skills/example-skill/SKILL.md",
"---\nname: example-skill\ndescription: \"Says hi\"\n---\nSay hi.\n",
)
.file("skills/example-skill/ref/notes.md", "notes")
.file(
"commands/example-command.md",
"# Summarise the branch\n\nDo it.\n",
)
.file("hooks/notify-on-stop/hook.json", HOOK_JSON)
.exec_file("hooks/notify-on-stop/notify.sh", "#!/bin/sh\necho done\n")
.file("plugins/.claude-plugin/marketplace.json", PLUGIN_CATALOG)
.file(
"plugins/example-plugin/.claude-plugin/plugin.json",
r#"{"name":"example-plugin"}"#,
)
.file(
"plugins/example-plugin/skills/hello/SKILL.md",
"---\nname: hello\n---\nhi",
)
}
#[test]
fn parses_every_kind() {
let items = parse_catalog(&full_repo());
let summary: Vec<_> = items
.iter()
.map(|i| (i.kind, i.key.as_str(), i.invalid.as_deref()))
.collect();
assert_eq!(
summary,
vec![
(ItemKind::Agent, "code-reviewer", None),
(ItemKind::Skill, "example-skill", None),
(ItemKind::Command, "example-command", None),
(ItemKind::Hook, "notify-on-stop", None),
(ItemKind::Plugin, "example-plugin", None),
]
);
assert_eq!(items[0].description, "Reviews diffs");
assert_eq!(items[0].preview, "You review code.\n");
assert_eq!(items[1].description, "Says hi");
assert_eq!(items[2].description, "Summarise the branch");
assert_eq!(items[3].name, "notify-on-stop");
assert_eq!(
items[3].hook_commands,
vec!["/home/claude/.claude/triple-c/hooks/notify-on-stop/notify.sh".to_string()]
);
assert_eq!(items[4].path, "plugins/example-plugin");
assert_eq!(items[4].preview, ".claude-plugin/\nskills/");
}
#[test]
fn an_empty_repo_has_no_items() {
assert!(parse_catalog(&MemTree::new().file("README.md", "x")).is_empty());
}
#[test]
fn name_falls_back_to_the_file_stem() {
let t = MemTree::new().file("agents/plain.md", "no front matter here");
let items = parse_catalog(&t);
assert_eq!(items[0].name, "plain");
assert_eq!(items[0].description, "");
assert!(items[0].invalid.is_none());
}
#[test]
fn rejects_symlink_items() {
let t = MemTree::new()
.symlink("agents/evil.md", "/etc/passwd")
.file("skills/s/SKILL.md", "x")
.symlink("skills/s/link", "../../..")
.symlink("hooks/h", "../skills/s");
let items = parse_catalog(&t);
assert_eq!(items.len(), 3);
for it in &items {
let reason = it
.invalid
.as_deref()
.unwrap_or_else(|| panic!("{} should be invalid", it.key));
assert!(reason.contains("symlink"), "{}: {}", it.key, reason);
}
assert!(item_files(&t, ItemKind::Skill, "s").is_err());
}
#[test]
fn rejects_escaping_plugin_source() {
for source in [
r#""../outside""#,
r#""./a/../../b""#,
r#""/abs""#,
r#""https://evil.example/x.git""#,
r#"{"source":"github","repo":"x/y"}"#,
r#""""#,
] {
let catalog = format!(r#"{{"plugins":[{{"name":"p","source":{}}}]}}"#, source);
let t = MemTree::new()
.file("plugins/.claude-plugin/marketplace.json", &catalog)
.file("plugins/p/x.md", "x")
.file("outside/x.md", "x");
let items = parse_catalog(&t);
assert!(
items[0].invalid.is_some(),
"source {} should be refused",
source
);
assert!(item_files(&t, ItemKind::Plugin, "p").is_err());
}
}
#[test]
fn rejects_bad_keys() {
let t = MemTree::new()
.file("agents/-rf.md", "x")
.file("agents/a b.md", "x")
.file("skills/$(id)/SKILL.md", "x")
.file(
"plugins/.claude-plugin/marketplace.json",
r#"{"plugins":[{"name":"bad;name","source":"./p"}]}"#,
)
.file("plugins/p/x", "x");
let items = parse_catalog(&t);
assert_eq!(items.len(), 4);
assert!(items.iter().all(|i| i.invalid.is_some()), "{:?}", items);
assert!(item_files(&t, ItemKind::Agent, "-rf").is_err());
assert!(item_files(&t, ItemKind::Skill, "$(id)").is_err());
assert!(item_files(&t, ItemKind::Agent, "../x").is_err());
}
#[test]
fn enforces_item_limits() {
let mut many = MemTree::new().file("skills/big/SKILL.md", "x");
for i in 0..MAX_ITEM_FILES {
many = many.file(&format!("skills/big/f{}.txt", i), "x");
}
let err = item_files(&many, ItemKind::Skill, "big").unwrap_err();
assert!(err.contains("more than 200 files"), "{}", err);
let huge = "x".repeat(MAX_ITEM_BYTES as usize + 1);
let t = MemTree::new().file("agents/huge.md", &huge);
assert!(item_files(&t, ItemKind::Agent, "huge")
.unwrap_err()
.contains("larger than 2 MiB"));
assert!(parse_catalog(&t)[0].invalid.is_some());
}
#[test]
fn hooks_must_name_known_events_and_commands() {
let t = MemTree::new()
.file(
"hooks/a/hook.json",
r#"{"hooks":{"NotAnEvent":[{"hooks":[{"type":"command","command":"x"}]}]}}"#,
)
.file(
"hooks/b/hook.json",
r#"{"hooks":{"Stop":[{"hooks":[{"type":"command"}]}]}}"#,
)
.file("hooks/c/hook.json", "not json")
.file("hooks/d/other.txt", "no hook.json");
let items = parse_catalog(&t);
assert_eq!(items.len(), 4);
assert!(items[0]
.invalid
.as_deref()
.unwrap()
.contains("unknown hook event"));
assert!(items[1]
.invalid
.as_deref()
.unwrap()
.contains("no \"command\""));
assert!(items[2]
.invalid
.as_deref()
.unwrap()
.contains("not valid JSON"));
assert!(items[3].invalid.as_deref().unwrap().contains("missing"));
}
#[test]
fn broken_plugin_catalog_is_one_invalid_entry() {
let t = MemTree::new()
.file("agents/ok.md", "x")
.file("plugins/.claude-plugin/marketplace.json", "{");
let items = parse_catalog(&t);
assert_eq!(items.len(), 2);
assert!(items[0].invalid.is_none());
assert!(items[1]
.invalid
.as_deref()
.unwrap()
.contains("not valid JSON"));
}
#[test]
fn item_files_are_relative_to_the_item_and_keep_exec_bits() {
let t = full_repo();
let agent = item_files(&t, ItemKind::Agent, "code-reviewer").unwrap();
assert_eq!(agent.len(), 1);
assert_eq!(agent[0].rel_path, "code-reviewer.md");
let hook = item_files(&t, ItemKind::Hook, "notify-on-stop").unwrap();
let names: Vec<_> = hook
.iter()
.map(|f| (f.rel_path.as_str(), f.executable))
.collect();
assert_eq!(names, vec![("hook.json", false), ("notify.sh", true)]);
let skill = item_files(&t, ItemKind::Skill, "example-skill").unwrap();
assert!(skill.iter().any(|f| f.rel_path == "ref/notes.md"));
let plugin = item_files(&t, ItemKind::Plugin, "example-plugin").unwrap();
assert!(plugin.iter().any(|f| f.rel_path == "skills/hello/SKILL.md"));
}
#[test]
fn fingerprint_tracks_the_item_only() {
let a = full_repo();
let b = full_repo().file("agents/code-reviewer.md", "changed");
for (kind, key) in [
(ItemKind::Skill, "example-skill"),
(ItemKind::Hook, "notify-on-stop"),
(ItemKind::Plugin, "example-plugin"),
] {
assert_eq!(
item_fingerprint(&a, kind, key).unwrap(),
item_fingerprint(&b, kind, key).unwrap()
);
}
assert_ne!(
item_fingerprint(&a, ItemKind::Agent, "code-reviewer").unwrap(),
item_fingerprint(&b, ItemKind::Agent, "code-reviewer").unwrap()
);
assert_eq!(
item_fingerprint(&a, ItemKind::Agent, "absent").unwrap(),
None
);
}
#[test]
fn plugin_fingerprint_changes_with_its_catalog_entry() {
let a = full_repo();
let b = full_repo().file(
"plugins/.claude-plugin/marketplace.json",
&PLUGIN_CATALOG.replace("Adds a skill", "Adds two skills"),
);
assert_ne!(
item_fingerprint(&a, ItemKind::Plugin, "example-plugin").unwrap(),
item_fingerprint(&b, ItemKind::Plugin, "example-plugin").unwrap()
);
}
#[test]
fn hook_settings_are_rendered_with_the_install_dir() {
let hooks = rendered_hook_settings(&full_repo(), "notify-on-stop").unwrap();
assert_eq!(
hooks["Stop"][0]["hooks"][0]["command"],
"/home/claude/.claude/triple-c/hooks/notify-on-stop/notify.sh"
);
assert_eq!(hook_dir("x"), "/home/claude/.claude/triple-c/hooks/x");
}
#[test]
fn plugin_catalog_entry_is_returned_verbatim() {
let entry = plugin_catalog_entry(&full_repo(), "example-plugin").unwrap();
assert_eq!(entry["description"], "Adds a skill");
assert!(plugin_catalog_entry(&full_repo(), "nope").is_err());
}
}
+4
View File
@@ -0,0 +1,4 @@
//! Marketplace support — see `docs/superpowers/specs/2026-09-27-marketplace-design.md`.
pub mod catalog;
pub mod tree;
+229
View File
@@ -0,0 +1,229 @@
//! A read-only view of a repository tree at one commit.
//!
//! The catalog parser only ever talks to [`TreeView`], so it is tested
//! against [`MemTree`] with no git involved, and runs in production against
//! [`GitTree`], which reads git objects straight out of the bare cache.
use std::collections::BTreeMap;
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
File,
Dir,
Symlink,
/// Anything else git can hold (submodule commits). Never installable.
Other,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirEntry {
pub name: String,
pub kind: EntryKind,
pub executable: bool,
}
pub trait TreeView {
/// Entries of the directory at `path` (`""` = root). `Ok(None)` if absent or not a dir.
fn list_dir(&self, path: &str) -> Result<Option<Vec<DirEntry>>, String>;
/// Contents of the regular file at `path`. `Ok(None)` if absent or not a file.
fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>, String>;
/// Stable content id of the entry at `path`; `None` if absent.
fn entry_id(&self, path: &str) -> Result<Option<String>, String>;
}
/// Hex-encode `bytes`. Shared by [`MemTree`]'s content id (test-only) and
/// `catalog::item_fingerprint`'s plugin-entry hash (production), so there is
/// one hex formatter rather than two copies of the same `format!("{:02x}")`.
pub(crate) fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
#[cfg(test)]
#[derive(Debug, Clone)]
enum MemNode {
File { data: Vec<u8>, executable: bool },
Symlink { target: String },
}
/// In-memory tree for tests: path → node. Directories are implied by paths.
#[cfg(test)]
#[derive(Debug, Clone, Default)]
pub struct MemTree {
nodes: BTreeMap<String, MemNode>,
}
#[cfg(test)]
impl MemTree {
pub fn new() -> Self {
Self::default()
}
pub fn file(mut self, path: &str, contents: &str) -> Self {
self.nodes.insert(
path.to_string(),
MemNode::File {
data: contents.as_bytes().to_vec(),
executable: false,
},
);
self
}
pub fn exec_file(mut self, path: &str, contents: &str) -> Self {
self.nodes.insert(
path.to_string(),
MemNode::File {
data: contents.as_bytes().to_vec(),
executable: true,
},
);
self
}
pub fn symlink(mut self, path: &str, target: &str) -> Self {
self.nodes.insert(
path.to_string(),
MemNode::Symlink {
target: target.to_string(),
},
);
self
}
fn is_dir(&self, path: &str) -> bool {
if path.is_empty() {
return true;
}
let prefix = format!("{}/", path);
self.nodes.keys().any(|k| k.starts_with(&prefix))
}
}
#[cfg(test)]
impl TreeView for MemTree {
fn list_dir(&self, path: &str) -> Result<Option<Vec<DirEntry>>, String> {
if self.nodes.contains_key(path) || !self.is_dir(path) {
return Ok(None);
}
let prefix = if path.is_empty() {
String::new()
} else {
format!("{}/", path)
};
let mut out: BTreeMap<String, DirEntry> = BTreeMap::new();
for (key, node) in &self.nodes {
let Some(rest) = key.strip_prefix(&prefix) else {
continue;
};
match rest.split_once('/') {
Some((dir, _)) => {
out.entry(dir.to_string()).or_insert(DirEntry {
name: dir.to_string(),
kind: EntryKind::Dir,
executable: false,
});
}
None => {
let (kind, executable) = match node {
MemNode::File { executable, .. } => (EntryKind::File, *executable),
MemNode::Symlink { .. } => (EntryKind::Symlink, false),
};
out.insert(
rest.to_string(),
DirEntry {
name: rest.to_string(),
kind,
executable,
},
);
}
}
}
Ok(Some(out.into_values().collect()))
}
fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>, String> {
match self.nodes.get(path) {
Some(MemNode::File { data, .. }) => Ok(Some(data.clone())),
_ => Ok(None),
}
}
fn entry_id(&self, path: &str) -> Result<Option<String>, String> {
let mut hasher = Sha256::new();
let mut found = false;
let prefix = format!("{}/", path);
for (key, node) in &self.nodes {
if key != path && !key.starts_with(&prefix) {
continue;
}
found = true;
hasher.update(key.as_bytes());
hasher.update([0]);
match node {
MemNode::File { data, executable } => {
hasher.update([if *executable { b'x' } else { b'f' }]);
hasher.update(data);
}
MemNode::Symlink { target } => {
hasher.update(b"l");
hasher.update(target.as_bytes());
}
}
hasher.update([0]);
}
Ok(found.then(|| hex(&hasher.finalize())))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mem_tree_lists_files_dirs_and_symlinks() {
let t = MemTree::new()
.file("agents/a.md", "x")
.exec_file("hooks/h/run.sh", "#!/bin/sh")
.symlink("agents/link.md", "a.md");
let root = t.list_dir("").unwrap().unwrap();
assert_eq!(
root.iter()
.map(|e| (e.name.as_str(), e.kind))
.collect::<Vec<_>>(),
vec![("agents", EntryKind::Dir), ("hooks", EntryKind::Dir)]
);
let agents = t.list_dir("agents").unwrap().unwrap();
assert_eq!(agents[1].kind, EntryKind::Symlink);
let hook = t.list_dir("hooks/h").unwrap().unwrap();
assert!(hook[0].executable);
assert_eq!(t.list_dir("agents/a.md").unwrap(), None);
assert_eq!(t.list_dir("missing").unwrap(), None);
assert_eq!(t.read_file("agents/a.md").unwrap().unwrap(), b"x");
assert_eq!(t.read_file("agents").unwrap(), None);
}
#[test]
fn mem_tree_entry_id_changes_only_with_content() {
let a = MemTree::new()
.file("skills/s/SKILL.md", "one")
.file("agents/x.md", "x");
let b = MemTree::new()
.file("skills/s/SKILL.md", "one")
.file("agents/x.md", "changed");
let c = MemTree::new()
.file("skills/s/SKILL.md", "two")
.file("agents/x.md", "x");
assert_eq!(
a.entry_id("skills/s").unwrap(),
b.entry_id("skills/s").unwrap()
);
assert_ne!(
a.entry_id("skills/s").unwrap(),
c.entry_id("skills/s").unwrap()
);
assert_eq!(a.entry_id("nope").unwrap(), None);
}
}