From 2e62728b0606271a1088b3baa2370cb469335758 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 27 Sep 2026 08:48:31 -0700 Subject: [PATCH] Marketplace: repo tree view and catalog parsing Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/lib.rs | 1 + app/src-tauri/src/marketplace/catalog.rs | 947 +++++++++++++++++++++++ app/src-tauri/src/marketplace/mod.rs | 4 + app/src-tauri/src/marketplace/tree.rs | 229 ++++++ 4 files changed, 1181 insertions(+) create mode 100644 app/src-tauri/src/marketplace/catalog.rs create mode 100644 app/src-tauri/src/marketplace/mod.rs create mode 100644 app/src-tauri/src/marketplace/tree.rs diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 8957f8a..34aea21 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod docker; pub mod file_viewer; mod install_helper; mod logging; +mod marketplace; mod models; mod project_lock; mod storage; diff --git a/app/src-tauri/src/marketplace/catalog.rs b/app/src-tauri/src/marketplace/catalog.rs new file mode 100644 index 0000000..1624582 --- /dev/null +++ b/app/src-tauri/src/marketplace/catalog.rs @@ -0,0 +1,947 @@ +//! Reading a marketplace repo: which items it offers, and the files of one item. +//! +//! Layout (spec §1): `agents/.md`, `skills//SKILL.md`, +//! `commands/.md`, `hooks//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, + 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, 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 { + 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>, 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 { + 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 { + 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, + 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, 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, 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, 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, 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 { + 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 { + 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, +) { + 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) { + 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) { + 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::>() + .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 { + 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()); + } +} diff --git a/app/src-tauri/src/marketplace/mod.rs b/app/src-tauri/src/marketplace/mod.rs new file mode 100644 index 0000000..70ccdb8 --- /dev/null +++ b/app/src-tauri/src/marketplace/mod.rs @@ -0,0 +1,4 @@ +//! Marketplace support — see `docs/superpowers/specs/2026-09-27-marketplace-design.md`. + +pub mod catalog; +pub mod tree; diff --git a/app/src-tauri/src/marketplace/tree.rs b/app/src-tauri/src/marketplace/tree.rs new file mode 100644 index 0000000..d56dc02 --- /dev/null +++ b/app/src-tauri/src/marketplace/tree.rs @@ -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>, String>; + /// Contents of the regular file at `path`. `Ok(None)` if absent or not a file. + fn read_file(&self, path: &str) -> Result>, String>; + /// Stable content id of the entry at `path`; `None` if absent. + fn entry_id(&self, path: &str) -> Result, 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, 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, +} + +#[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>, 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 = 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>, String> { + match self.nodes.get(path) { + Some(MemNode::File { data, .. }) => Ok(Some(data.clone())), + _ => Ok(None), + } + } + + fn entry_id(&self, path: &str) -> Result, 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![("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); + } +}