Marketplace: repo tree view and catalog parsing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ mod docker;
|
||||
pub mod file_viewer;
|
||||
mod install_helper;
|
||||
mod logging;
|
||||
mod marketplace;
|
||||
mod models;
|
||||
mod project_lock;
|
||||
mod storage;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Marketplace support — see `docs/superpowers/specs/2026-09-27-marketplace-design.md`.
|
||||
|
||||
pub mod catalog;
|
||||
pub mod tree;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user