From 723555bf1dcea5ad7481ac37b8f7a7c006f62231 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 27 Sep 2026 08:13:59 -0700 Subject: [PATCH] Plan: Triple-C marketplace; spec: ship sync script in the app, detect readiness without an entrypoint change Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-09-27-marketplace.md | 11012 ++++++++++++++++ .../specs/2026-09-27-marketplace-design.md | 26 +- 2 files changed, 11029 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-27-marketplace.md diff --git a/docs/superpowers/plans/2026-09-27-marketplace.md b/docs/superpowers/plans/2026-09-27-marketplace.md new file mode 100644 index 0000000..2de79e8 --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-marketplace.md @@ -0,0 +1,11012 @@ +# Triple-C Marketplace Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Users add git-repo marketplaces in Settings, browse agents/skills/commands/hooks/plugins, and install them for all projects or per project; installs are pinned and synced into containers on start. + +**Architecture:** The host fetches each marketplace into a bare `gix` cache and reads item files straight from git objects at pinned commits. On container start (and on "Apply now") the host builds one tar of the project's effective install set, uploads it, and runs a constant sync script (shipped inside the app and uploaded alongside the payload) that copies files, merges hook entries with `jq`, and drives `claude plugin`. Credentials live in the OS keychain (or are fetched live from host `gh`) and never enter containers. + +**Tech Stack:** Rust (Tauri 2, gix 0.88, bollard 0.18, keyring 3, reqwest 0.12, similar), React 19 + TypeScript + Zustand + Tailwind, Vitest, POSIX sh + jq in the container. + +**Spec:** `docs/superpowers/specs/2026-09-27-marketplace-design.md` — read it before starting any task. + +## Global Constraints + +- Branch: `feat/marketplace` in `/workspace/triple-c` (already contains the SharedAuthSettings `flex-wrap` fix `ece0d74` and the spec `a6b00e0`). +- Item kinds: exactly `agent`, `skill`, `command`, `hook`, `plugin` (serde `snake_case`). +- Item key pattern: `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`. +- Per-item limits: 2 MiB total, 200 files; any symlink in an item makes it invalid. +- Marketplace URLs: `https://` only. Plugins' catalog `source` must be a relative path inside `plugins/`. +- Hook placeholder: `${HOOK_DIR}` → `/home/claude/.claude/triple-c/hooks/`. +- Container paths: payload `/home/claude/.claude/triple-c/marketplace/incoming/`, state `/home/claude/.claude/triple-c/marketplace/state.json`, hooks `/home/claude/.claude/triple-c/hooks//`, plugin trees `/home/claude/.claude/triple-c/plugins//`, plugin marketplace name `triple-c-`. +- Readiness: poll `pgrep -x -f 'su -s /bin/bash claude -c exec sleep infinity'` (as root) every 2 s, up to 180 s. No marker file and **no changes to `container/`** — image/entrypoint changes never reach existing projects (CLAUDE.md). Plugin commands run under `flock /tmp/.triple-c-claude-update.lock` when `flock` exists. +- The sync script is `app/src-tauri/src/marketplace/sync.sh`, embedded with `include_str!` and uploaded next to `payload.tar` on every sync. +- Cache: `/triple-c/marketplaces/.git` (bare); fetched tip stored at `refs/triple-c/head`; pins at `refs/triple-c/pins/`. +- Keychain service per account: `triple-c-marketplace-account-`, account `secret` (existing `KEYCHAIN_ACCOUNT`). +- Refresh: on Marketplace tab open when last fetch > 15 min, on Refresh, once at app start. +- GitHub fetch username `x-access-token`; other hosts: account `username`, else `oauth2`. +- New Tauri command = `#[tauri::command]` + `generate_handler!` entry in `lib.rs` + `"allow-"` in `app/src-tauri/capabilities/default.json` + wrapper in `app/src/lib/tauri-commands.ts` (+ types in `app/src/lib/types.ts`). Only `lib/tauri-commands.ts` may import `@tauri-apps/api/core`. +- All new serde fields `#[serde(default)]`. No secrets in `settings.json`, `projects.json`, container labels, logs, events or test output. Test fixtures must not look like live tokens (the pre-commit secret scan rejects `ghp_…`, `gho_…` etc.) — use `test-token-not-real`. +- Errors are `Result`; UI copy says changes apply to **new** Claude sessions. +- Commit after every task; messages end with `Co-Authored-By: Claude Opus 5.5 `. +- Test commands: `cd app/src-tauri && cargo test --lib `; `cd app && npx vitest run `. Run `cargo fmt` only on files you touch (the repo is not globally rustfmt-clean): `rustfmt --edition 2021 `. +- Note for local runs in the dev container only: `SSL_CERT_FILE` is set to an empty string there, which breaks rustls cert loading for HTTPS tests; run with `env -u SSL_CERT_FILE`. Unit tests use `file://` fixtures and are unaffected. + +## Review Focus + +1. **A user already has an agent/skill/command file with the same name as a marketplace item** → the sync must skip it and report a conflict, never overwrite (Task 8 test `sync_skips_user_owned_agent`). +2. **User-authored hooks in `~/.claude/settings.json`** (and the entrypoint's managed-settings merge) → a sync, an update and an uninstall must leave them byte-identical in meaning (Task 8 test `sync_preserves_user_hooks`). +3. **Marketplace fetch fails (offline, 401/403/404)** → the last cache remains browsable and installs keep syncing from cached pins; the error names the account and org causes (Task 4 `fetch_error_mapping`, Task 6 `refresh_failure_keeps_snapshot`). +4. **Container not ready / sync script fails** → container start still succeeds; the report is stored and surfaced (Task 9 `sync_failure_does_not_fail_start`). +5. **Hostile repo content** (symlinks, `../` in plugin sources, oversized items, keys with shell metacharacters) → item marked invalid, never copied, never interpolated into a shell (Task 3 tests `rejects_symlink_items`, `rejects_escaping_plugin_source`, `rejects_bad_keys`, `enforces_item_limits`). + +--- + +## File Structure + +Backend (`app/src-tauri/`): + +| File | Responsibility | +|---|---| +| `Cargo.toml` | add `gix`, `similar`; dev-dep `tempfile` | +| `src/models/marketplace.rs` | serde types, key validation, slug, effective-set merge | +| `src/models/mod.rs`, `models/app_settings.rs`, `models/project.rs` | new fields | +| `src/marketplace/mod.rs` | module root; `MarketplaceManager` (in-memory snapshots, sync reports, paths) | +| `src/marketplace/tree.rs` | `TreeView` trait, `MemTree` (tests), `GitTree` (gix) | +| `src/marketplace/catalog.rs` | parse repo → `CatalogItem`s; item files; fingerprints; validation | +| `src/marketplace/git.rs` | gix fetch w/ credentials, head, pin refs, error mapping | +| `src/marketplace/auth.rs` | credential resolution (host gh / keychain), token validation (who-am-I) | +| `src/marketplace/gh_login.rs` | `gh auth login --web` inside a container (attached pty exec) | +| `src/marketplace/diff.rs` | per-item text diff between two commits | +| `src/marketplace/payload.rs` | build the tar for a project's effective set (+ generated plugin catalog, manifest) | +| `src/marketplace/sync.rs` | wait-ready, upload, run script, parse report, persist report | +| `src/storage/secure.rs` | marketplace account token helpers | +| `src/commands/marketplace_commands.rs` | all Tauri commands | +| `src/commands/project_commands.rs` | call sync after start | +| `src/lib.rs` | `mod marketplace;`, `AppState.marketplace`, handlers, startup refresh | +| `capabilities/default.json` | grants | +| `src/marketplace/sync.sh` | the constant sync script (embedded, uploaded each sync) | +| `src/marketplace/sync_script_tests.rs` | drives the real script against a temp HOME | + +Frontend (`app/src/`): + +| File | Responsibility | +|---|---| +| `lib/types.ts`, `lib/tauri-commands.ts` | mirrors + wrappers | +| `store/appState.ts` | `MARKETPLACE_TAB_KEY`, `openMarketplace`, `closeMarketplaceTab`, `marketplaceFilterProjectId` | +| `App.tsx`, `components/layout/MainTabs.tsx`, `hooks/useKeyboardShortcuts.ts`, `components/layout/NotesDock.tsx` | render/label/close the singleton tab | +| `hooks/useMarketplace.ts` | load/refresh snapshots, accounts, updates; mutations | +| `components/settings/MarketplaceSettings.tsx` | sidebar summary + Open Marketplace | +| `components/marketplace/MarketplaceView.tsx` | tab shell with Browse / Installed / Accounts | +| `components/marketplace/BrowsePane.tsx`, `ItemDetail.tsx`, `InstallControls.tsx`, `HookConfirmModal.tsx` | browse + install | +| `components/marketplace/AddMarketplaceModal.tsx` | add repo | +| `components/marketplace/InstalledPane.tsx`, `UpdateDiffModal.tsx` | installed list, updates, apply now | +| `components/marketplace/AccountsPane.tsx`, `AddAccountModal.tsx`, `GhContainerLoginModal.tsx` | accounts | +| `components/projects/home/config/MarketplaceSection.tsx` | per-project effective set + opt-out | +| `lib/marketplace.ts` | pure helpers: item state per project, grouping | + +Starter repo: `/workspace/projects/triple-c-marketplace` → `github.com/shadowdao/triple-c-marketplace` (public). + +## Interface Contract + +Every task uses these exact names. Rust first, TypeScript mirror after. + +### `src/models/marketplace.rs` + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ItemKind { Agent, Skill, Command, Hook, Plugin } + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountMethod { GhHost, GhContainer, Token } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MarketplaceAccount { + pub id: String, + pub label: String, + pub host: String, + pub method: AccountMethod, + #[serde(default)] + pub username: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Marketplace { + pub id: String, + pub name: String, + pub url: String, + #[serde(default)] + pub branch: Option, + #[serde(default)] + pub account_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct MarketplaceItemRef { + pub marketplace_id: String, + pub kind: ItemKind, + pub key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MarketplaceInstall { + pub marketplace_id: String, + pub kind: ItemKind, + pub key: String, + pub commit: String, +} + +impl MarketplaceInstall { + pub fn item_ref(&self) -> MarketplaceItemRef; +} + +/// `(global − disabled) ∪ project`; project wins on clash; sorted by item_ref. +pub fn effective_installs( + global: &[MarketplaceInstall], + disabled: &[MarketplaceItemRef], + project: &[MarketplaceInstall], +) -> Vec; + +pub fn is_valid_item_key(key: &str) -> bool; +/// lowercase, [a-z0-9-] only, collapsed dashes, ≤ 32 chars, then "-" + first 8 chars of id. +pub fn marketplace_slug(name: &str, id: &str) -> String; +pub fn is_valid_commit(commit: &str) -> bool; // 40 lowercase hex + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CatalogItem { + pub kind: ItemKind, + pub key: String, + pub name: String, + pub description: String, + /// repo-relative path of the item (file or folder) + pub path: String, + /// Some(reason) when the item cannot be installed + pub invalid: Option, + /// hooks only: rendered commands with ${HOOK_DIR} substituted + #[serde(default)] + pub hook_commands: Vec, + /// agents/commands/skills: the markdown body (≤ 64 KiB, truncated); plugins: component listing + #[serde(default)] + pub preview: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct MarketplaceSnapshot { + pub marketplace_id: String, + pub head_commit: Option, + /// RFC 3339 + pub fetched_at: Option, + pub fetch_error: Option, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ItemUpdate { + pub item: MarketplaceItemRef, + pub pinned: String, + pub head: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FileChange { Added, Removed, Modified } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileDiff { + pub path: String, + pub change: FileChange, + /// unified diff text; None when either side is binary + pub unified: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SkippedItem { pub item: String, pub reason: String } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SyncReport { + #[serde(default)] pub installed: Vec, + #[serde(default)] pub updated: Vec, + #[serde(default)] pub removed: Vec, + #[serde(default)] pub skipped: Vec, + #[serde(default)] pub errors: Vec, + /// RFC 3339, set by the host + #[serde(default)] pub finished_at: String, +} +``` + +Item strings in `SyncReport` are `":"` (e.g. `"agent:code-reviewer"`). + +New fields: `AppSettings { marketplace_accounts: Vec, marketplaces: Vec, global_marketplace_installs: Vec }`; `Project { marketplace_installs: Vec, marketplace_disabled: Vec }` — all `#[serde(default)]`, listed explicitly in `Default` impls / project constructors. + +### `src/marketplace/tree.rs` + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntryKind { File, Dir, Symlink, 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` (git object id hex); None if absent. + fn entry_id(&self, path: &str) -> Result, String>; +} + +/// In-memory tree for tests: path → (bytes, executable). Dirs are implied; symlinks via `add_symlink`. +pub struct MemTree { /* private */ } +impl MemTree { + pub fn new() -> Self; + pub fn file(self, path: &str, contents: &str) -> Self; + pub fn exec_file(self, path: &str, contents: &str) -> Self; + pub fn symlink(self, path: &str, target: &str) -> Self; +} +impl TreeView for MemTree { /* entry_id = sha256 hex of path-sorted contents */ } + +/// A tree at a commit in a bare gix repo. +pub struct GitTree { /* private: repo + tree id */ } +impl GitTree { + pub fn open(repo_path: &std::path::Path, commit: &str) -> Result; +} +impl TreeView for GitTree {} +``` + +### `src/marketplace/catalog.rs` + +```rust +pub const MAX_ITEM_BYTES: u64 = 2 * 1024 * 1024; +pub const MAX_ITEM_FILES: usize = 200; + +/// 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 } + +/// Parse every item in the repo. Never fails as a whole; broken items carry `invalid`. +pub fn parse_catalog(tree: &dyn TreeView) -> Vec; + +/// 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>; + +/// Content fingerprint used for update detection (changes iff the item's files or, +/// for plugins, its catalog entry change). +pub fn item_fingerprint(tree: &dyn TreeView, kind: ItemKind, key: &str) -> Result, String>; + +/// Plugins only: the plugin's entry from plugins/.claude-plugin/marketplace.json. +pub fn plugin_catalog_entry(tree: &dyn TreeView, key: &str) -> Result; + +/// Hooks only: parsed hook.json `hooks` object with ${HOOK_DIR} substituted. +pub fn rendered_hook_settings(tree: &dyn TreeView, key: &str) -> Result; + +pub fn hook_dir(key: &str) -> String; // "/home/claude/.claude/triple-c/hooks/" +``` + +### `src/marketplace/git.rs` (blocking; call from `tokio::task::spawn_blocking`) + +```rust +#[derive(Clone)] +pub struct Credential { pub username: String, pub password: String } +impl std::fmt::Debug for Credential { /* prints username and "" */ } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FetchError { + Auth { status: u16 }, // 401 / 403 or "credentials … not accepted" + NotFound, // 404 / "repository not found" + Network(String), + Other(String), +} +impl std::fmt::Display for FetchError {} + +pub fn cache_path(data_root: &std::path::Path, marketplace_id: &str) -> std::path::PathBuf; +/// Init the bare repo if missing, fetch branch (or remote HEAD) into refs/triple-c/head, return head commit hex. +pub fn fetch(repo_path: &std::path::Path, url: &str, branch: Option<&str>, cred: Option) -> Result; +/// Current refs/triple-c/head, if fetched before. +pub fn cached_head(repo_path: &std::path::Path) -> Result, String>; +/// Make refs/triple-c/pins/* exactly the given set. +pub fn set_pins(repo_path: &std::path::Path, commits: &[String]) -> Result<(), String>; +pub fn has_commit(repo_path: &std::path::Path, commit: &str) -> bool; +``` + +### `src/marketplace/auth.rs` + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostKind { GitHub, Gitea, GitLab, Unknown } +pub fn host_kind(host: &str) -> HostKind; // github.com → GitHub, gitlab.com → GitLab, else Unknown (Gitea detected by probe) +pub fn host_of(url: &str) -> Result; // https only +pub fn fetch_username(account: &MarketplaceAccount) -> String; // x-access-token for GitHub, else username or "oauth2" +/// Resolve the credential for an account: GhHost → `gh auth token --hostname `; others → keychain. +pub async fn resolve_credential(account: &MarketplaceAccount) -> Result; +/// "Who am I" check; returns the login name. +pub async fn validate_token(host: &str, token: &str) -> Result; +pub async fn gh_host_available() -> bool; +pub async fn gh_host_login(host: &str) -> Result; // `gh api user --jq .login` when logged in; Err with instructions otherwise +/// User-facing message for a FetchError, naming the account and org causes. +pub fn describe_fetch_error(err: &crate::marketplace::git::FetchError, account: Option<&MarketplaceAccount>, url: &str) -> String; +``` + +`storage/secure.rs` additions: + +```rust +pub fn store_marketplace_token(account_id: &str, token: &str) -> Result<(), String>; +pub fn get_marketplace_token(account_id: &str) -> Result, String>; +pub fn delete_marketplace_token(account_id: &str) -> Result<(), String>; +``` + +### `src/marketplace/gh_login.rs` + +Events (payload always has `account_id`): +- `marketplace-gh-login-code` → `{ account_id, code, url }` +- `marketplace-gh-login-output` → `{ account_id, chunk }` (redacted, ANSI-stripped) + +```rust +/// Runs `gh auth login` in the container with a temp GH_CONFIG_DIR, returns the token. +pub async fn run_gh_container_login(app: &tauri::AppHandle, account_id: &str, container_id: &str, host: &str, cancel: tokio::sync::oneshot::Receiver<()>) -> Result; +/// Parse gh's "First copy your one-time code: XXXX-XXXX" and URL out of accumulated output. +pub fn parse_device_prompt(output: &str) -> Option<(String, String)>; +``` + +### `src/marketplace/diff.rs` + +```rust +pub fn item_diff(repo_path: &std::path::Path, kind: ItemKind, key: &str, from_commit: &str, to_commit: &str) -> Result, String>; +``` + +### `src/marketplace/payload.rs` + +```rust +pub struct PayloadInput<'a> { + pub installs: &'a [MarketplaceInstall], + pub marketplaces: &'a [Marketplace], + /// data root used to find caches (see git::cache_path) + pub data_root: &'a std::path::Path, +} +pub struct Payload { pub tar: Vec, pub manifest: serde_json::Value, pub skipped: Vec } +/// Build the tar described in spec §4. Items whose marketplace/cache/commit is missing go to `skipped`. +pub fn build_payload(input: &PayloadInput) -> Result; +``` + +Manifest shape (`manifest.json` at the tar root): + +```json +{ + "version": 1, + "items": [ + { "kind": "agent", "key": "code-reviewer", "marketplace": "", "commit": "", "file": "agents/code-reviewer.md" }, + { "kind": "skill", "key": "example-skill", "marketplace": "", "commit": "", "dir": "skills/example-skill" }, + { "kind": "command", "key": "example-command", "marketplace": "", "commit": "", "file": "commands/example-command.md" }, + { "kind": "hook", "key": "notify-on-stop", "marketplace": "", "commit": "", "dir": "hooks/notify-on-stop", "settings": { "Stop": [ ... ] } }, + { "kind": "plugin", "key": "example-plugin", "marketplace": "", "commit": "", "slug": "" } + ], + "plugin_marketplaces": [ { "slug": "", "dir": "plugins/", "plugins": ["example-plugin"] } ] +} +``` + +Tar layout: `agents/.md`, `skills//…`, `commands/.md`, `hooks//…`, `plugins//.claude-plugin/marketplace.json` (generated: `{"name":"triple-c-","owner":{"name":"Triple-C"},"plugins":[">]}`), `plugins///…`, `manifest.json`. Modes: 0644, or 0755 when executable; dirs 0755. + +### `src/marketplace/sync.rs` + +```rust +pub const INCOMING_DIR: &str = "/home/claude/.claude/triple-c/marketplace/incoming"; +pub const SYNC_SCRIPT: &str = include_str!("sync.sh"); +/// Wait for readiness (pgrep, see Global Constraints), upload payload.tar + sync.sh, run `sh sync.sh` as claude, parse its JSON report. +pub async fn sync_container(container_id: &str, payload: &Payload) -> Result; +/// Parse the script's stdout (last line is the JSON report). +pub fn parse_report(stdout: &str) -> Result; +``` + +### `src/marketplace/mod.rs` + +```rust +pub mod auth; pub mod catalog; pub mod diff; pub mod gh_login; pub mod git; pub mod payload; pub mod sync; pub mod tree; +#[cfg(test)] mod sync_script_tests; + +pub struct MarketplaceManager { + // private: data_root, snapshots: Mutex>, reports: Mutex>, gh_login_cancel: tokio::sync::Mutex>> +} +impl MarketplaceManager { + pub fn new(data_root: std::path::PathBuf) -> Self; // data_root = /triple-c + pub fn data_root(&self) -> &std::path::Path; + pub fn snapshot(&self, marketplace_id: &str) -> Option; + pub fn put_snapshot(&self, snap: MarketplaceSnapshot); + pub fn remove_snapshot(&self, marketplace_id: &str); + /// Reports are also persisted to /marketplace-sync/.json + pub fn report(&self, project_id: &str) -> Option; + pub fn put_report(&self, project_id: &str, report: SyncReport); + pub async fn set_gh_login_cancel(&self, tx: Option>) -> bool; // false if one already running + pub async fn cancel_gh_login(&self); +} + +/// Refresh one marketplace: resolve credential, fetch (blocking task), parse catalog at head, store snapshot. +/// On fetch failure keep the previous items and head, set fetch_error. +pub async fn refresh_marketplace(mgr: &MarketplaceManager, settings: &crate::models::AppSettings, marketplace_id: &str) -> MarketplaceSnapshot; +/// Load snapshot from the cache without network (used at startup and after install when no snapshot is in memory). +pub fn load_cached_snapshot(mgr: &MarketplaceManager, marketplace: &Marketplace) -> MarketplaceSnapshot; +/// Every install (global + all projects) whose item fingerprint at head differs from its pin. +pub fn compute_updates(mgr: &MarketplaceManager, settings: &crate::models::AppSettings, projects: &[crate::models::Project]) -> Vec; +/// All commits referenced by installs, per marketplace (for git::set_pins). +pub fn pins_by_marketplace(settings: &crate::models::AppSettings, projects: &[crate::models::Project]) -> std::collections::HashMap>; +/// Build payload for a project and sync it into its running container; stores the report. +pub async fn sync_project(mgr: &MarketplaceManager, settings: &crate::models::AppSettings, project: &crate::models::Project, container_id: &str) -> SyncReport; +``` + +`AppState` gains `pub marketplace: Arc`. + +### Tauri commands (`src/commands/marketplace_commands.rs`) + +| Command | Args (Rust) | Returns | +|---|---|---| +| `list_marketplace_snapshots` | – | `Vec` (one per configured marketplace; cached or empty) | +| `refresh_marketplaces` | `marketplace_id: Option` | `Vec` | +| `add_marketplace` | `name: String, url: String, branch: Option, account_id: Option` | `MarketplaceSnapshot` (test fetch first; nothing saved on failure) | +| `update_marketplace` | `marketplace: Marketplace` | `AppSettings` | +| `remove_marketplace` | `marketplace_id: String` | `AppSettings` | +| `install_marketplace_item` | `item: MarketplaceItemRef, scope: InstallScope` | `AppSettings` (global) — frontend reloads projects for project scope | +| `uninstall_marketplace_item` | `item: MarketplaceItemRef, scope: InstallScope` | `()` | +| `set_global_item_disabled` | `project_id: String, item: MarketplaceItemRef, disabled: bool` | `Project` | +| `forget_marketplace_installs` | `marketplace_id: String` | `()` (drops installs of a removed marketplace everywhere) | +| `list_marketplace_updates` | – | `Vec` | +| `marketplace_item_diff` | `item: MarketplaceItemRef, from_commit: String, to_commit: String` | `Vec` | +| `update_marketplace_item` | `item: MarketplaceItemRef, scope: InstallScope` | `()` (moves that install's pin to head) | +| `apply_marketplace_now` | `project_id: Option` | `Vec` (all running projects when None) | +| `get_marketplace_sync_report` | `project_id: String` | `Option` | +| `add_marketplace_token_account` | `label: String, host: String, token: String` | `MarketplaceAccount` | +| `add_marketplace_gh_host_account` | `label: String, host: String` | `MarketplaceAccount` | +| `start_marketplace_gh_container_login` | `label: String, host: String, project_id: String` | `MarketplaceAccount` (long-running; emits events) | +| `cancel_marketplace_gh_login` | – | `()` | +| `test_marketplace_account` | `account_id: String` | `String` (login name) | +| `remove_marketplace_account` | `account_id: String` | `AppSettings` | +| `marketplace_gh_host_available` | – | `bool` | + +```rust +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum InstallScope { Global, Project { project_id: String } } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectSyncResult { pub project_id: String, pub report: SyncReport } +``` + +(`InstallScope` and `ProjectSyncResult` live in `models/marketplace.rs`.) + +### TypeScript mirror (`app/src/lib/types.ts`) + +```ts +export type ItemKind = "agent" | "skill" | "command" | "hook" | "plugin"; +export type AccountMethod = "gh_host" | "gh_container" | "token"; +export interface MarketplaceAccount { id: string; label: string; host: string; method: AccountMethod; username: string | null; } +export interface Marketplace { id: string; name: string; url: string; branch: string | null; account_id: string | null; } +export interface MarketplaceItemRef { marketplace_id: string; kind: ItemKind; key: string; } +export interface MarketplaceInstall extends MarketplaceItemRef { commit: string; } +export interface CatalogItem { kind: ItemKind; key: string; name: string; description: string; path: string; invalid: string | null; hook_commands: string[]; preview: string; } +export interface MarketplaceSnapshot { marketplace_id: string; head_commit: string | null; fetched_at: string | null; fetch_error: string | null; items: CatalogItem[]; } +export interface ItemUpdate { item: MarketplaceItemRef; pinned: string; head: string; } +export type FileChange = "added" | "removed" | "modified"; +export interface FileDiff { path: string; change: FileChange; unified: string | null; } +export interface SkippedItem { item: string; reason: string; } +export interface SyncReport { installed: string[]; updated: string[]; removed: string[]; skipped: SkippedItem[]; errors: string[]; finished_at: string; } +export type InstallScope = { type: "global" } | { type: "project"; project_id: string }; +export interface ProjectSyncResult { project_id: string; report: SyncReport; } +// AppSettings += marketplace_accounts: MarketplaceAccount[]; marketplaces: Marketplace[]; global_marketplace_installs: MarketplaceInstall[]; +// Project += marketplace_installs: MarketplaceInstall[]; marketplace_disabled: MarketplaceItemRef[]; +``` + +Wrappers in `lib/tauri-commands.ts` (camelCase args): `listMarketplaceSnapshots()`, `refreshMarketplaces(marketplaceId?: string)`, `addMarketplace(name, url, branch: string | null, accountId: string | null)`, `updateMarketplace(marketplace)`, `removeMarketplace(marketplaceId)`, `installMarketplaceItem(item, scope)`, `uninstallMarketplaceItem(item, scope)`, `setGlobalItemDisabled(projectId, item, disabled)`, `forgetMarketplaceInstalls(marketplaceId)`, `listMarketplaceUpdates()`, `marketplaceItemDiff(item, fromCommit, toCommit)`, `updateMarketplaceItem(item, scope)`, `applyMarketplaceNow(projectId?: string)`, `getMarketplaceSyncReport(projectId)`, `addMarketplaceTokenAccount(label, host, token)`, `addMarketplaceGhHostAccount(label, host)`, `startMarketplaceGhContainerLogin(label, host, projectId)`, `cancelMarketplaceGhLogin()`, `testMarketplaceAccount(accountId)`, `removeMarketplaceAccount(accountId)`, `marketplaceGhHostAvailable()`. + +### `app/src/lib/marketplace.ts` + +```ts +export type ProjectItemState = "none" | "inherited" | "opted_out" | "project" | "project_pinned_differently"; +export const itemRefKey = (r: MarketplaceItemRef) => `${r.marketplace_id}/${r.kind}/${r.key}`; +export function projectItemState(item: MarketplaceItemRef, globalInstalls: MarketplaceInstall[], project: Project): ProjectItemState; +export function effectiveInstalls(globalInstalls: MarketplaceInstall[], project: Project): (MarketplaceInstall & { source: "global" | "project" })[]; +export const KIND_LABELS: Record; // Agents, Skills, Commands, Hooks, Plugins +``` + +### Store (`app/src/store/appState.ts`) + +```ts +export const MARKETPLACE_TAB_KEY = "marketplace"; +export const isMarketplaceTab = (key: string) => key === MARKETPLACE_TAB_KEY; +// state + actions +marketplaceFilterProjectId: string | null; +openMarketplace: (filterProjectId?: string | null) => void; +closeMarketplaceTab: () => void; +``` + +--- + +## Tasks + +### Contract amendments (these override the Interface Contract above where they differ) + +From Tasks 2–5: + +1. `auth::validate_token(host, token)` returns `Result, String>` (not `Result`). `Ok(Some(login))` = host confirmed the token; `Ok(None)` = host is not GitHub/Gitea/GitLab, so the token is unchecked and the marketplace's test fetch proves it. Tasks 11/15 must handle `None` (store the account with `username: None`). +2. Additions (no renames): `ItemKind::as_str()` ("agent"…"plugin", for report strings and the manifest); `git::HEAD_REF`, `git::PIN_PREFIX`, `pub fn git::classify_fetch_error(&str) -> FetchError`; test-only fixtures `git::test_support::{git_available, git, init_repo, commit_files, file_url}` (`#[cfg(test)] pub(crate)`) that later tasks' tests (Task 6 refresh, Task 7 payload, Task 11 diff) should reuse. +3. `GitTree` lives in `tree.rs` as the contract says, but is added in Task 4 together with the `gix` dependency; Task 3's `tree.rs` has `TreeView` + `MemTree` only. +4. `models/mod.rs` gets `pub mod marketplace; pub use marketplace::*;` (checked: no name clashes with existing models). +5. Until Task 11 wires the new modules into commands, `cargo build` prints dead-code warnings for them. That is expected; do not add `allow` attributes. + +From Tasks 6–11: + +1. **Sync script is shipped by the app, not the image** (lead correction). `container/` is not touched. The script is `app/src-tauri/src/marketplace/sync.sh`, embedded as `pub const SYNC_SCRIPT: &str = include_str!("sync.sh");` in `sync.rs`, uploaded as `/sync.sh` (mode 0755) next to `payload.tar` and run as `sh /sync.sh`. `SYNC_SCRIPT_PATH` and `READY_MARKER` are dropped. Readiness = polling (every 2 s, ≤ 180 s, as root) `pgrep -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null`. +2. The script honours two env overrides used only by tests: `MARKETPLACE_INCOMING` (default `$HOME/.claude/triple-c/marketplace/incoming`) and `MARKETPLACE_LOCK` (default `/tmp/.triple-c-claude-update.lock`). +3. `gh_login::parse_device_prompt(output: &str, host: &str) -> Option<(String, String)>` takes the host: gh prints "Press Enter to open github.com in your browser", not a URL, so the URL falls back to `https:///login/device`. New pure helpers `gh_login::extract_token(text) -> Option` and `gh_login::take_display_lines(pending: &mut String, chunk: &str) -> String`. +4. `gh auth login` runs with `--git-protocol ssh --skip-ssh-key` and `GIT_CONFIG_GLOBAL` inside the temp dir: with `https` gh asks "Authenticate Git with your GitHub credentials?" and would write a credential helper into `~/.gitconfig`. The host reaches the script as `$1` (argv), because `create_attached_exec_as` has no env parameter. +5. New in `sync.rs`: `pub fn report_from_result(r: Result) -> SyncReport`. New in `marketplace/mod.rs`: `pub const SYNC_FINISHED_EVENT: &str = "marketplace-sync-finished";` (payload `{ project_id, report }`), `pub fn should_sync(mgr, settings, project) -> bool`, `pub fn spawn_project_sync(app: tauri::AppHandle, mgr: Arc, settings: AppSettings, project: Project, container_id: String)`, `pub fn head_for(mgr: &MarketplaceManager, m: &Marketplace) -> Option`. +6. Container-start sync runs **in the background** (spawned), because it waits for the entrypoint (which may spend up to 120 s in `claude update`); the start command never waits on it. +7. `AppState.marketplace` is wired in **Task 6** (Task 9's start hook needs it); Task 11 only adds handlers and the startup refresh. +8. `#[cfg(test)] pub(crate) mod test_support;` with `GitFixture` is added in Task 6. If Task 4 already created an equivalent helper, keep one and adapt call sites. +9. Marketplace fields are **store-owned**: `update_settings` restores `marketplace_accounts`, `marketplaces`, `global_marketplace_installs` from the stored settings, and `update_project` restores `marketplace_installs`, `marketplace_disabled` (a stale frontend copy must not undo an install). `apply_settings_import` writes the imported marketplace fields explicitly after its `update_settings` call. +10. `remove_marketplace_account` refuses while a marketplace uses the account. `apply_marketplace_now(Some(id))` errors when that project is not running. + +From Tasks 1, 12–17: + +1. New backend event `marketplace-sync-finished`, payload `{ project_id: string, report: SyncReport }`, emitted by the backend after **every** container sync (container start path in Task 9 and `apply_marketplace_now` in Task 11). The frontend (Task 12 `useMarketplaceSyncToasts`) toasts errors/skips from it. Tasks 9/11 must emit it via `app_handle.emit("marketplace-sync-finished", serde_json::json!({ "project_id": id, "report": report }))`. +2. `lib/marketplace.ts` gains `isStale(snapshot: MarketplaceSnapshot, now: number): boolean` (null `fetched_at` or older than 15 min) and `formatItemRef(r: MarketplaceItemRef): string` (`":"`, same format as `SyncReport` item strings). Both are frontend-only. +3. GhContainerLoginModal cannot know the new account id before `startMarketplaceGhContainerLogin` resolves, so it accepts **every** `marketplace-gh-login-code` / `marketplace-gh-login-output` event while it is open. This is safe because the backend single-flights the login (`MarketplaceManager::set_gh_login_cancel` returns false when one is running). The `account_id` field is still in the payload but is not used for filtering. + +--- + +### Task 1: Starter marketplace repo + +Creates `/workspace/projects/triple-c-marketplace`, a standalone git repo in the format from the spec, and publishes it as the public `github.com/shadowdao/triple-c-marketplace`. It is independent of the app code and doubles as the end-to-end fixture in Task 17. + +**Files:** +- Create: `/workspace/projects/triple-c-marketplace/README.md` +- Create: `/workspace/projects/triple-c-marketplace/agents/code-reviewer.md` +- Create: `/workspace/projects/triple-c-marketplace/skills/example-skill/SKILL.md` +- Create: `/workspace/projects/triple-c-marketplace/commands/example-command.md` +- Create: `/workspace/projects/triple-c-marketplace/hooks/notify-on-stop/hook.json` +- Create: `/workspace/projects/triple-c-marketplace/hooks/notify-on-stop/notify.sh` +- Create: `/workspace/projects/triple-c-marketplace/plugins/.claude-plugin/marketplace.json` +- Create: `/workspace/projects/triple-c-marketplace/plugins/example-plugin/.claude-plugin/plugin.json` +- Create: `/workspace/projects/triple-c-marketplace/plugins/example-plugin/skills/hello/SKILL.md` + +**Interfaces:** +- Consumes: nothing. +- Produces: a public repo at `https://github.com/shadowdao/triple-c-marketplace.git` (default branch `main`) containing exactly one valid item per kind: `agent:code-reviewer`, `skill:example-skill`, `command:example-command`, `hook:notify-on-stop`, `plugin:example-plugin`. + +- [ ] **Step 1: Create the folder and README** + +```bash +mkdir -p /workspace/projects/triple-c-marketplace/{agents,skills/example-skill,commands,hooks/notify-on-stop,plugins/.claude-plugin,plugins/example-plugin/.claude-plugin,plugins/example-plugin/skills/hello} +``` + +`/workspace/projects/triple-c-marketplace/README.md`: + +````markdown +# Triple-C Marketplace + +A marketplace of Claude Code agents, skills, commands, hooks and plugins for +[Triple-C](https://repo.anhonesthost.net/CyberCoveLLC/Triple-C). Add this repo in +Triple-C under **Settings → Marketplace → Open Marketplace → Add**, then install +items for all projects or for individual projects. + +The `plugins/` folder is also a standard Claude Code marketplace, so it works +without Triple-C: + +``` +/plugin marketplace add shadowdao/triple-c-marketplace/plugins +``` + +## Layout + +``` +agents/.md Claude Code agent (front matter: name, description) +skills//SKILL.md (+ files) Claude Code skill folder +commands/.md Claude Code slash command +hooks//hook.json (+ scripts) Triple-C hook manifest +plugins/.claude-plugin/marketplace.json +plugins//… Claude Code plugins +``` + +Every folder is optional. + +## Item rules + +- **Names** (file stem, folder name, plugin name) must match + `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`. +- **No symlinks** anywhere in an item. +- **Limits:** 2 MiB and 200 files per item. +- **Agents** are installed to `~/.claude/agents/.md`. `name` and + `description` come from the YAML front matter. +- **Skills** are installed to `~/.claude/skills//`. The folder must contain `SKILL.md`. +- **Commands** are installed to `~/.claude/commands/.md`. The description + comes from front matter `description`, or the first non-empty line. +- **Hooks** are a folder with a `hook.json`: + + ```json + { + "name": "notify-on-stop", + "description": "Rings the terminal bell when Claude finishes", + "hooks": { + "Stop": [{ "hooks": [{ "type": "command", "command": "${HOOK_DIR}/notify.sh" }] }] + } + } + ``` + + `hooks` uses Claude Code's `settings.json` hooks format verbatim. + `${HOOK_DIR}` is replaced with the folder the hook is installed to + (`~/.claude/triple-c/hooks/`). The whole folder is copied, and files keep + their executable bit. Triple-C shows every command a hook runs before it is installed. +- **Plugins** are the entries of `plugins/.claude-plugin/marketplace.json`. + Each `source` must be a relative path inside `plugins/` (for example + `"./example-plugin"`). Remote sources are not installable through Triple-C, + because they would bypass pinning. + +## Versioning + +Triple-C pins every install to the commit it was installed from. Pushing to this +repo never changes a container by itself: Triple-C shows **update available** +for the items whose files changed, and the user reviews a diff before accepting. +```` + +- [ ] **Step 2: Write the agent, skill and command** + +`agents/code-reviewer.md`: + +```markdown +--- +name: code-reviewer +description: Reviews the current diff for correctness bugs, risky changes and missing tests. Use after finishing a change and before committing. +tools: Read, Grep, Glob, Bash +--- + +You are a careful code reviewer. Review the uncommitted changes in this repository. + +1. Run `git diff` (and `git diff --staged`) to see what changed. +2. For every changed file, read enough surrounding code to understand the change. +3. Report only real problems, most severe first: + - correctness bugs (wrong logic, unhandled errors, off-by-one, races) + - security issues (injection, secrets in code, unsafe input handling) + - behaviour changes without tests +4. For each finding give the file and line, what goes wrong, and a concrete fix. + +If you find nothing worth fixing, say so in one line. Do not restate the diff. +``` + +`skills/example-skill/SKILL.md`: + +```markdown +--- +name: example-skill +description: Summarises the repository's recent git history. Use when the user asks what changed recently or wants a changelog draft. +--- + +# Recent changes summary + +1. Run `git log --oneline -20`. +2. Group the commits by theme (features, fixes, chores). +3. Write a short bulleted summary per group, newest first. +4. Mention any commit that looks like a revert or a hotfix. +``` + +`commands/example-command.md`: + +```markdown +--- +description: Show the files changed on this branch compared with main +--- + +Run `git diff --stat main...HEAD` and summarise which areas of the codebase this +branch touches, in three bullets or fewer. +``` + +- [ ] **Step 3: Write the hook** + +`hooks/notify-on-stop/hook.json`: + +```json +{ + "name": "notify-on-stop", + "description": "Rings the terminal bell and prints a line when Claude finishes a turn", + "hooks": { + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "${HOOK_DIR}/notify.sh" } + ] + } + ] + } +} +``` + +`hooks/notify-on-stop/notify.sh`: + +```sh +#!/bin/sh +# Stop hook: ring the terminal bell and leave a line on stderr. +# Portable on purpose: no desktop notification tools exist in the container. +printf '\a' >&2 +printf 'Claude finished at %s\n' "$(date '+%H:%M:%S')" >&2 +exit 0 +``` + +```bash +chmod +x /workspace/projects/triple-c-marketplace/hooks/notify-on-stop/notify.sh +``` + +- [ ] **Step 4: Write the plugin marketplace and plugin** + +`plugins/.claude-plugin/marketplace.json`: + +```json +{ + "name": "triple-c-marketplace", + "owner": { "name": "shadowdao" }, + "plugins": [ + { + "name": "example-plugin", + "source": "./example-plugin", + "description": "A single-skill example plugin that greets the user" + } + ] +} +``` + +`plugins/example-plugin/.claude-plugin/plugin.json`: + +```json +{ + "name": "example-plugin", + "version": "0.1.0", + "description": "A single-skill example plugin that greets the user", + "author": { "name": "shadowdao" } +} +``` + +`plugins/example-plugin/skills/hello/SKILL.md`: + +```markdown +--- +name: hello +description: Greets the user and lists the plugin's capabilities. Use when the user says hello to the example plugin. +--- + +Greet the user by name if you know it, then say that this skill comes from the +`example-plugin` plugin in the Triple-C starter marketplace. +``` + +- [ ] **Step 5: Validate the plugin marketplace and plugin** + +Run: +```bash +cd /workspace/projects/triple-c-marketplace && claude plugin validate plugins && claude plugin validate plugins/example-plugin +``` +Expected: both report the manifest as valid (exit status 0). If either reports an error, fix the named field and re-run before continuing. + +- [ ] **Step 6: Check the item-rule constraints locally** + +Run: +```bash +cd /workspace/projects/triple-c-marketplace && find . -path ./.git -prune -o -type l -print | wc -l && ls agents commands | grep -Ev '^(agents:|commands:|)$' | grep -Evc '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}\.md$'; python3 -c "import json;json.load(open('hooks/notify-on-stop/hook.json'));json.load(open('plugins/.claude-plugin/marketplace.json'));print('json ok')" +``` +Expected: `0` (no symlinks), `0` (no badly named files), `json ok`. + +- [ ] **Step 7: Initialise git and commit** + +```bash +cd /workspace/projects/triple-c-marketplace && git init -q -b main && git add -A && git commit -qm "Starter marketplace: one example agent, skill, command, hook and plugin + +Co-Authored-By: Claude Opus 5.5 " && git log --oneline -1 +``` +Expected: one commit hash printed. + +- [ ] **Step 8: Publish to GitHub (public)** + +```bash +cd /workspace/projects/triple-c-marketplace && gh repo create shadowdao/triple-c-marketplace --public --source . --push --description "Agents, skills, commands, hooks and plugins for Triple-C" +``` +Expected: `✓ Created repository shadowdao/triple-c-marketplace on GitHub` and the push succeeds. Verify without printing credentials: +```bash +gh repo view shadowdao/triple-c-marketplace --json visibility,defaultBranchRef -q '.visibility + " " + .defaultBranchRef.name' +``` +Expected: `PUBLIC main`. + +--- + +--- + +### Task 2: Marketplace data model + +**Files:** +- Create: `app/src-tauri/src/models/marketplace.rs` +- Modify: `app/src-tauri/src/models/mod.rs` (add module + re-export) +- Modify: `app/src-tauri/src/models/app_settings.rs` (three fields + `Default` impl) +- Modify: `app/src-tauri/src/models/project.rs` (two fields + `Project::new`) +- Modify: `app/src/lib/types.ts` (TS mirror + `AppSettings`/`Project` fields) +- Modify (fixtures that build a full `Project` literal): `app/src/components/projects/home/BrowserTab.test.tsx`, `app/src/components/projects/home/config/RuntimeSection.test.tsx`, `app/src/components/settings/SharedAuthSettings.test.tsx`, `app/src/components/projects/home/TaskEditorModal.test.tsx`, `app/src/components/projects/ProjectRow.test.tsx`, `app/src/components/projects/PermissionModeControl.test.tsx`, `app/src/components/projects/home/config/ModelSection.test.tsx`, `app/src/components/projects/home/config/WorkspaceSection.test.tsx` +- Test: `app/src-tauri/src/models/marketplace.rs` (`#[cfg(test)] mod tests`) + +**Interfaces:** +- Consumes: nothing new. +- Produces: every type and function in the Interface Contract section `src/models/marketplace.rs` (exact names), plus `ItemKind::as_str(&self) -> &'static str`. New fields `AppSettings.marketplace_accounts: Vec`, `AppSettings.marketplaces: Vec`, `AppSettings.global_marketplace_installs: Vec`, `Project.marketplace_installs: Vec`, `Project.marketplace_disabled: Vec`. Reachable as `crate::models::X` (glob re-export). TS: all types in the contract's TypeScript mirror. + +- [ ] **Step 1: Write the failing tests** + +Create `app/src-tauri/src/models/marketplace.rs` containing only this tests module for now: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn install(market: &str, kind: ItemKind, key: &str, commit: &str) -> MarketplaceInstall { + MarketplaceInstall { + marketplace_id: market.to_string(), + kind, + key: key.to_string(), + commit: commit.to_string(), + } + } + + #[test] + fn effective_set_is_global_minus_disabled_plus_project() { + let global = vec![ + install("m1", ItemKind::Agent, "reviewer", "a"), + install("m1", ItemKind::Hook, "notify", "a"), + ]; + let disabled = vec![MarketplaceItemRef { + marketplace_id: "m1".into(), + kind: ItemKind::Hook, + key: "notify".into(), + }]; + let project = vec![install("m2", ItemKind::Skill, "tidy", "b")]; + + let got = effective_installs(&global, &disabled, &project); + + assert_eq!( + got, + vec![ + install("m1", ItemKind::Agent, "reviewer", "a"), + install("m2", ItemKind::Skill, "tidy", "b"), + ] + ); + } + + #[test] + fn project_pin_wins_over_global_pin() { + let global = vec![install("m1", ItemKind::Agent, "reviewer", "old")]; + let project = vec![install("m1", ItemKind::Agent, "reviewer", "new")]; + let got = effective_installs(&global, &[], &project); + assert_eq!(got, vec![install("m1", ItemKind::Agent, "reviewer", "new")]); + } + + #[test] + fn same_key_different_kind_are_different_items() { + let global = vec![ + install("m1", ItemKind::Agent, "x", "a"), + install("m1", ItemKind::Command, "x", "a"), + ]; + assert_eq!(effective_installs(&global, &[], &[]).len(), 2); + } + + #[test] + fn item_keys_follow_the_pattern() { + for ok in ["a", "code-reviewer", "A.b_c-9", &"x".repeat(64)] { + assert!(is_valid_item_key(ok), "{ok} should be valid"); + } + for bad in [ + "", ".hidden", "-flag", "_x", "a/b", "a b", "a;rm", "$(x)", "ä", "..", &"x".repeat(65), + ] { + assert!(!is_valid_item_key(bad), "{bad:?} should be invalid"); + } + } + + #[test] + fn slug_is_sanitised_and_suffixed_with_the_id() { + assert_eq!( + marketplace_slug("Triple-C Marketplace!", "1A2B3C4D-ffff"), + "triple-c-marketplace-1a2b3c4d" + ); + assert_eq!(marketplace_slug("***", "abcdef0123"), "marketplace-abcdef01"); + let long = marketplace_slug(&"x".repeat(80), "12345678"); + assert_eq!(long, format!("{}-12345678", "x".repeat(32))); + } + + #[test] + fn commits_must_be_full_lowercase_hex() { + assert!(is_valid_commit(&"a".repeat(40))); + assert!(!is_valid_commit(&"A".repeat(40))); + assert!(!is_valid_commit(&"a".repeat(39))); + assert!(!is_valid_commit("HEAD")); + } + + #[test] + fn install_scope_serialises_tagged() { + assert_eq!( + serde_json::to_value(InstallScope::Global).unwrap(), + serde_json::json!({"type": "global"}) + ); + assert_eq!( + serde_json::to_value(InstallScope::Project { project_id: "p".into() }).unwrap(), + serde_json::json!({"type": "project", "project_id": "p"}) + ); + } + + #[test] + fn kinds_serialise_snake_case() { + assert_eq!(serde_json::to_value(ItemKind::Plugin).unwrap(), "plugin"); + assert_eq!(serde_json::to_value(AccountMethod::GhHost).unwrap(), "gh_host"); + } + + #[test] + fn settings_and_projects_saved_before_the_marketplace_still_load() { + let mut settings = serde_json::to_value(crate::models::AppSettings::default()).unwrap(); + for key in ["marketplace_accounts", "marketplaces", "global_marketplace_installs"] { + settings.as_object_mut().unwrap().remove(key); + } + let settings: crate::models::AppSettings = serde_json::from_value(settings).unwrap(); + assert!(settings.marketplace_accounts.is_empty()); + assert!(settings.marketplaces.is_empty()); + assert!(settings.global_marketplace_installs.is_empty()); + + let mut project = + serde_json::to_value(crate::models::Project::new("p".to_string(), Vec::new())).unwrap(); + for key in ["marketplace_installs", "marketplace_disabled"] { + project.as_object_mut().unwrap().remove(key); + } + let project: crate::models::Project = serde_json::from_value(project).unwrap(); + assert!(project.marketplace_installs.is_empty()); + assert!(project.marketplace_disabled.is_empty()); + } +} +``` + +Register the module in `app/src-tauri/src/models/mod.rs` — add the line `pub mod marketplace;` after `pub mod gateway_settings;`, and `pub use marketplace::*;` after `pub use gateway_settings::*;`. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd /workspace/triple-c/app/src-tauri && cargo test --lib models::marketplace 2>&1 | tail -20` +Expected: compile errors such as "cannot find function effective_installs in this scope" and "cannot find type MarketplaceInstall". + +- [ ] **Step 3: Write the implementation** + +Prepend this to `app/src-tauri/src/models/marketplace.rs`, above the tests module: + +```rust +//! Marketplace data model — see `docs/superpowers/specs/2026-09-27-marketplace-design.md`. +//! +//! Plain data plus the pure rules that decide what a project actually gets +//! ([`effective_installs`]) and what names are allowed to reach a container +//! path ([`is_valid_item_key`], [`marketplace_slug`]). + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ItemKind { + Agent, + Skill, + Command, + Hook, + Plugin, +} + +impl ItemKind { + /// The lowercase name used in report strings (`"agent:code-reviewer"`) and the manifest. + pub fn as_str(&self) -> &'static str { + match self { + ItemKind::Agent => "agent", + ItemKind::Skill => "skill", + ItemKind::Command => "command", + ItemKind::Hook => "hook", + ItemKind::Plugin => "plugin", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountMethod { + GhHost, + GhContainer, + Token, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MarketplaceAccount { + pub id: String, + pub label: String, + pub host: String, + pub method: AccountMethod, + #[serde(default)] + pub username: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Marketplace { + pub id: String, + pub name: String, + pub url: String, + #[serde(default)] + pub branch: Option, + #[serde(default)] + pub account_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct MarketplaceItemRef { + pub marketplace_id: String, + pub kind: ItemKind, + pub key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MarketplaceInstall { + pub marketplace_id: String, + pub kind: ItemKind, + pub key: String, + pub commit: String, +} + +impl MarketplaceInstall { + pub fn item_ref(&self) -> MarketplaceItemRef { + MarketplaceItemRef { + marketplace_id: self.marketplace_id.clone(), + kind: self.kind, + key: self.key.clone(), + } + } +} + +/// What a project's container actually gets: the global installs minus the +/// ones this project opted out of, plus the project's own installs. When the +/// project installs an item that is also global, the project's entry (and so +/// its pin) wins. Sorted by item ref so the result is deterministic. +pub fn effective_installs( + global: &[MarketplaceInstall], + disabled: &[MarketplaceItemRef], + project: &[MarketplaceInstall], +) -> Vec { + let mut out: BTreeMap = BTreeMap::new(); + for install in global { + let item = install.item_ref(); + if disabled.contains(&item) { + continue; + } + out.insert(item, install.clone()); + } + for install in project { + out.insert(install.item_ref(), install.clone()); + } + out.into_values().collect() +} + +/// `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` — the only names that may become a +/// container path component. No `/`, no leading `.` or `-`, no shell +/// metacharacters. +pub fn is_valid_item_key(key: &str) -> bool { + let bytes = key.as_bytes(); + if bytes.is_empty() || bytes.len() > 64 { + return false; + } + if !bytes[0].is_ascii_alphanumeric() { + return false; + } + bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + +/// A container-safe, collision-free name for a marketplace: its name +/// lowercased to `[a-z0-9-]`, dashes collapsed, at most 32 characters, then +/// `-` and the first 8 characters of its id. An empty sanitised name becomes +/// `marketplace`. +pub fn marketplace_slug(name: &str, id: &str) -> String { + let mut base = String::new(); + for c in name.chars() { + let c = c.to_ascii_lowercase(); + if c.is_ascii_lowercase() || c.is_ascii_digit() { + base.push(c); + } else if !base.ends_with('-') && !base.is_empty() { + base.push('-'); + } + } + let mut base: String = base.trim_matches('-').chars().take(32).collect(); + while base.ends_with('-') { + base.pop(); + } + if base.is_empty() { + base.push_str("marketplace"); + } + let id_part: String = id + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_lowercase()) + .take(8) + .collect(); + format!("{}-{}", base, id_part) +} + +/// A full, lowercase, 40-character hex object id. +pub fn is_valid_commit(commit: &str) -> bool { + commit.len() == 40 && commit.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CatalogItem { + pub kind: ItemKind, + pub key: String, + pub name: String, + pub description: String, + /// Repo-relative path of the item (file or folder). + pub path: String, + /// `Some(reason)` when the item cannot be installed. + pub invalid: Option, + /// Hooks only: rendered commands with `${HOOK_DIR}` substituted. + #[serde(default)] + pub hook_commands: Vec, + /// Agents/commands/skills: the markdown body (≤ 64 KiB, truncated); + /// plugins: a component listing. + #[serde(default)] + pub preview: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct MarketplaceSnapshot { + pub marketplace_id: String, + pub head_commit: Option, + /// RFC 3339. + pub fetched_at: Option, + pub fetch_error: Option, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ItemUpdate { + pub item: MarketplaceItemRef, + pub pinned: String, + pub head: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FileChange { + Added, + Removed, + Modified, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileDiff { + pub path: String, + pub change: FileChange, + /// Unified diff text; `None` when either side is binary. + pub unified: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SkippedItem { + pub item: String, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SyncReport { + #[serde(default)] + pub installed: Vec, + #[serde(default)] + pub updated: Vec, + #[serde(default)] + pub removed: Vec, + #[serde(default)] + pub skipped: Vec, + #[serde(default)] + pub errors: Vec, + /// RFC 3339, set by the host. + #[serde(default)] + pub finished_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum InstallScope { + Global, + Project { project_id: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectSyncResult { + pub project_id: String, + pub report: SyncReport, +} +``` + +Add the fields. In `app/src-tauri/src/models/app_settings.rs`: + +1. Below `use super::gateway_settings::GatewaySettings;` add: + +```rust +use super::marketplace::{Marketplace, MarketplaceAccount, MarketplaceInstall}; +``` + +2. In `pub struct AppSettings`, directly after the `global_claude_code_settings` field (`pub global_claude_code_settings: Option,`) add: + +```rust + /// Sign-in accounts for private marketplace repos. Secrets live in the + /// OS keychain (`storage::secure::*_marketplace_token`), never here. + #[serde(default)] + pub marketplace_accounts: Vec, + /// Marketplace git repos the user added. + #[serde(default)] + pub marketplaces: Vec, + /// Items installed for every project (projects may opt out per item). + #[serde(default)] + pub global_marketplace_installs: Vec, +``` + +3. In `impl Default for AppSettings`, after `global_claude_code_settings: None,` add: + +```rust + marketplace_accounts: Vec::new(), + marketplaces: Vec::new(), + global_marketplace_installs: Vec::new(), +``` + +In `app/src-tauri/src/models/project.rs`: + +1. In `pub struct Project`, directly after `pub renamed_session_names: HashMap,` add: + +```rust + /// Marketplace items installed for this project only (spec §2). + #[serde(default)] + pub marketplace_installs: Vec, + /// Global marketplace installs this project opts out of. + #[serde(default)] + pub marketplace_disabled: Vec, +``` + +2. In `Project::new`, after `renamed_session_names: HashMap::new(),` add: + +```rust + marketplace_installs: Vec::new(), + marketplace_disabled: Vec::new(), +``` + +(`Project::new` and `AppSettings::default()` are the only places in the Rust crate that build these structs field-by-field; every other constructor goes through them, `..AppSettings::default()` or `serde_json`, so nothing else needs the fields.) + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd /workspace/triple-c/app/src-tauri && cargo test --lib models:: 2>&1 | grep -E "^test result|FAILED|panicked"` +Expected: `test result: ok.` with the 9 `models::marketplace::tests` passing and no failures elsewhere in `models::`. + +- [ ] **Step 5: Mirror the types in TypeScript** + +Append to `app/src/lib/types.ts` (after the closing `}` of `export interface AppSettings`, i.e. just before the doc comment that starts "What preview_settings_import returns"): + +```ts + +// ── Marketplace (mirrors src-tauri/src/models/marketplace.rs) ─────────────── + +export type ItemKind = "agent" | "skill" | "command" | "hook" | "plugin"; +export type AccountMethod = "gh_host" | "gh_container" | "token"; +export interface MarketplaceAccount { id: string; label: string; host: string; method: AccountMethod; username: string | null; } +export interface Marketplace { id: string; name: string; url: string; branch: string | null; account_id: string | null; } +export interface MarketplaceItemRef { marketplace_id: string; kind: ItemKind; key: string; } +export interface MarketplaceInstall extends MarketplaceItemRef { commit: string; } +export interface CatalogItem { kind: ItemKind; key: string; name: string; description: string; path: string; invalid: string | null; hook_commands: string[]; preview: string; } +export interface MarketplaceSnapshot { marketplace_id: string; head_commit: string | null; fetched_at: string | null; fetch_error: string | null; items: CatalogItem[]; } +export interface ItemUpdate { item: MarketplaceItemRef; pinned: string; head: string; } +export type FileChange = "added" | "removed" | "modified"; +export interface FileDiff { path: string; change: FileChange; unified: string | null; } +export interface SkippedItem { item: string; reason: string; } +export interface SyncReport { installed: string[]; updated: string[]; removed: string[]; skipped: SkippedItem[]; errors: string[]; finished_at: string; } +export type InstallScope = { type: "global" } | { type: "project"; project_id: string }; +export interface ProjectSyncResult { project_id: string; report: SyncReport; } +``` + +In `export interface AppSettings`, after `terminal_gpu_rendering: boolean | null;` add: + +```ts + marketplace_accounts: MarketplaceAccount[]; + marketplaces: Marketplace[]; + global_marketplace_installs: MarketplaceInstall[]; +``` + +In `export interface Project`, after `renamed_session_names: Record;` add: + +```ts + marketplace_installs: MarketplaceInstall[]; + marketplace_disabled: MarketplaceItemRef[]; +``` + +Update the test fixtures that spell out a whole `Project` (each has exactly one `renamed_session_names: {},` line inside its fixture): + +```bash +cd /workspace/triple-c/app/src +for f in components/projects/home/BrowserTab.test.tsx components/projects/home/config/RuntimeSection.test.tsx components/settings/SharedAuthSettings.test.tsx components/projects/home/TaskEditorModal.test.tsx components/projects/ProjectRow.test.tsx components/projects/PermissionModeControl.test.tsx components/projects/home/config/ModelSection.test.tsx components/projects/home/config/WorkspaceSection.test.tsx; do + grep -c "renamed_session_names: {}," "$f" # expect 1 + sed -i 's/^\(\s*\)renamed_session_names: {},$/\1renamed_session_names: {},\n\1marketplace_installs: [],\n\1marketplace_disabled: [],/' "$f" +done +git diff --stat -- . +``` + +Expected: each `grep -c` prints `1`; the diff touches those 8 files with 2 insertions each. + +- [ ] **Step 6: Verify the frontend still type-checks and its tests pass** + +Run: `cd /workspace/triple-c/app && npx tsc --noEmit -p . && npx vitest run 2>&1 | grep -E "Test Files|Tests "` +Expected: `tsc` prints nothing; Vitest reports all test files and tests passed. + +- [ ] **Step 7: Commit** + +```bash +cd /workspace/triple-c +git add app/src-tauri/src/models/marketplace.rs app/src-tauri/src/models/mod.rs app/src-tauri/src/models/app_settings.rs app/src-tauri/src/models/project.rs app/src/lib/types.ts app/src/components/projects/home/BrowserTab.test.tsx app/src/components/projects/home/config/RuntimeSection.test.tsx app/src/components/settings/SharedAuthSettings.test.tsx app/src/components/projects/home/TaskEditorModal.test.tsx app/src/components/projects/ProjectRow.test.tsx app/src/components/projects/PermissionModeControl.test.tsx app/src/components/projects/home/config/ModelSection.test.tsx app/src/components/projects/home/config/WorkspaceSection.test.tsx +git commit -m "Marketplace: data model, settings and project fields + +Co-Authored-By: Claude Opus 5.5 " +``` + +### Task 3: Tree view and catalog parsing + +**Files:** +- Create: `app/src-tauri/src/marketplace/mod.rs` +- Create: `app/src-tauri/src/marketplace/tree.rs` (`TreeView`, `DirEntry`, `EntryKind`, `MemTree`; `GitTree` is added in Task 4) +- Create: `app/src-tauri/src/marketplace/catalog.rs` +- Modify: `app/src-tauri/src/lib.rs` (declare the module) +- Test: unit tests inside `tree.rs` and `catalog.rs` + +**Interfaces:** +- Consumes: `crate::models::marketplace::{is_valid_item_key, CatalogItem, ItemKind}` (Task 2). +- Produces: `marketplace::tree::{TreeView, DirEntry, EntryKind, MemTree}` and `marketplace::catalog::{MAX_ITEM_BYTES, MAX_ITEM_FILES, ItemFile, parse_catalog, item_files, item_fingerprint, plugin_catalog_entry, rendered_hook_settings, hook_dir}` with the contract's signatures. `ItemFile.rel_path` is relative to the item root; for agents/commands it is `".md"`. `parse_catalog` order: agents, skills, commands, hooks, plugins. A broken `plugins/.claude-plugin/marketplace.json` yields one invalid `Plugin` item with key `"catalog"`. Recognised hook events: `PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, Notification, UserPromptSubmit, SessionStart, SessionEnd, Stop, SubagentStart, SubagentStop, PreCompact`. + +- [ ] **Step 1: Create the module skeleton and the tree tests** + +`app/src-tauri/src/marketplace/mod.rs`: + +```rust +//! Marketplace support — see `docs/superpowers/specs/2026-09-27-marketplace-design.md`. + +pub mod catalog; +pub mod tree; +``` + +In `app/src-tauri/src/lib.rs` add `mod marketplace;` on its own line between `mod logging;` and `mod models;`. + +Create `app/src-tauri/src/marketplace/tree.rs` with only its tests for now: + +```rust +#[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); + } +} +``` + +Create `app/src-tauri/src/marketplace/catalog.rs` with only its tests for now: + +```rust +#[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()); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd /workspace/triple-c/app/src-tauri && cargo test --lib marketplace:: 2>&1 | tail -20` +Expected: compile errors, e.g. "cannot find type MemTree in this scope", "cannot find function parse_catalog". + +- [ ] **Step 3: Implement `tree.rs`** + +Prepend to `app/src-tauri/src/marketplace/tree.rs`, above its tests module: + +```rust +//! 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>; +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +#[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. +#[derive(Debug, Clone, Default)] +pub struct MemTree { + nodes: BTreeMap, +} + +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)) + } +} + +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()))) + } +} +``` + +- [ ] **Step 4: Implement `catalog.rs`** + +Prepend to `app/src-tauri/src/marketplace/catalog.rs`, above its tests module: + +```rust +//! 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::{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)), + } +} + +/// 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(format!( + "{:?} is not a valid name (letters, digits, '.', '_' and '-', starting with a letter or digit, at most 64)", + 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(hasher.finalize().iter().map(|b| format!("{:02x}", b)).collect())); + } + 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(format!( + "{:?} is not a valid name (letters, digits, '.', '_' and '-', starting with a letter or digit, at most 64)", + 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(format!( + "{:?} is not a valid name (letters, digits, '.', '_' and '-', starting with a letter or digit, at most 64)", + 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 +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cd /workspace/triple-c/app/src-tauri && cargo test --lib marketplace:: 2>&1 | grep -E "^test |^test result"` +Expected: 16 tests pass (2 in `tree::tests`, 14 in `catalog::tests`), including `rejects_symlink_items`, `rejects_escaping_plugin_source`, `rejects_bad_keys`, `enforces_item_limits`. Dead-code warnings for the new module are expected until Task 11. + +- [ ] **Step 6: Format and commit** + +```bash +cd /workspace/triple-c/app/src-tauri +rustfmt --edition 2021 src/marketplace/mod.rs src/marketplace/tree.rs src/marketplace/catalog.rs +cargo test --lib marketplace:: 2>&1 | grep "^test result" +``` + +```bash +cd /workspace/triple-c +git add app/src-tauri/src/lib.rs app/src-tauri/src/marketplace/mod.rs app/src-tauri/src/marketplace/tree.rs app/src-tauri/src/marketplace/catalog.rs +git commit -m "Marketplace: repo tree view and catalog parsing + +Co-Authored-By: Claude Opus 5.5 " +``` + +### Task 4: Git cache (gix fetch, pins, `GitTree`) + +**Files:** +- Modify: `app/src-tauri/Cargo.toml` (`gix`, dev-dep `tempfile`), `app/src-tauri/Cargo.lock` (generated) +- Create: `app/src-tauri/src/marketplace/git.rs` +- Modify: `app/src-tauri/src/marketplace/tree.rs` (add `GitTree`) +- Modify: `app/src-tauri/src/marketplace/mod.rs` (`pub mod git;`) +- Test: unit tests in `git.rs` (fixture repos built with the git CLI over `file://`; each test returns early when `git` is not installed) + +**Interfaces:** +- Consumes: `tree::{TreeView, DirEntry, EntryKind}` (Task 3). +- Produces: `git::{Credential, FetchError, HEAD_REF, PIN_PREFIX, classify_fetch_error, cache_path, fetch, cached_head, set_pins, has_commit}`; `tree::GitTree::open(repo_path, commit)`. `fetch` stores the tip at `refs/triple-c/head` via refspec `+HEAD:refs/triple-c/head` (default branch) or `+refs/heads/:refs/triple-c/head`; branch names outside `[A-Za-z0-9._/-]` (or containing `..`, leading `-`/`/`, trailing `/` or `.lock`) are refused with `FetchError::Other("… is not a valid branch name")`. `FetchError` mapping: `HTTP status 401` or `not accepted by the remote` → `Auth{401}`, `HTTP status 403` → `Auth{403}`, `HTTP status 404`/`repository not found` → `NotFound`, dns/connect/timeout text → `Network`, else `Other`. Test fixtures for later tasks: `git::test_support::{git_available() -> bool, git(dir, args) -> String, init_repo(dir, files: &[(&str, &str, bool)]) -> String /*commit*/, commit_files(dir, files, message) -> String, file_url(dir) -> String}`. + +- [ ] **Step 1: Add the dependencies** + +In `app/src-tauri/Cargo.toml` under `[dependencies]` (after `url = "2"`) add: + +```toml +# Marketplace repos are fetched on the host into a bare cache (spec §3). +# Blocking client + rustls: no git binary or OpenSSL needed on the host. +gix = { version = "0.88", default-features = false, features = ["blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "credentials", "sha1"] } +``` + +Under `[dev-dependencies]` add: + +```toml +tempfile = "3" +``` + +Run: `cd /workspace/triple-c/app/src-tauri && cargo build 2>&1 | tail -3` +Expected: builds (first build of gix takes a few minutes). + +- [ ] **Step 2: Write the failing tests** + +Add `pub mod git;` to `app/src-tauri/src/marketplace/mod.rs` (keep the list alphabetical: `catalog`, `git`, `tree`). + +Create `app/src-tauri/src/marketplace/git.rs` with only the test code for now: + +```rust +#[cfg(test)] +pub(crate) mod test_support { + //! Fixture repos built with the git CLI. Tests that need one call + //! [`git_available`] first and return early without it. + use std::path::Path; + use std::process::Command; + + pub fn git_available() -> bool { + Command::new("git").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) + } + + pub fn git(dir: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .args(["-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "init.defaultBranch=main"]) + .args(args) + .current_dir(dir) + .output() + .expect("git runs"); + assert!(out.status.success(), "git {:?}: {}", args, String::from_utf8_lossy(&out.stderr)); + String::from_utf8_lossy(&out.stdout).trim().to_string() + } + + /// Write `files` (path, contents, executable) into a new repo and commit. + pub fn init_repo(dir: &Path, files: &[(&str, &str, bool)]) -> String { + git(dir, &["init", "-q"]); + commit_files(dir, files, "initial") + } + + pub fn commit_files(dir: &Path, files: &[(&str, &str, bool)], message: &str) -> String { + for (path, contents, exec) in files { + let full = dir.join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(&full, contents).unwrap(); + #[cfg(unix)] + if *exec { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&full, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + #[cfg(not(unix))] + let _ = exec; + } + git(dir, &["add", "-A"]); + git(dir, &["commit", "-q", "-m", message]); + git(dir, &["rev-parse", "HEAD"]) + } + + pub fn file_url(dir: &Path) -> String { + format!("file://{}", dir.display()) + } +} + +#[cfg(test)] +mod tests { + use super::test_support::*; + use super::*; + use crate::marketplace::tree::{GitTree, TreeView}; + + #[test] + fn fetch_error_mapping() { + let cases = [ + ("Credentials provided for \"https://x\" were not accepted by the remote\n└─ Received HTTP status 401", FetchError::Auth { status: 401 }), + ("handshake\n└─ Received HTTP status 403", FetchError::Auth { status: 403 }), + ("└─ Received HTTP status 404", FetchError::NotFound), + ("remote: Repository not found.", FetchError::NotFound), + ]; + for (text, want) in cases { + assert_eq!(classify_fetch_error(text), want, "{}", text); + } + assert!(matches!( + classify_fetch_error("error sending request\n└─ dns error: failed to lookup address"), + FetchError::Network(_) + )); + assert!(matches!(classify_fetch_error("operation timed out"), FetchError::Network(_))); + assert!(matches!(classify_fetch_error("something odd"), FetchError::Other(_))); + } + + #[test] + fn credential_debug_never_shows_the_password() { + let c = Credential { username: "u".into(), password: "test-token-not-real".into() }; + let shown = format!("{:?}", c); + assert!(!shown.contains("test-token-not-real")); + assert!(shown.contains("")); + } + + #[test] + fn refuses_unsafe_branch_names() { + let dir = tempfile::tempdir().unwrap(); + for bad in ["-x", "a..b", "a b", "a:b", "x*", "a.lock", ""] { + let err = fetch(&dir.path().join("c.git"), "file:///nowhere", Some(bad), None).unwrap_err(); + assert!(matches!(err, FetchError::Other(ref m) if m.contains("branch")), "{bad:?}: {err:?}"); + } + } + + #[test] + fn fetches_default_branch_then_updates() { + if !git_available() { + return; + } + let src = tempfile::tempdir().unwrap(); + let first = init_repo(src.path(), &[("agents/a.md", "one", false), ("hooks/h/run.sh", "#!/bin/sh", true)]); + let cache = tempfile::tempdir().unwrap(); + let repo = cache_path(cache.path(), "m1"); + + assert_eq!(cached_head(&repo).unwrap(), None); + let head = fetch(&repo, &file_url(src.path()), None, None).unwrap(); + assert_eq!(head, first); + assert_eq!(cached_head(&repo).unwrap(), Some(first.clone())); + assert!(has_commit(&repo, &first)); + + let tree = GitTree::open(&repo, &first).unwrap(); + assert_eq!(tree.read_file("agents/a.md").unwrap().unwrap(), b"one"); + let hook = tree.list_dir("hooks/h").unwrap().unwrap(); + assert!(hook[0].executable); + assert!(tree.entry_id("agents/a.md").unwrap().is_some()); + assert_eq!(tree.list_dir("agents/a.md").unwrap(), None); + + let second = commit_files(src.path(), &[("agents/a.md", "two", false)], "second"); + assert_eq!(fetch(&repo, &file_url(src.path()), None, None).unwrap(), second); + // The old commit is still readable after the update. + assert_eq!( + GitTree::open(&repo, &first).unwrap().read_file("agents/a.md").unwrap().unwrap(), + b"one" + ); + } + + #[test] + fn fetches_a_named_branch() { + if !git_available() { + return; + } + let src = tempfile::tempdir().unwrap(); + init_repo(src.path(), &[("a.md", "main", false)]); + git(src.path(), &["checkout", "-q", "-b", "next"]); + let next = commit_files(src.path(), &[("a.md", "next", false)], "next"); + git(src.path(), &["checkout", "-q", "main"]); + + let cache = tempfile::tempdir().unwrap(); + let repo = cache_path(cache.path(), "m1"); + assert_eq!(fetch(&repo, &file_url(src.path()), Some("next"), None).unwrap(), next); + } + + #[test] + fn missing_repo_is_an_error_not_a_panic() { + let cache = tempfile::tempdir().unwrap(); + let err = fetch(&cache_path(cache.path(), "m"), "file:///definitely/not/here", None, None).unwrap_err(); + assert!(!matches!(err, FetchError::Auth { .. }), "{err:?}"); + } + + #[test] + fn pins_are_exactly_the_requested_set() { + if !git_available() { + return; + } + let src = tempfile::tempdir().unwrap(); + let a = init_repo(src.path(), &[("x", "1", false)]); + let b = commit_files(src.path(), &[("x", "2", false)], "b"); + let cache = tempfile::tempdir().unwrap(); + let repo = cache_path(cache.path(), "m"); + fetch(&repo, &file_url(src.path()), None, None).unwrap(); + + set_pins(&repo, &[a.clone(), b.clone(), "f".repeat(40)]).unwrap(); + let pins = |repo: &Path| -> Vec { + let r = gix::open(repo).unwrap(); + let mut names: Vec = r + .references() + .unwrap() + .prefixed(PIN_PREFIX) + .unwrap() + .map(|x| x.unwrap().name().as_bstr().to_string()) + .collect(); + names.sort(); + names + }; + let mut want = vec![format!("{}{}", PIN_PREFIX, a), format!("{}{}", PIN_PREFIX, b)]; + want.sort(); + assert_eq!(pins(&repo), want); + + set_pins(&repo, &[b.clone()]).unwrap(); + assert_eq!(pins(&repo), vec![format!("{}{}", PIN_PREFIX, b)]); + } +} +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `cd /workspace/triple-c/app/src-tauri && cargo test --lib marketplace::git 2>&1 | tail -20` +Expected: compile errors, e.g. "cannot find function classify_fetch_error", "cannot find type GitTree in module crate::marketplace::tree". + +- [ ] **Step 4: Add `GitTree` to `tree.rs`** + +In `app/src-tauri/src/marketplace/tree.rs`, insert this block immediately above `#[cfg(test)]`: + +```rust +/// A tree at one commit of a bare gix repository. +pub struct GitTree { + repo: gix::Repository, + tree_id: gix::ObjectId, +} + +impl GitTree { + pub fn open(repo_path: &std::path::Path, commit: &str) -> Result { + let repo = gix::open(repo_path) + .map_err(|e| format!("Could not open the marketplace cache: {}", e))?; + let oid = gix::ObjectId::from_hex(commit.as_bytes()) + .map_err(|e| format!("Invalid commit id {}: {}", commit, e))?; + let tree_id = repo + .find_commit(oid) + .map_err(|e| format!("Commit {} is not in the marketplace cache: {}", commit, e))? + .tree_id() + .map_err(|e| format!("Commit {} has no tree: {}", commit, e))? + .detach(); + Ok(Self { repo, tree_id }) + } + + fn root(&self) -> Result, String> { + self.repo + .find_tree(self.tree_id) + .map_err(|e| format!("Could not read tree {}: {}", self.tree_id, e)) + } + + /// `(object id, mode)` of the entry at `path`, or `None`. + fn lookup(&self, path: &str) -> Result, String> { + if path.is_empty() { + return Ok(Some((self.tree_id, gix::object::tree::EntryKind::Tree.into()))); + } + let root = self.root()?; + let entry = root + .lookup_entry_by_path(path) + .map_err(|e| format!("Could not look up {}: {}", path, e))?; + Ok(entry.map(|e| (e.object_id(), e.mode()))) + } +} + +impl TreeView for GitTree { + fn list_dir(&self, path: &str) -> Result>, String> { + let Some((id, mode)) = self.lookup(path)? else { return Ok(None) }; + if !mode.is_tree() { + return Ok(None); + } + let tree = self + .repo + .find_tree(id) + .map_err(|e| format!("Could not read {}: {}", path, e))?; + let mut out = Vec::new(); + for entry in tree.iter() { + let entry = entry.map_err(|e| format!("Could not read {}: {:?}", path, e))?; + let mode = entry.mode(); + let kind = if mode.is_tree() { + EntryKind::Dir + } else if mode.is_link() { + EntryKind::Symlink + } else if mode.is_blob() { + EntryKind::File + } else { + EntryKind::Other + }; + out.push(DirEntry { + name: entry.filename().to_string(), + kind, + executable: mode.is_executable(), + }); + } + Ok(Some(out)) + } + + fn read_file(&self, path: &str) -> Result>, String> { + let Some((id, mode)) = self.lookup(path)? else { return Ok(None) }; + if !mode.is_blob() { + return Ok(None); + } + let blob = self + .repo + .find_blob(id) + .map_err(|e| format!("Could not read {}: {}", path, e))?; + Ok(Some(blob.data.clone())) + } + + fn entry_id(&self, path: &str) -> Result, String> { + Ok(self.lookup(path)?.map(|(id, _)| id.to_string())) + } +} +``` + +- [ ] **Step 5: Implement `git.rs`** + +Prepend to `app/src-tauri/src/marketplace/git.rs`, above `#[cfg(test)] pub(crate) mod test_support`: + +```rust +//! The marketplace cache: one bare `gix` repository per marketplace. +//! +//! Everything here is blocking — call it from `tokio::task::spawn_blocking`. +//! Credentials are handed to gix through its credential callback for the +//! duration of one fetch and are never written to disk or into the repo +//! config. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; + +/// The ref the fetched branch tip is stored under. +pub const HEAD_REF: &str = "refs/triple-c/head"; +/// Prefix of the refs that keep pinned commits alive. +pub const PIN_PREFIX: &str = "refs/triple-c/pins/"; + +#[derive(Clone)] +pub struct Credential { + pub username: String, + pub password: String, +} + +impl std::fmt::Debug for Credential { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Credential") + .field("username", &self.username) + .field("password", &"") + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FetchError { + /// 401 / 403, or gix's "credentials … were not accepted". + Auth { status: u16 }, + /// 404 / "repository not found". + NotFound, + Network(String), + Other(String), +} + +impl std::fmt::Display for FetchError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FetchError::Auth { status } => write!(f, "access denied (HTTP {})", status), + FetchError::NotFound => write!(f, "repository not found"), + FetchError::Network(m) => write!(f, "network error: {}", m), + FetchError::Other(m) => write!(f, "{}", m), + } + } +} + +/// Classify a gix error by its Debug-formatted chain. gix wraps transport +/// errors several layers deep and some layers are not `std::error::Error`, +/// so the text is the one stable thing to match on. +pub fn classify_fetch_error(chain: &str) -> FetchError { + let lower = chain.to_ascii_lowercase(); + if lower.contains("http status 401") || lower.contains("not accepted by the remote") { + return FetchError::Auth { status: 401 }; + } + if lower.contains("http status 403") { + return FetchError::Auth { status: 403 }; + } + if lower.contains("http status 404") || lower.contains("repository not found") { + return FetchError::NotFound; + } + const NETWORK: &[&str] = &[ + "dns error", + "failed to lookup address", + "connection refused", + "connection reset", + "timed out", + "timeout", + "network is unreachable", + "no route to host", + "error sending request", + "tcp connect error", + ]; + if NETWORK.iter().any(|needle| lower.contains(needle)) { + return FetchError::Network(first_line(chain)); + } + FetchError::Other(first_line(chain)) +} + +fn first_line(chain: &str) -> String { + chain.lines().next().unwrap_or("").trim().chars().take(300).collect() +} + +fn classify(e: E) -> FetchError { + classify_fetch_error(&format!("{:?}", e)) +} + +pub fn cache_path(data_root: &Path, marketplace_id: &str) -> PathBuf { + data_root.join("marketplaces").join(format!("{}.git", marketplace_id)) +} + +/// Branch names that are safe inside a refspec. Stricter than git's own +/// rules on purpose: nothing that could change the refspec's meaning. +fn valid_branch(branch: &str) -> bool { + !branch.is_empty() + && branch.len() <= 200 + && !branch.starts_with('-') + && !branch.starts_with('/') + && !branch.ends_with('/') + && !branch.ends_with(".lock") + && !branch.contains("..") + && !branch.contains("//") + && branch + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'/')) +} + +fn open_or_init(repo_path: &Path) -> Result { + if repo_path.exists() { + gix::open(repo_path).map_err(|e| FetchError::Other(format!("Could not open the marketplace cache: {}", e))) + } else { + if let Some(parent) = repo_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| FetchError::Other(format!("Could not create {}: {}", parent.display(), e)))?; + } + gix::init_bare(repo_path) + .map_err(|e| FetchError::Other(format!("Could not create the marketplace cache: {}", e))) + } +} + +/// Init the bare repo if missing, fetch `branch` (or the remote's default +/// branch) into [`HEAD_REF`], and return the head commit hex. +pub fn fetch( + repo_path: &Path, + url: &str, + branch: Option<&str>, + cred: Option, +) -> Result { + let refspec = match branch { + Some(b) if !valid_branch(b) => { + return Err(FetchError::Other(format!("{:?} is not a valid branch name", b))); + } + Some(b) => format!("+refs/heads/{}:{}", b, HEAD_REF), + None => format!("+HEAD:{}", HEAD_REF), + }; + let repo = open_or_init(repo_path)?; + let remote = repo + .remote_at(url) + .map_err(|e| FetchError::Other(format!("Invalid repository URL: {}", e)))? + .with_refspecs([refspec.as_str()], gix::remote::Direction::Fetch) + .map_err(|e| FetchError::Other(format!("Invalid refspec: {}", e)))?; + let connection = remote + .connect(gix::remote::Direction::Fetch) + .map_err(classify)? + .with_credentials(move |action| match (action, &cred) { + (gix::credentials::helper::Action::Get(ctx), Some(c)) => { + Ok(Some(gix::credentials::protocol::Outcome { + identity: gix::sec::identity::Account { + username: c.username.clone(), + password: c.password.clone(), + oauth_refresh_token: None, + }, + next: gix::credentials::helper::NextAction::from(ctx), + })) + } + _ => Ok(None), + }); + connection + .prepare_fetch(gix::progress::Discard, Default::default()) + .map_err(classify)? + .receive(gix::progress::Discard, &AtomicBool::new(false)) + .map_err(classify)?; + cached_head(repo_path) + .map_err(FetchError::Other)? + .ok_or_else(|| FetchError::Other("The remote did not return a branch to fetch".to_string())) +} + +/// Current [`HEAD_REF`], if fetched before. +pub fn cached_head(repo_path: &Path) -> Result, String> { + if !repo_path.exists() { + return Ok(None); + } + let repo = gix::open(repo_path).map_err(|e| format!("Could not open the marketplace cache: {}", e))?; + let reference = repo + .try_find_reference(HEAD_REF) + .map_err(|e| format!("Could not read {}: {}", HEAD_REF, e))?; + match reference { + None => Ok(None), + Some(mut r) => { + let id = r + .peel_to_id() + .map_err(|e| format!("Could not resolve {}: {}", HEAD_REF, e))?; + Ok(Some(id.to_string())) + } + } +} + +pub fn has_commit(repo_path: &Path, commit: &str) -> bool { + let Ok(repo) = gix::open(repo_path) else { return false }; + let Ok(oid) = gix::ObjectId::from_hex(commit.as_bytes()) else { return false }; + repo.find_commit(oid).is_ok() +} + +/// Make `refs/triple-c/pins/*` exactly the given set (commits missing from +/// the cache are skipped), so pinned commits survive later fetches. +pub fn set_pins(repo_path: &Path, commits: &[String]) -> Result<(), String> { + let repo = gix::open(repo_path).map_err(|e| format!("Could not open the marketplace cache: {}", e))?; + let wanted: std::collections::BTreeSet<&str> = commits.iter().map(String::as_str).collect(); + + let mut existing = Vec::new(); + let platform = repo.references().map_err(|e| format!("Could not list refs: {}", e))?; + for reference in platform + .prefixed(PIN_PREFIX) + .map_err(|e| format!("Could not list pins: {}", e))? + { + let reference = reference.map_err(|e| format!("Could not read a pin: {:?}", e))?; + existing.push(reference.name().as_bstr().to_string()); + } + + for name in &existing { + let commit = name.trim_start_matches(PIN_PREFIX); + if !wanted.contains(commit) { + if let Some(r) = repo + .try_find_reference(name.as_str()) + .map_err(|e| format!("Could not read {}: {}", name, e))? + { + r.delete().map_err(|e| format!("Could not remove {}: {}", name, e))?; + } + } + } + for commit in wanted { + let name = format!("{}{}", PIN_PREFIX, commit); + if existing.contains(&name) { + continue; + } + let Ok(oid) = gix::ObjectId::from_hex(commit.as_bytes()) else { continue }; + if repo.find_commit(oid).is_err() { + continue; + } + repo.reference(name.as_str(), oid, gix::refs::transaction::PreviousValue::Any, "triple-c pin") + .map_err(|e| format!("Could not pin {}: {}", commit, e))?; + } + Ok(()) +} +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `cd /workspace/triple-c/app/src-tauri && cargo test --lib marketplace:: 2>&1 | grep -E "^test |^test result"` +Expected: all `marketplace::` tests pass, including `git::tests::fetch_error_mapping`, `fetches_default_branch_then_updates`, `fetches_a_named_branch`, `pins_are_exactly_the_requested_set` (these three print `ok` even without `git`, because they return early — on CI runners `git` is present, so they really run). + +- [ ] **Step 7: Format and commit** + +```bash +cd /workspace/triple-c/app/src-tauri +rustfmt --edition 2021 src/marketplace/git.rs src/marketplace/tree.rs src/marketplace/mod.rs +``` + +```bash +cd /workspace/triple-c +git add app/src-tauri/Cargo.toml app/src-tauri/Cargo.lock app/src-tauri/src/marketplace/git.rs app/src-tauri/src/marketplace/tree.rs app/src-tauri/src/marketplace/mod.rs +git commit -m "Marketplace: gix cache with credentialed fetch, pins and GitTree + +Co-Authored-By: Claude Opus 5.5 " +``` + +### Task 5: Accounts — credentials, token checks, fetch-error advice + +**Files:** +- Modify: `app/src-tauri/src/storage/secure.rs` (marketplace token helpers + test) +- Create: `app/src-tauri/src/marketplace/auth.rs` +- Modify: `app/src-tauri/src/marketplace/mod.rs` (`pub mod auth;`) +- Test: unit tests in `auth.rs` (a local `axum` server stands in for the GitHub/Gitea/GitLab APIs) and `secure.rs` + +**Interfaces:** +- Consumes: `models::marketplace::{MarketplaceAccount, AccountMethod}` (Task 2); `git::{Credential, FetchError}` (Task 4). +- Produces: `secure::{store_marketplace_token(account_id, token), get_marketplace_token(account_id) -> Result, String>, delete_marketplace_token(account_id)}` (service `triple-c-marketplace-account-`, ids must match `[A-Za-z0-9-]{1,64}`); `auth::{HostKind, host_kind, host_of, fetch_username, resolve_credential, validate_token /* -> Result, String> */, gh_host_available, gh_host_login, describe_fetch_error}`. `host_of` rejects non-https URLs and URLs with embedded credentials. `resolve_credential` errors (user-facing): GhHost not logged in → "gh on this computer is not logged in to . Run `gh auth login --hostname ` in a terminal, then try again."; missing keychain token → "No token is stored for the account \"