445 KiB
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/marketplacein/workspace/triple-c(already contains the SharedAuthSettingsflex-wrapfixece0d74and the speca6b00e0). - Item kinds: exactly
agent,skill,command,hook,plugin(serdesnake_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' catalogsourcemust be a relative path insideplugins/. - Hook placeholder:
${HOOK_DIR}→/home/claude/.claude/triple-c/hooks/<key>. - 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/<key>/, plugin trees/home/claude/.claude/triple-c/plugins/<slug>/, plugin marketplace nametriple-c-<slug>. - 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 tocontainer/— image/entrypoint changes never reach existing projects (CLAUDE.md). Plugin commands run underflock /tmp/.triple-c-claude-update.lockwhenflockexists. - The sync script is
app/src-tauri/src/marketplace/sync.sh, embedded withinclude_str!and uploaded next topayload.taron every sync. - Cache:
<dirs::data_dir()>/triple-c/marketplaces/<marketplace_id>.git(bare); fetched tip stored atrefs/triple-c/head; pins atrefs/triple-c/pins/<commit>. - Keychain service per account:
triple-c-marketplace-account-<account_id>, accountsecret(existingKEYCHAIN_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: accountusername, elseoauth2. - New Tauri command =
#[tauri::command]+generate_handler!entry inlib.rs+"allow-<name-with-dashes>"inapp/src-tauri/capabilities/default.json+ wrapper inapp/src/lib/tauri-commands.ts(+ types inapp/src/lib/types.ts). Onlylib/tauri-commands.tsmay import@tauri-apps/api/core. - All new serde fields
#[serde(default)]. No secrets insettings.json,projects.json, container labels, logs, events or test output. Test fixtures must not look like live tokens (the pre-commit secret scan rejectsghp_…,gho_…etc.) — usetest-token-not-real. - Errors are
Result<T, String>; UI copy says changes apply to new Claude sessions. - Commit after every task; messages end with
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>. - Test commands:
cd app/src-tauri && cargo test --lib <filter>;cd app && npx vitest run <path>. Runcargo fmtonly on files you touch (the repo is not globally rustfmt-clean):rustfmt --edition 2021 <file>. - Note for local runs in the dev container only:
SSL_CERT_FILEis set to an empty string there, which breaks rustls cert loading for HTTPS tests; run withenv -u SSL_CERT_FILE. Unit tests usefile://fixtures and are unaffected.
Review Focus
- 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). - 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 testsync_preserves_user_hooks). - 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 6refresh_failure_keeps_snapshot). - Container not ready / sync script fails → container start still succeeds; the report is stored and surfaced (Task 9
sync_failure_does_not_fail_start). - 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 testsrejects_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 → CatalogItems; 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
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<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Marketplace {
pub id: String,
pub name: String,
pub url: String,
#[serde(default)]
pub branch: Option<String>,
#[serde(default)]
pub account_id: Option<String>,
}
#[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<MarketplaceInstall>;
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<String>,
/// hooks only: rendered commands with ${HOOK_DIR} substituted
#[serde(default)]
pub hook_commands: Vec<String>,
/// 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<String>,
/// RFC 3339
pub fetched_at: Option<String>,
pub fetch_error: Option<String>,
pub items: Vec<CatalogItem>,
}
#[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<String>,
}
#[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<String>,
#[serde(default)] pub updated: Vec<String>,
#[serde(default)] pub removed: Vec<String>,
#[serde(default)] pub skipped: Vec<SkippedItem>,
#[serde(default)] pub errors: Vec<String>,
/// RFC 3339, set by the host
#[serde(default)] pub finished_at: String,
}
Item strings in SyncReport are "<kind>:<key>" (e.g. "agent:code-reviewer").
New fields: AppSettings { marketplace_accounts: Vec<MarketplaceAccount>, marketplaces: Vec<Marketplace>, global_marketplace_installs: Vec<MarketplaceInstall> }; Project { marketplace_installs: Vec<MarketplaceInstall>, marketplace_disabled: Vec<MarketplaceItemRef> } — all #[serde(default)], listed explicitly in Default impls / project constructors.
src/marketplace/tree.rs
#[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<Option<Vec<DirEntry>>, String>;
/// Contents of the regular file at `path`. Ok(None) if absent or not a file.
fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>, String>;
/// Stable content id of the entry at `path` (git object id hex); None if absent.
fn entry_id(&self, path: &str) -> Result<Option<String>, 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<Self, String>;
}
impl TreeView for GitTree {}
src/marketplace/catalog.rs
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<u8>, 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<CatalogItem>;
/// All files of one item. Err if the item is missing/invalid or breaks the limits.
pub fn item_files(tree: &dyn TreeView, kind: ItemKind, key: &str) -> Result<Vec<ItemFile>, String>;
/// 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<Option<String>, String>;
/// Plugins only: the plugin's entry from plugins/.claude-plugin/marketplace.json.
pub fn plugin_catalog_entry(tree: &dyn TreeView, key: &str) -> Result<serde_json::Value, String>;
/// Hooks only: parsed hook.json `hooks` object with ${HOOK_DIR} substituted.
pub fn rendered_hook_settings(tree: &dyn TreeView, key: &str) -> Result<serde_json::Value, String>;
pub fn hook_dir(key: &str) -> String; // "/home/claude/.claude/triple-c/hooks/<key>"
src/marketplace/git.rs (blocking; call from tokio::task::spawn_blocking)
#[derive(Clone)]
pub struct Credential { pub username: String, pub password: String }
impl std::fmt::Debug for Credential { /* prints username and "<redacted>" */ }
#[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<Credential>) -> Result<String, FetchError>;
/// Current refs/triple-c/head, if fetched before.
pub fn cached_head(repo_path: &std::path::Path) -> Result<Option<String>, 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
#[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<String, String>; // 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 <host>`; others → keychain.
pub async fn resolve_credential(account: &MarketplaceAccount) -> Result<crate::marketplace::git::Credential, String>;
/// "Who am I" check; returns the login name.
pub async fn validate_token(host: &str, token: &str) -> Result<String, String>;
pub async fn gh_host_available() -> bool;
pub async fn gh_host_login(host: &str) -> Result<String, String>; // `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:
pub fn store_marketplace_token(account_id: &str, token: &str) -> Result<(), String>;
pub fn get_marketplace_token(account_id: &str) -> Result<Option<String>, 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)
/// 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<String, String>;
/// 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
pub fn item_diff(repo_path: &std::path::Path, kind: ItemKind, key: &str, from_commit: &str, to_commit: &str) -> Result<Vec<FileDiff>, String>;
src/marketplace/payload.rs
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<u8>, pub manifest: serde_json::Value, pub skipped: Vec<SkippedItem> }
/// Build the tar described in spec §4. Items whose marketplace/cache/commit is missing go to `skipped`.
pub fn build_payload(input: &PayloadInput) -> Result<Payload, String>;
Manifest shape (manifest.json at the tar root):
{
"version": 1,
"items": [
{ "kind": "agent", "key": "code-reviewer", "marketplace": "<id>", "commit": "<sha>", "file": "agents/code-reviewer.md" },
{ "kind": "skill", "key": "example-skill", "marketplace": "<id>", "commit": "<sha>", "dir": "skills/example-skill" },
{ "kind": "command", "key": "example-command", "marketplace": "<id>", "commit": "<sha>", "file": "commands/example-command.md" },
{ "kind": "hook", "key": "notify-on-stop", "marketplace": "<id>", "commit": "<sha>", "dir": "hooks/notify-on-stop", "settings": { "Stop": [ ... ] } },
{ "kind": "plugin", "key": "example-plugin", "marketplace": "<id>", "commit": "<sha>", "slug": "<slug>" }
],
"plugin_marketplaces": [ { "slug": "<slug>", "dir": "plugins/<slug>", "plugins": ["example-plugin"] } ]
}
Tar layout: agents/<key>.md, skills/<key>/…, commands/<key>.md, hooks/<key>/…, plugins/<slug>/.claude-plugin/marketplace.json (generated: {"name":"triple-c-<slug>","owner":{"name":"Triple-C"},"plugins":[<entries with source rewritten to "./<key>">]}), plugins/<slug>/<key>/…, manifest.json. Modes: 0644, or 0755 when executable; dirs 0755.
src/marketplace/sync.rs
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<SyncReport, String>;
/// Parse the script's stdout (last line is the JSON report).
pub fn parse_report(stdout: &str) -> Result<SyncReport, String>;
src/marketplace/mod.rs
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<HashMap<String, MarketplaceSnapshot>>, reports: Mutex<HashMap<String, SyncReport>>, gh_login_cancel: tokio::sync::Mutex<Option<oneshot::Sender<()>>>
}
impl MarketplaceManager {
pub fn new(data_root: std::path::PathBuf) -> Self; // data_root = <data_dir>/triple-c
pub fn data_root(&self) -> &std::path::Path;
pub fn snapshot(&self, marketplace_id: &str) -> Option<MarketplaceSnapshot>;
pub fn put_snapshot(&self, snap: MarketplaceSnapshot);
pub fn remove_snapshot(&self, marketplace_id: &str);
/// Reports are also persisted to <data_root>/marketplace-sync/<project_id>.json
pub fn report(&self, project_id: &str) -> Option<SyncReport>;
pub fn put_report(&self, project_id: &str, report: SyncReport);
pub async fn set_gh_login_cancel(&self, tx: Option<tokio::sync::oneshot::Sender<()>>) -> 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<ItemUpdate>;
/// 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<String, Vec<String>>;
/// 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<marketplace::MarketplaceManager>.
Tauri commands (src/commands/marketplace_commands.rs)
| Command | Args (Rust) | Returns |
|---|---|---|
list_marketplace_snapshots |
– | Vec<MarketplaceSnapshot> (one per configured marketplace; cached or empty) |
refresh_marketplaces |
marketplace_id: Option<String> |
Vec<MarketplaceSnapshot> |
add_marketplace |
name: String, url: String, branch: Option<String>, account_id: Option<String> |
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<ItemUpdate> |
marketplace_item_diff |
item: MarketplaceItemRef, from_commit: String, to_commit: String |
Vec<FileDiff> |
update_marketplace_item |
item: MarketplaceItemRef, scope: InstallScope |
() (moves that install's pin to head) |
apply_marketplace_now |
project_id: Option<String> |
Vec<ProjectSyncResult> (all running projects when None) |
get_marketplace_sync_report |
project_id: String |
Option<SyncReport> |
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 |
#[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)
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
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<ItemKind, string>; // Agents, Skills, Commands, Hooks, Plugins
Store (app/src/store/appState.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:
auth::validate_token(host, token)returnsResult<Option<String>, String>(notResult<String, String>).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 handleNone(store the account withusername: None).- 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 fixturesgit::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. GitTreelives intree.rsas the contract says, but is added in Task 4 together with thegixdependency; Task 3'stree.rshasTreeView+MemTreeonly.models/mod.rsgetspub mod marketplace; pub use marketplace::*;(checked: no name clashes with existing models).- Until Task 11 wires the new modules into commands,
cargo buildprints dead-code warnings for them. That is expected; do not addallowattributes.
From Tasks 6–11:
- Sync script is shipped by the app, not the image (lead correction).
container/is not touched. The script isapp/src-tauri/src/marketplace/sync.sh, embedded aspub const SYNC_SCRIPT: &str = include_str!("sync.sh");insync.rs, uploaded as<INCOMING_DIR>/sync.sh(mode 0755) next topayload.tarand run assh <INCOMING_DIR>/sync.sh.SYNC_SCRIPT_PATHandREADY_MARKERare dropped. Readiness = polling (every 2 s, ≤ 180 s, as root)pgrep -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null. - The script honours two env overrides used only by tests:
MARKETPLACE_INCOMING(default$HOME/.claude/triple-c/marketplace/incoming) andMARKETPLACE_LOCK(default/tmp/.triple-c-claude-update.lock). 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 tohttps://<host>/login/device. New pure helpersgh_login::extract_token(text) -> Option<String>andgh_login::take_display_lines(pending: &mut String, chunk: &str) -> String.gh auth loginruns with--git-protocol ssh --skip-ssh-keyandGIT_CONFIG_GLOBALinside the temp dir: withhttpsgh asks "Authenticate Git with your GitHub credentials?" and would write a credential helper into~/.gitconfig. The host reaches the script as$1(argv), becausecreate_attached_exec_ashas no env parameter.- New in
sync.rs:pub fn report_from_result(r: Result<SyncReport, String>) -> SyncReport. New inmarketplace/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<MarketplaceManager>, settings: AppSettings, project: Project, container_id: String),pub fn head_for(mgr: &MarketplaceManager, m: &Marketplace) -> Option<String>. - 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. AppState.marketplaceis wired in Task 6 (Task 9's start hook needs it); Task 11 only adds handlers and the startup refresh.#[cfg(test)] pub(crate) mod test_support;withGitFixtureis added in Task 6. If Task 4 already created an equivalent helper, keep one and adapt call sites.- Marketplace fields are store-owned:
update_settingsrestoresmarketplace_accounts,marketplaces,global_marketplace_installsfrom the stored settings, andupdate_projectrestoresmarketplace_installs,marketplace_disabled(a stale frontend copy must not undo an install).apply_settings_importwrites the imported marketplace fields explicitly after itsupdate_settingscall. remove_marketplace_accountrefuses while a marketplace uses the account.apply_marketplace_now(Some(id))errors when that project is not running.
From Tasks 1, 12–17:
- 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 andapply_marketplace_nowin Task 11). The frontend (Task 12useMarketplaceSyncToasts) toasts errors/skips from it. Tasks 9/11 must emit it viaapp_handle.emit("marketplace-sync-finished", serde_json::json!({ "project_id": id, "report": report })). lib/marketplace.tsgainsisStale(snapshot: MarketplaceSnapshot, now: number): boolean(nullfetched_ator older than 15 min) andformatItemRef(r: MarketplaceItemRef): string("<kind>:<key>", same format asSyncReportitem strings). Both are frontend-only.- GhContainerLoginModal cannot know the new account id before
startMarketplaceGhContainerLoginresolves, so it accepts everymarketplace-gh-login-code/marketplace-gh-login-outputevent while it is open. This is safe because the backend single-flights the login (MarketplaceManager::set_gh_login_cancelreturns false when one is running). Theaccount_idfield 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 branchmain) 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
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:
# 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/<name>.md Claude Code agent (front matter: name, description)
skills/<name>/SKILL.md (+ files) Claude Code skill folder
commands/<name>.md Claude Code slash command
hooks/<name>/hook.json (+ scripts) Triple-C hook manifest
plugins/.claude-plugin/marketplace.json
plugins/<plugin>/… 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/<name>.md`. `name` and
`description` come from the YAML front matter.
- **Skills** are installed to `~/.claude/skills/<name>/`. The folder must contain `SKILL.md`.
- **Commands** are installed to `~/.claude/commands/<name>.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/<name>`). 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:
---
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:
---
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:
---
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:
{
"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:
#!/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
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:
{
"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:
{
"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:
---
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:
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:
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
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 <noreply@anthropic.com>" && git log --oneline -1
Expected: one commit hash printed.
- Step 8: Publish to GitHub (public)
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:
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 +Defaultimpl) - Modify:
app/src-tauri/src/models/project.rs(two fields +Project::new) - Modify:
app/src/lib/types.ts(TS mirror +AppSettings/Projectfields) - Modify (fixtures that build a full
Projectliteral):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), plusItemKind::as_str(&self) -> &'static str. New fieldsAppSettings.marketplace_accounts: Vec<MarketplaceAccount>,AppSettings.marketplaces: Vec<Marketplace>,AppSettings.global_marketplace_installs: Vec<MarketplaceInstall>,Project.marketplace_installs: Vec<MarketplaceInstall>,Project.marketplace_disabled: Vec<MarketplaceItemRef>. Reachable ascrate::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:
#[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:
//! 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<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Marketplace {
pub id: String,
pub name: String,
pub url: String,
#[serde(default)]
pub branch: Option<String>,
#[serde(default)]
pub account_id: Option<String>,
}
#[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<MarketplaceInstall> {
let mut out: BTreeMap<MarketplaceItemRef, MarketplaceInstall> = 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<String>,
/// Hooks only: rendered commands with `${HOOK_DIR}` substituted.
#[serde(default)]
pub hook_commands: Vec<String>,
/// 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<String>,
/// RFC 3339.
pub fetched_at: Option<String>,
pub fetch_error: Option<String>,
pub items: Vec<CatalogItem>,
}
#[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<String>,
}
#[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<String>,
#[serde(default)]
pub updated: Vec<String>,
#[serde(default)]
pub removed: Vec<String>,
#[serde(default)]
pub skipped: Vec<SkippedItem>,
#[serde(default)]
pub errors: Vec<String>,
/// 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:
- Below
use super::gateway_settings::GatewaySettings;add:
use super::marketplace::{Marketplace, MarketplaceAccount, MarketplaceInstall};
- In
pub struct AppSettings, directly after theglobal_claude_code_settingsfield (pub global_claude_code_settings: Option<ClaudeCodeSettings>,) add:
/// 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<MarketplaceAccount>,
/// Marketplace git repos the user added.
#[serde(default)]
pub marketplaces: Vec<Marketplace>,
/// Items installed for every project (projects may opt out per item).
#[serde(default)]
pub global_marketplace_installs: Vec<MarketplaceInstall>,
- In
impl Default for AppSettings, afterglobal_claude_code_settings: None,add:
marketplace_accounts: Vec::new(),
marketplaces: Vec::new(),
global_marketplace_installs: Vec::new(),
In app/src-tauri/src/models/project.rs:
- In
pub struct Project, directly afterpub renamed_session_names: HashMap<String, String>,add:
/// Marketplace items installed for this project only (spec §2).
#[serde(default)]
pub marketplace_installs: Vec<super::marketplace::MarketplaceInstall>,
/// Global marketplace installs this project opts out of.
#[serde(default)]
pub marketplace_disabled: Vec<super::marketplace::MarketplaceItemRef>,
- In
Project::new, afterrenamed_session_names: HashMap::new(),add:
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"):
// ── 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:
marketplace_accounts: MarketplaceAccount[];
marketplaces: Marketplace[];
global_marketplace_installs: MarketplaceInstall[];
In export interface Project, after renamed_session_names: Record<string, string>; add:
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):
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
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 <noreply@anthropic.com>"
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;GitTreeis 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.rsandcatalog.rs
Interfaces:
-
Consumes:
crate::models::marketplace::{is_valid_item_key, CatalogItem, ItemKind}(Task 2). -
Produces:
marketplace::tree::{TreeView, DirEntry, EntryKind, MemTree}andmarketplace::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_pathis relative to the item root; for agents/commands it is"<key>.md".parse_catalogorder: agents, skills, commands, hooks, plugins. A brokenplugins/.claude-plugin/marketplace.jsonyields one invalidPluginitem 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:
//! 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:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mem_tree_lists_files_dirs_and_symlinks() {
let t = MemTree::new()
.file("agents/a.md", "x")
.exec_file("hooks/h/run.sh", "#!/bin/sh")
.symlink("agents/link.md", "a.md");
let root = t.list_dir("").unwrap().unwrap();
assert_eq!(
root.iter().map(|e| (e.name.as_str(), e.kind)).collect::<Vec<_>>(),
vec![("agents", EntryKind::Dir), ("hooks", EntryKind::Dir)]
);
let agents = t.list_dir("agents").unwrap().unwrap();
assert_eq!(agents[1].kind, EntryKind::Symlink);
let hook = t.list_dir("hooks/h").unwrap().unwrap();
assert!(hook[0].executable);
assert_eq!(t.list_dir("agents/a.md").unwrap(), None);
assert_eq!(t.list_dir("missing").unwrap(), None);
assert_eq!(t.read_file("agents/a.md").unwrap().unwrap(), b"x");
assert_eq!(t.read_file("agents").unwrap(), None);
}
#[test]
fn mem_tree_entry_id_changes_only_with_content() {
let a = MemTree::new().file("skills/s/SKILL.md", "one").file("agents/x.md", "x");
let b = MemTree::new().file("skills/s/SKILL.md", "one").file("agents/x.md", "changed");
let c = MemTree::new().file("skills/s/SKILL.md", "two").file("agents/x.md", "x");
assert_eq!(a.entry_id("skills/s").unwrap(), b.entry_id("skills/s").unwrap());
assert_ne!(a.entry_id("skills/s").unwrap(), c.entry_id("skills/s").unwrap());
assert_eq!(a.entry_id("nope").unwrap(), None);
}
}
Create app/src-tauri/src/marketplace/catalog.rs with only its tests for now:
#[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:
//! A read-only view of a repository tree at one commit.
//!
//! The catalog parser only ever talks to [`TreeView`], so it is tested
//! against [`MemTree`] with no git involved, and runs in production against
//! [`GitTree`], which reads git objects straight out of the bare cache.
use std::collections::BTreeMap;
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
File,
Dir,
Symlink,
/// Anything else git can hold (submodule commits). Never installable.
Other,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirEntry {
pub name: String,
pub kind: EntryKind,
pub executable: bool,
}
pub trait TreeView {
/// Entries of the directory at `path` (`""` = root). `Ok(None)` if absent or not a dir.
fn list_dir(&self, path: &str) -> Result<Option<Vec<DirEntry>>, String>;
/// Contents of the regular file at `path`. `Ok(None)` if absent or not a file.
fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>, String>;
/// Stable content id of the entry at `path`; `None` if absent.
fn entry_id(&self, path: &str) -> Result<Option<String>, String>;
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
#[derive(Debug, Clone)]
enum MemNode {
File { data: Vec<u8>, executable: bool },
Symlink { target: String },
}
/// In-memory tree for tests: path → node. Directories are implied by paths.
#[derive(Debug, Clone, Default)]
pub struct MemTree {
nodes: BTreeMap<String, MemNode>,
}
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<Option<Vec<DirEntry>>, String> {
if self.nodes.contains_key(path) || !self.is_dir(path) {
return Ok(None);
}
let prefix = if path.is_empty() { String::new() } else { format!("{}/", path) };
let mut out: BTreeMap<String, DirEntry> = BTreeMap::new();
for (key, node) in &self.nodes {
let Some(rest) = key.strip_prefix(&prefix) else { continue };
match rest.split_once('/') {
Some((dir, _)) => {
out.entry(dir.to_string()).or_insert(DirEntry {
name: dir.to_string(),
kind: EntryKind::Dir,
executable: false,
});
}
None => {
let (kind, executable) = match node {
MemNode::File { executable, .. } => (EntryKind::File, *executable),
MemNode::Symlink { .. } => (EntryKind::Symlink, false),
};
out.insert(rest.to_string(), DirEntry { name: rest.to_string(), kind, executable });
}
}
}
Ok(Some(out.into_values().collect()))
}
fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>, String> {
match self.nodes.get(path) {
Some(MemNode::File { data, .. }) => Ok(Some(data.clone())),
_ => Ok(None),
}
}
fn entry_id(&self, path: &str) -> Result<Option<String>, String> {
let mut hasher = Sha256::new();
let mut found = false;
let prefix = format!("{}/", path);
for (key, node) in &self.nodes {
if key != path && !key.starts_with(&prefix) {
continue;
}
found = true;
hasher.update(key.as_bytes());
hasher.update([0]);
match node {
MemNode::File { data, executable } => {
hasher.update([if *executable { b'x' } else { b'f' }]);
hasher.update(data);
}
MemNode::Symlink { target } => {
hasher.update(b"l");
hasher.update(target.as_bytes());
}
}
hasher.update([0]);
}
Ok(found.then(|| hex(&hasher.finalize())))
}
}
- Step 4: Implement
catalog.rs
Prepend to app/src-tauri/src/marketplace/catalog.rs, above its tests module:
//! Reading a marketplace repo: which items it offers, and the files of one item.
//!
//! Layout (spec §1): `agents/<key>.md`, `skills/<key>/SKILL.md`,
//! `commands/<key>.md`, `hooks/<key>/hook.json`, and `plugins/` as a standard
//! Claude Code marketplace. Every item is validated here — key pattern,
//! symlinks, size and file-count limits, plugin sources that stay inside
//! `plugins/` — so nothing downstream ever sees a name or a file it would
//! have to distrust. A broken item is listed with its reason; it never stops
//! the rest of the repo from loading.
use sha2::{Digest, Sha256};
use crate::marketplace::tree::{EntryKind, TreeView};
use crate::models::marketplace::{is_valid_item_key, CatalogItem, ItemKind};
pub const MAX_ITEM_BYTES: u64 = 2 * 1024 * 1024;
pub const MAX_ITEM_FILES: usize = 200;
/// Preview text is truncated to this many bytes (on a char boundary).
const MAX_PREVIEW_BYTES: usize = 64 * 1024;
const PLUGIN_CATALOG_PATH: &str = "plugins/.claude-plugin/marketplace.json";
/// Hook events Claude Code understands. A `hook.json` naming anything else is
/// invalid rather than silently ignored by Claude Code at runtime.
const HOOK_EVENTS: &[&str] = &[
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"PermissionRequest",
"Notification",
"UserPromptSubmit",
"SessionStart",
"SessionEnd",
"Stop",
"SubagentStart",
"SubagentStop",
"PreCompact",
];
/// One file of an item, path relative to the item root (for single-file
/// items: the file name).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ItemFile {
pub rel_path: String,
pub data: Vec<u8>,
pub executable: bool,
}
pub fn hook_dir(key: &str) -> String {
format!("/home/claude/.claude/triple-c/hooks/{}", key)
}
// ─────────────────────────────────────────────────────────────────────────────
// Parsing helpers
// ─────────────────────────────────────────────────────────────────────────────
/// Minimal YAML front matter: `key: value` lines between leading `---`
/// fences. Returns `(fields, body)`. Quotes around values are stripped. No
/// front matter → no fields, the whole text is the body.
fn front_matter(text: &str) -> (Vec<(String, String)>, &str) {
let rest = match text.strip_prefix("---\n").or_else(|| text.strip_prefix("---\r\n")) {
Some(rest) => rest,
None => return (Vec::new(), text),
};
let mut fields = Vec::new();
let mut offset = 0;
for line in rest.split_inclusive('\n') {
offset += line.len();
let trimmed = line.trim_end_matches(['\n', '\r']);
if trimmed == "---" {
return (fields, &rest[offset..]);
}
if let Some((k, v)) = trimmed.split_once(':') {
let k = k.trim();
if !k.is_empty() && !k.starts_with(' ') && !line.starts_with(' ') {
let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
fields.push((k.to_string(), v));
}
}
}
// Unterminated front matter: treat the whole file as body.
(Vec::new(), text)
}
fn field<'a>(fields: &'a [(String, String)], name: &str) -> Option<&'a str> {
fields
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
.filter(|v| !v.is_empty())
}
fn truncate_preview(text: &str) -> String {
if text.len() <= MAX_PREVIEW_BYTES {
return text.to_string();
}
let mut cut = MAX_PREVIEW_BYTES;
while !text.is_char_boundary(cut) {
cut -= 1;
}
format!("{}\n…(truncated)", &text[..cut])
}
fn read_utf8(tree: &dyn TreeView, path: &str) -> Result<Option<String>, String> {
match tree.read_file(path)? {
None => Ok(None),
Some(bytes) => String::from_utf8(bytes)
.map(Some)
.map_err(|_| format!("{} is not UTF-8 text", path)),
}
}
/// Normalise a plugin `source` into a path under `plugins/`, refusing
/// anything that is not a plain relative path staying inside `plugins/`.
fn plugin_source_path(source: &serde_json::Value) -> Result<String, String> {
let source = source.as_str().ok_or_else(|| {
"remote plugin sources are not supported — the plugin must live in this repo's plugins/ folder"
.to_string()
})?;
if source.starts_with('/') || source.contains('\\') || source.contains(':') {
return Err(format!("plugin source {:?} must be a relative path inside plugins/", source));
}
let mut parts = Vec::new();
for part in source.split('/') {
match part {
"" | "." => {}
".." => {
return Err(format!("plugin source {:?} must stay inside plugins/", source));
}
p => parts.push(p),
}
}
if parts.is_empty() {
return Err(format!("plugin source {:?} must name a folder inside plugins/", source));
}
Ok(format!("plugins/{}", parts.join("/")))
}
fn read_plugin_catalog(tree: &dyn TreeView) -> Result<Option<Vec<serde_json::Value>>, String> {
let Some(text) = read_utf8(tree, PLUGIN_CATALOG_PATH)? else { return Ok(None) };
let json: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| format!("{} is not valid JSON: {}", PLUGIN_CATALOG_PATH, e))?;
let plugins = json
.get("plugins")
.and_then(|p| p.as_array())
.ok_or_else(|| format!("{} has no \"plugins\" array", PLUGIN_CATALOG_PATH))?;
Ok(Some(plugins.clone()))
}
/// Plugins only: the plugin's entry from `plugins/.claude-plugin/marketplace.json`.
pub fn plugin_catalog_entry(tree: &dyn TreeView, key: &str) -> Result<serde_json::Value, String> {
let entries = read_plugin_catalog(tree)?
.ok_or_else(|| format!("{} is missing", PLUGIN_CATALOG_PATH))?;
entries
.into_iter()
.find(|e| e.get("name").and_then(|n| n.as_str()) == Some(key))
.ok_or_else(|| format!("plugin {} is not in {}", key, PLUGIN_CATALOG_PATH))
}
/// Repo path of an item: a file for agents/commands, a folder otherwise.
fn item_path(tree: &dyn TreeView, kind: ItemKind, key: &str) -> Result<String, String> {
if !is_valid_item_key(key) {
return Err(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<ItemFile>,
total: &mut u64,
) -> Result<(), String> {
let path = if rel.is_empty() { root.to_string() } else { format!("{}/{}", root, rel) };
let entries = tree
.list_dir(&path)?
.ok_or_else(|| format!("{} is not a folder", path))?;
for entry in entries {
let child_rel = if rel.is_empty() { entry.name.clone() } else { format!("{}/{}", rel, entry.name) };
match entry.kind {
EntryKind::Symlink => {
return Err(format!("contains a symlink ({}), which is not allowed", child_rel));
}
EntryKind::Other => {
return Err(format!("contains a submodule or special entry ({})", child_rel));
}
EntryKind::Dir => collect_dir(tree, root, &child_rel, out, total)?,
EntryKind::File => {
let data = tree
.read_file(&format!("{}/{}", root, child_rel))?
.ok_or_else(|| format!("{} vanished while reading", child_rel))?;
*total += data.len() as u64;
if out.len() + 1 > MAX_ITEM_FILES {
return Err(format!("has more than {} files", MAX_ITEM_FILES));
}
if *total > MAX_ITEM_BYTES {
return Err(format!("is larger than {} MiB", MAX_ITEM_BYTES / (1024 * 1024)));
}
out.push(ItemFile { rel_path: child_rel, data, executable: entry.executable });
}
}
}
Ok(())
}
/// Kind of the entry at `path`, looked up through its parent listing.
fn entry_kind(tree: &dyn TreeView, path: &str) -> Result<Option<(EntryKind, bool)>, String> {
let (parent, name) = match path.rsplit_once('/') {
Some((p, n)) => (p, n),
None => ("", path),
};
Ok(tree
.list_dir(parent)?
.and_then(|entries| entries.into_iter().find(|e| e.name == name))
.map(|e| (e.kind, e.executable)))
}
/// All files of one item. Err if the item is missing/invalid or breaks the limits.
pub fn item_files(tree: &dyn TreeView, kind: ItemKind, key: &str) -> Result<Vec<ItemFile>, String> {
let path = item_path(tree, kind, key)?;
match kind {
ItemKind::Agent | ItemKind::Command => {
let (entry, executable) = entry_kind(tree, &path)?
.ok_or_else(|| format!("{} is missing", path))?;
match entry {
EntryKind::File => {}
EntryKind::Symlink => return Err(format!("{} is a symlink, which is not allowed", path)),
_ => return Err(format!("{} is not a regular file", path)),
}
let data = tree.read_file(&path)?.ok_or_else(|| format!("{} is missing", path))?;
if data.len() as u64 > MAX_ITEM_BYTES {
return Err(format!("is larger than {} MiB", MAX_ITEM_BYTES / (1024 * 1024)));
}
Ok(vec![ItemFile { rel_path: format!("{}.md", key), data, executable }])
}
ItemKind::Skill | ItemKind::Hook | ItemKind::Plugin => {
match entry_kind(tree, &path)? {
Some((EntryKind::Dir, _)) => {}
Some((EntryKind::Symlink, _)) => {
return Err(format!("{} is a symlink, which is not allowed", path))
}
Some(_) => return Err(format!("{} is not a folder", path)),
None => return Err(format!("{} is missing", path)),
}
let mut out = Vec::new();
let mut total = 0u64;
collect_dir(tree, &path, "", &mut out, &mut total)?;
let required = match kind {
ItemKind::Skill => Some("SKILL.md"),
ItemKind::Hook => Some("hook.json"),
_ => None,
};
if let Some(required) = required {
if !out.iter().any(|f| f.rel_path == required) {
return Err(format!("{} has no {}", path, required));
}
}
Ok(out)
}
}
}
/// Content fingerprint for update detection: changes iff the item's files or,
/// for plugins, its catalog entry change. `Ok(None)` when the item is absent.
pub fn item_fingerprint(tree: &dyn TreeView, kind: ItemKind, key: &str) -> Result<Option<String>, String> {
if kind == ItemKind::Plugin {
let entry = match plugin_catalog_entry(tree, key) {
Ok(entry) => entry,
Err(_) => return Ok(None),
};
let path = match plugin_source_path(entry.get("source").unwrap_or(&serde_json::Value::Null)) {
Ok(path) => path,
Err(_) => return Ok(None),
};
let Some(dir_id) = tree.entry_id(&path)? else { return Ok(None) };
let mut hasher = Sha256::new();
hasher.update(dir_id.as_bytes());
hasher.update([0]);
// serde_json's Map is ordered by key (no preserve_order), so this is canonical.
hasher.update(entry.to_string().as_bytes());
return Ok(Some(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<Vec<String>, String> {
let map = hooks
.as_object()
.ok_or_else(|| "\"hooks\" must be an object keyed by event name".to_string())?;
if map.is_empty() {
return Err("\"hooks\" is empty".to_string());
}
let mut commands = Vec::new();
for (event, matchers) in map {
if !HOOK_EVENTS.contains(&event.as_str()) {
return Err(format!("unknown hook event {:?}", event));
}
let matchers = matchers
.as_array()
.ok_or_else(|| format!("\"{}\" must be an array", event))?;
for matcher in matchers {
let handlers = matcher
.get("hooks")
.and_then(|h| h.as_array())
.ok_or_else(|| format!("each \"{}\" entry needs a \"hooks\" array", event))?;
for handler in handlers {
let kind = handler.get("type").and_then(|t| t.as_str()).unwrap_or("");
if kind.is_empty() {
return Err(format!("a \"{}\" hook has no \"type\"", event));
}
if kind == "command" {
let command = handler
.get("command")
.and_then(|c| c.as_str())
.filter(|c| !c.trim().is_empty())
.ok_or_else(|| format!("a \"{}\" command hook has no \"command\"", event))?;
commands.push(command.to_string());
}
}
}
}
Ok(commands)
}
fn read_hook_json(tree: &dyn TreeView, key: &str) -> Result<serde_json::Value, String> {
let path = format!("hooks/{}/hook.json", key);
let text = read_utf8(tree, &path)?.ok_or_else(|| format!("{} is missing", path))?;
serde_json::from_str(&text).map_err(|e| format!("{} is not valid JSON: {}", path, e))
}
/// Hooks only: the parsed `hooks` object with `${HOOK_DIR}` substituted.
pub fn rendered_hook_settings(tree: &dyn TreeView, key: &str) -> Result<serde_json::Value, String> {
if !is_valid_item_key(key) {
return Err(format!("{:?} is not a valid hook name", key));
}
let json = read_hook_json(tree, key)?;
let mut hooks = json
.get("hooks")
.cloned()
.ok_or_else(|| format!("hooks/{}/hook.json has no \"hooks\" object", key))?;
validate_hooks(&hooks)?;
substitute_hook_dir(&mut hooks, &hook_dir(key));
Ok(hooks)
}
// ─────────────────────────────────────────────────────────────────────────────
// Catalog
// ─────────────────────────────────────────────────────────────────────────────
fn item(kind: ItemKind, key: &str, path: String) -> CatalogItem {
CatalogItem {
kind,
key: key.to_string(),
name: key.to_string(),
description: String::new(),
path,
invalid: None,
hook_commands: Vec::new(),
preview: String::new(),
}
}
/// Fill name/description/preview from a markdown file with front matter.
fn describe_markdown(it: &mut CatalogItem, text: &str, first_line_fallback: bool) {
let (fields, body) = front_matter(text);
if let Some(name) = field(&fields, "name") {
it.name = name.to_string();
}
if let Some(desc) = field(&fields, "description") {
it.description = desc.to_string();
} else if first_line_fallback {
if let Some(line) = body.lines().map(str::trim).find(|l| !l.is_empty()) {
it.description = line.trim_start_matches('#').trim().to_string();
}
}
it.preview = truncate_preview(body.trim_start_matches(['\n', '\r']));
}
/// Mark `it` invalid when its files break the rules.
fn validate_files(tree: &dyn TreeView, it: &mut CatalogItem) {
if it.invalid.is_some() {
return;
}
if let Err(reason) = item_files(tree, it.kind, &it.key) {
it.invalid = Some(reason);
}
}
fn parse_single_files(tree: &dyn TreeView, kind: ItemKind, folder: &str, out: &mut Vec<CatalogItem>) {
let entries = match tree.list_dir(folder) {
Ok(Some(entries)) => entries,
Ok(None) => return,
Err(e) => {
let mut it = item(kind, folder, folder.to_string());
it.invalid = Some(e);
out.push(it);
return;
}
};
for entry in entries {
let Some(stem) = entry.name.strip_suffix(".md") else { continue };
let mut it = item(kind, stem, format!("{}/{}", folder, entry.name));
if !is_valid_item_key(stem) {
it.invalid = Some(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<CatalogItem>) {
let entries = match tree.list_dir(folder) {
Ok(Some(entries)) => entries,
Ok(None) => return,
Err(e) => {
let mut it = item(kind, folder, folder.to_string());
it.invalid = Some(e);
out.push(it);
return;
}
};
for entry in entries {
if entry.kind == EntryKind::File {
continue; // e.g. a README.md next to the item folders
}
let mut it = item(kind, &entry.name, format!("{}/{}", folder, entry.name));
if !is_valid_item_key(&entry.name) {
it.invalid = Some(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<CatalogItem>) {
let entries = match read_plugin_catalog(tree) {
Ok(Some(entries)) => entries,
Ok(None) => return,
Err(e) => {
let mut it = item(ItemKind::Plugin, "catalog", PLUGIN_CATALOG_PATH.to_string());
it.name = PLUGIN_CATALOG_PATH.to_string();
it.invalid = Some(e);
out.push(it);
return;
}
};
for entry in entries {
let key = entry.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string();
let mut it = item(ItemKind::Plugin, &key, PLUGIN_CATALOG_PATH.to_string());
if let Some(desc) = entry.get("description").and_then(|d| d.as_str()) {
it.description = desc.to_string();
}
if !is_valid_item_key(&key) {
it.invalid = Some(format!("plugin name {:?} is not a valid name", key));
out.push(it);
continue;
}
match plugin_source_path(entry.get("source").unwrap_or(&serde_json::Value::Null)) {
Ok(path) => {
it.path = path.clone();
if let Ok(Some(children)) = tree.list_dir(&path) {
it.preview = children
.iter()
.map(|c| if c.kind == EntryKind::Dir { format!("{}/", c.name) } else { c.name.clone() })
.collect::<Vec<_>>()
.join("\n");
}
}
Err(e) => it.invalid = Some(e),
}
validate_files(tree, &mut it);
out.push(it);
}
}
/// Parse every item in the repo. Never fails as a whole; broken items carry `invalid`.
/// Order: agents, skills, commands, hooks, plugins; each in listing order.
pub fn parse_catalog(tree: &dyn TreeView) -> Vec<CatalogItem> {
let mut out = Vec::new();
parse_single_files(tree, ItemKind::Agent, "agents", &mut out);
parse_folders(tree, ItemKind::Skill, "skills", &mut out);
parse_single_files(tree, ItemKind::Command, "commands", &mut out);
parse_folders(tree, ItemKind::Hook, "hooks", &mut out);
parse_plugins(tree, &mut out);
out
}
- 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
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"
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 <noreply@anthropic.com>"
Task 4: Git cache (gix fetch, pins, GitTree)
Files:
- Modify:
app/src-tauri/Cargo.toml(gix, dev-deptempfile),app/src-tauri/Cargo.lock(generated) - Create:
app/src-tauri/src/marketplace/git.rs - Modify:
app/src-tauri/src/marketplace/tree.rs(addGitTree) - Modify:
app/src-tauri/src/marketplace/mod.rs(pub mod git;) - Test: unit tests in
git.rs(fixture repos built with the git CLI overfile://; each test returns early whengitis 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).fetchstores the tip atrefs/triple-c/headvia refspec+HEAD:refs/triple-c/head(default branch) or+refs/heads/<branch>:refs/triple-c/head; branch names outside[A-Za-z0-9._/-](or containing.., leading-//, trailing/or.lock) are refused withFetchError::Other("… is not a valid branch name").FetchErrormapping:HTTP status 401ornot accepted by the remote→Auth{401},HTTP status 403→Auth{403},HTTP status 404/repository not found→NotFound, dns/connect/timeout text →Network, elseOther. 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:
# 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:
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:
#[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("<redacted>"));
}
#[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<String> {
let r = gix::open(repo).unwrap();
let mut names: Vec<String> = 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
GitTreetotree.rs
In app/src-tauri/src/marketplace/tree.rs, insert this block immediately above #[cfg(test)]:
/// 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<Self, String> {
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<gix::Tree<'_>, 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<Option<(gix::ObjectId, gix::object::tree::EntryMode)>, 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<Option<Vec<DirEntry>>, 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<Option<Vec<u8>>, 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<Option<String>, 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:
//! 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", &"<redacted>")
.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: std::fmt::Debug>(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<gix::Repository, FetchError> {
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<Credential>,
) -> Result<String, FetchError> {
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<Option<String>, 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
cd /workspace/triple-c/app/src-tauri
rustfmt --edition 2021 src/marketplace/git.rs src/marketplace/tree.rs src/marketplace/mod.rs
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 <noreply@anthropic.com>"
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 localaxumserver stands in for the GitHub/Gitea/GitLab APIs) andsecure.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<Option<String>, String>, delete_marketplace_token(account_id)}(servicetriple-c-marketplace-account-<id>, ids must match[A-Za-z0-9-]{1,64});auth::{HostKind, host_kind, host_of, fetch_username, resolve_credential, validate_token /* -> Result<Option<String>, String> */, gh_host_available, gh_host_login, describe_fetch_error}.host_ofrejects non-https URLs and URLs with embedded credentials.resolve_credentialerrors (user-facing): GhHost not logged in → "gh on this computer is not logged in to . Rungh auth login --hostname <host>in a terminal, then try again."; missing keychain token → "No token is stored for the account "". Remove it and sign in again." -
Step 1: Write the failing tests
In app/src-tauri/src/storage/secure.rs, add this test inside the existing #[cfg(test)] mod tests { … } (after an_unlisted_key_cannot_be_stored_at_all):
/// Account ids become part of a keychain service name, so a malformed one
/// is refused before any entry is constructed — and so before the
/// keychain is touched, which is also what lets this run in CI.
#[test]
fn marketplace_token_ids_are_validated_before_the_keychain() {
for bad in ["", "../x", "a b", "x;y", &"a".repeat(65)] {
let err = store_marketplace_token(bad, "test-token-not-real").unwrap_err();
assert!(err.contains("Invalid marketplace account id"), "{bad:?}: {err}");
assert!(!err.contains("test-token-not-real"));
assert!(get_marketplace_token(bad).is_err());
assert!(delete_marketplace_token(bad).is_err());
}
let err = store_marketplace_token("0b9e6a2c-1111-4222-8333-944445555666", " ").unwrap_err();
assert!(err.contains("empty"));
}
Add pub mod auth; to app/src-tauri/src/marketplace/mod.rs (alphabetical: auth, catalog, git, tree).
Create app/src-tauri/src/marketplace/auth.rs with only its tests for now:
#[cfg(test)]
mod tests {
use super::*;
fn account(host: &str, username: Option<&str>) -> MarketplaceAccount {
MarketplaceAccount {
id: "acc-1".into(),
label: "Work".into(),
host: host.into(),
method: AccountMethod::Token,
username: username.map(str::to_string),
}
}
#[test]
fn host_of_accepts_https_only() {
assert_eq!(host_of("https://GitHub.com/a/b.git").unwrap(), "github.com");
assert_eq!(host_of("https://git.example.com:8443/a/b").unwrap(), "git.example.com:8443");
assert!(host_of("http://github.com/a/b").is_err());
assert!(host_of("git@github.com:a/b.git").is_err());
assert!(host_of("file:///tmp/x").is_err());
let err = host_of("https://user:test-token-not-real@github.com/a/b").unwrap_err();
assert!(!err.contains("test-token-not-real"));
}
#[test]
fn fetch_username_per_host() {
assert_eq!(fetch_username(&account("github.com", Some("me"))), "x-access-token");
assert_eq!(fetch_username(&account("repo.example.net", Some("jk"))), "jk");
assert_eq!(fetch_username(&account("repo.example.net", None)), "oauth2");
assert_eq!(fetch_username(&account("repo.example.net", Some(" "))), "oauth2");
}
#[test]
fn host_kinds() {
assert_eq!(host_kind("GITHUB.com"), HostKind::GitHub);
assert_eq!(host_kind("gitlab.com"), HostKind::GitLab);
assert_eq!(host_kind("repo.example.net"), HostKind::Unknown);
}
#[test]
fn describe_access_errors_names_account_and_org_causes() {
let url = "https://github.com/acme/private-market.git";
let msg = describe_fetch_error(&FetchError::Auth { status: 403 }, Some(&account("github.com", Some("me"))), url);
assert!(msg.contains("\"Work\" (me)"), "{}", msg);
assert!(msg.contains("HTTP 403"));
assert!(msg.contains("third-party app access"));
assert!(msg.contains("single sign-on"));
assert!(msg.contains("fine-grained"));
let anon = describe_fetch_error(&FetchError::NotFound, None, url);
assert!(anon.contains("anonymously"));
assert!(anon.contains("may be private"));
let gitea = describe_fetch_error(
&FetchError::Auth { status: 401 },
Some(&account("repo.example.net", None)),
"https://repo.example.net/o/r.git",
);
assert!(!gitea.contains("single sign-on"));
assert!(gitea.contains("expired"));
}
#[test]
fn describe_network_and_other_errors() {
let msg = describe_fetch_error(&FetchError::Network("dns error".into()), None, "https://github.com/a/b");
assert!(msg.contains("Could not reach github.com"));
assert!(msg.contains("last fetched copy"));
let msg = describe_fetch_error(&FetchError::Other("weird".into()), None, "https://github.com/a/b");
assert!(msg.contains("weird"));
}
// ── validate_token against a local mock API ──────────────────────────────
const FAKE: &str = "test-token-not-real";
async fn serve(app: axum::Router) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{}", addr)
}
fn authorised(headers: &axum::http::HeaderMap, name: &str, want: &str) -> bool {
headers.get(name).and_then(|v| v.to_str().ok()) == Some(want)
}
#[tokio::test]
async fn github_token_returns_login_or_is_rejected() {
use axum::{http::HeaderMap, http::StatusCode, routing::get, Json, Router};
let app = Router::new().route(
"/user",
get(|headers: HeaderMap| async move {
if authorised(&headers, "authorization", &format!("Bearer {}", FAKE)) {
Ok(Json(serde_json::json!({ "login": "octo" })))
} else {
Err(StatusCode::UNAUTHORIZED)
}
}),
);
let base = serve(app).await;
assert_eq!(
validate_token_at("github.com", Some(&base), "unused", FAKE).await.unwrap(),
Some("octo".to_string())
);
let err = validate_token_at("github.com", Some(&base), "unused", "wrong").await.unwrap_err();
assert!(err.contains("HTTP 401"), "{}", err);
assert!(!err.contains("wrong"), "the token must not appear in the error");
}
#[tokio::test]
async fn gitea_is_detected_first() {
use axum::{http::HeaderMap, http::StatusCode, routing::get, Json, Router};
let app = Router::new().route(
"/api/v1/user",
get(|headers: HeaderMap| async move {
if authorised(&headers, "authorization", &format!("token {}", FAKE)) {
Ok(Json(serde_json::json!({ "login": "jk" })))
} else {
Err(StatusCode::UNAUTHORIZED)
}
}),
);
let site = serve(app).await;
assert_eq!(validate_token_at("h", None, &site, FAKE).await.unwrap(), Some("jk".to_string()));
assert!(validate_token_at("h", None, &site, "wrong").await.is_err());
}
#[tokio::test]
async fn gitlab_is_tried_after_gitea_404() {
use axum::{http::HeaderMap, http::StatusCode, routing::get, Json, Router};
let app = Router::new().route(
"/api/v4/user",
get(|headers: HeaderMap| async move {
if authorised(&headers, "private-token", FAKE) {
Ok(Json(serde_json::json!({ "username": "gl-user" })))
} else {
Err(StatusCode::UNAUTHORIZED)
}
}),
);
let site = serve(app).await;
assert_eq!(validate_token_at("h", None, &site, FAKE).await.unwrap(), Some("gl-user".to_string()));
}
#[tokio::test]
async fn unknown_host_is_left_unchecked() {
let site = serve(axum::Router::new()).await; // every path 404s
assert_eq!(validate_token_at("h", None, &site, FAKE).await.unwrap(), None);
}
#[tokio::test]
async fn validate_token_refuses_bad_input_without_network() {
assert!(validate_token("-evil", FAKE).await.is_err());
assert!(validate_token("github.com", " ").await.is_err());
}
}
- Step 2: Run the tests to verify they fail
Run: cd /workspace/triple-c/app/src-tauri && cargo test --lib marketplace::auth 2>&1 | tail -20 (the crate fails to compile as a whole, so this one run shows both files' errors)
Expected: compile errors, e.g. "cannot find function store_marketplace_token", "cannot find function host_of".
- Step 3: Implement
Append to app/src-tauri/src/storage/secure.rs, directly above its #[cfg(test)] line:
// ─────────────────────────────────────────────────────────────────────────────
// Marketplace account tokens (global, one entry per account)
// ─────────────────────────────────────────────────────────────────────────────
/// Keychain service prefix; the account id completes it.
const MARKETPLACE_TOKEN_SERVICE_PREFIX: &str = "triple-c-marketplace-account-";
/// The service name for one account. Ids are uuids; anything else is refused
/// before a keychain entry is constructed.
fn marketplace_token_service(account_id: &str) -> Result<String, String> {
let ok = !account_id.is_empty()
&& account_id.len() <= 64
&& account_id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-');
if !ok {
return Err(format!("Invalid marketplace account id {:?}", account_id));
}
Ok(format!("{}{}", MARKETPLACE_TOKEN_SERVICE_PREFIX, account_id))
}
pub fn store_marketplace_token(account_id: &str, token: &str) -> Result<(), String> {
let service = marketplace_token_service(account_id)?;
if token.trim().is_empty() {
return Err("Refusing to store an empty marketplace token.".to_string());
}
let entry = keyring::Entry::new(&service, KEYCHAIN_ACCOUNT)
.map_err(|e| format!("Keyring error: {}", e))?;
entry
.set_password(token.trim())
.map_err(|e| format!("Failed to store the marketplace account token: {}", e))
}
pub fn get_marketplace_token(account_id: &str) -> Result<Option<String>, String> {
read_entry(&marketplace_token_service(account_id)?, "the marketplace account token")
}
pub fn delete_marketplace_token(account_id: &str) -> Result<(), String> {
delete_entry(&marketplace_token_service(account_id)?, "the marketplace account token")
}
Prepend to app/src-tauri/src/marketplace/auth.rs, above its tests module:
//! Marketplace accounts: where a fetch credential comes from, checking a
//! pasted token, and turning a failed fetch into advice a person can act on.
//!
//! Nothing here logs, returns or formats a token into an error string. A
//! `GhHost` account stores nothing at all: its token is asked of the host's
//! `gh` every time, so a later `gh auth refresh` or logout takes effect.
use std::time::Duration;
use crate::marketplace::git::{Credential, FetchError};
use crate::models::marketplace::{AccountMethod, MarketplaceAccount};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HostKind {
GitHub,
Gitea,
GitLab,
Unknown,
}
/// Known by name only; Gitea (and self-hosted GitLab) are recognised by
/// probing their API in [`validate_token`].
pub fn host_kind(host: &str) -> HostKind {
match host.to_ascii_lowercase().as_str() {
"github.com" => HostKind::GitHub,
"gitlab.com" => HostKind::GitLab,
_ => HostKind::Unknown,
}
}
/// `host[:port]` characters only — also what keeps a host safe as a `gh` argument.
fn valid_host(host: &str) -> bool {
!host.is_empty()
&& host.len() <= 253
&& !host.starts_with('-')
&& host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':'))
}
/// The host of an `https://` marketplace URL, lowercased, with a non-default port kept.
pub fn host_of(url: &str) -> Result<String, String> {
let parsed = url::Url::parse(url.trim()).map_err(|e| format!("{:?} is not a valid URL: {}", url, e))?;
if parsed.scheme() != "https" {
return Err("Only https:// marketplace URLs are supported.".to_string());
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(
"Put credentials in a marketplace account, not in the URL.".to_string(),
);
}
let host = parsed
.host_str()
.ok_or_else(|| format!("{:?} has no host", url))?
.to_ascii_lowercase();
let host = match parsed.port() {
Some(port) => format!("{}:{}", host, port),
None => host,
};
if !valid_host(&host) {
return Err(format!("{:?} is not a supported host name", host));
}
Ok(host)
}
/// The username sent with the token over HTTPS.
pub fn fetch_username(account: &MarketplaceAccount) -> String {
if host_kind(&account.host) == HostKind::GitHub {
return "x-access-token".to_string();
}
account
.username
.clone()
.filter(|u| !u.trim().is_empty())
.unwrap_or_else(|| "oauth2".to_string())
}
// ─────────────────────────────────────────────────────────────────────────────
// Host `gh`
// ─────────────────────────────────────────────────────────────────────────────
const GH_TIMEOUT: Duration = Duration::from_secs(15);
/// Run the host's `gh` with a plain argv (no shell) and return trimmed stdout.
async fn run_gh(args: &[&str]) -> Result<String, String> {
let mut cmd = tokio::process::Command::new("gh");
cmd.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let output = tokio::time::timeout(GH_TIMEOUT, cmd.output())
.await
.map_err(|_| "gh did not answer within 15 seconds".to_string())?
.map_err(|e| format!("Could not run gh: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(stderr.lines().next().unwrap_or("gh failed").trim().to_string());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub async fn gh_host_available() -> bool {
run_gh(&["--version"]).await.is_ok()
}
fn gh_login_instructions(host: &str) -> String {
format!(
"gh on this computer is not logged in to {host}. Run `gh auth login --hostname {host}` \
in a terminal, then try again.",
host = host
)
}
/// The login name `gh` on the host is signed in as for `host`.
pub async fn gh_host_login(host: &str) -> Result<String, String> {
if !valid_host(host) {
return Err(format!("{:?} is not a supported host name", host));
}
run_gh(&["auth", "status", "--hostname", host])
.await
.map_err(|_| gh_login_instructions(host))?;
let login = run_gh(&["api", "user", "--hostname", host, "--jq", ".login"]).await?;
if login.is_empty() {
return Err(gh_login_instructions(host));
}
Ok(login)
}
/// Resolve the credential for an account: `GhHost` → `gh auth token
/// --hostname <host>`; `GhContainer`/`Token` → the keychain.
pub async fn resolve_credential(account: &MarketplaceAccount) -> Result<Credential, String> {
let password = match account.method {
AccountMethod::GhHost => {
if !valid_host(&account.host) {
return Err(format!("{:?} is not a supported host name", account.host));
}
let token = run_gh(&["auth", "token", "--hostname", &account.host])
.await
.map_err(|_| gh_login_instructions(&account.host))?;
if token.is_empty() {
return Err(gh_login_instructions(&account.host));
}
token
}
AccountMethod::GhContainer | AccountMethod::Token => {
crate::storage::secure::get_marketplace_token(&account.id)?.ok_or_else(|| {
format!(
"No token is stored for the account \"{}\". Remove it and sign in again.",
account.label
)
})?
}
};
Ok(Credential { username: fetch_username(account), password })
}
// ─────────────────────────────────────────────────────────────────────────────
// Token validation
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Debug, PartialEq, Eq)]
enum Probe {
Login(String),
Rejected(u16),
NotThisKind,
}
fn http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.user_agent("Triple-C")
.timeout(Duration::from_secs(15))
.build()
.map_err(|e| format!("Could not create an HTTP client: {}", e))
}
/// One "who am I" call. `base` is the API root for GitHub
/// (`https://api.github.com`) and the site root for Gitea/GitLab.
async fn who_am_i(client: &reqwest::Client, kind: HostKind, base: &str, token: &str) -> Result<Probe, String> {
let (url, header, value, field) = match kind {
HostKind::GitHub => (format!("{}/user", base), "Authorization", format!("Bearer {}", token), "login"),
HostKind::Gitea => (format!("{}/api/v1/user", base), "Authorization", format!("token {}", token), "login"),
HostKind::GitLab => (format!("{}/api/v4/user", base), "PRIVATE-TOKEN", token.to_string(), "username"),
HostKind::Unknown => return Ok(Probe::NotThisKind),
};
let response = client
.get(&url)
.header(header, value)
.header("Accept", "application/json")
.send()
.await
// reqwest's error text carries the URL, never the header.
.map_err(|e| format!("Could not reach {}: {}", base, e.without_url()))?;
let status = response.status().as_u16();
match status {
200 => {
let json: serde_json::Value = match response.json().await {
Ok(json) => json,
Err(_) => return Ok(Probe::NotThisKind),
};
match json.get(field).and_then(|v| v.as_str()) {
Some(login) if !login.is_empty() => Ok(Probe::Login(login.to_string())),
_ => Ok(Probe::NotThisKind),
}
}
401 | 403 => Ok(Probe::Rejected(status)),
404 => Ok(Probe::NotThisKind),
other => Err(format!("{} answered HTTP {} when checking the token", base, other)),
}
}
fn rejected(host: &str, status: u16) -> String {
format!(
"{} rejected the token (HTTP {}). Check that it has not expired and can read repositories.",
host, status
)
}
/// GitHub is asked at `github_api`; anything else is probed as Gitea, then
/// GitLab, at `site`. `Ok(None)`: the host is neither, so the token could not
/// be checked here — the marketplace's test fetch checks it instead.
async fn validate_token_at(
host: &str,
github_api: Option<&str>,
site: &str,
token: &str,
) -> Result<Option<String>, String> {
let client = http_client()?;
if let Some(api) = github_api {
return match who_am_i(&client, HostKind::GitHub, api, token).await? {
Probe::Login(login) => Ok(Some(login)),
Probe::Rejected(status) => Err(rejected(host, status)),
Probe::NotThisKind => Err(format!("{} did not return a user for this token", host)),
};
}
for kind in [HostKind::Gitea, HostKind::GitLab] {
match who_am_i(&client, kind, site, token).await? {
Probe::Login(login) => return Ok(Some(login)),
Probe::Rejected(status) => return Err(rejected(host, status)),
Probe::NotThisKind => {}
}
}
Ok(None)
}
/// "Who am I" check for a pasted token. `Ok(Some(login))` when the host
/// confirmed it; `Ok(None)` when the host is not GitHub, Gitea or GitLab and
/// the token is left to the first fetch to prove.
pub async fn validate_token(host: &str, token: &str) -> Result<Option<String>, String> {
if !valid_host(host) {
return Err(format!("{:?} is not a supported host name", host));
}
if token.trim().is_empty() {
return Err("Paste a token first.".to_string());
}
let site = format!("https://{}", host);
match host_kind(host) {
HostKind::GitHub => validate_token_at(host, Some("https://api.github.com"), &site, token.trim()).await,
_ => validate_token_at(host, None, &site, token.trim()).await,
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Fetch errors
// ─────────────────────────────────────────────────────────────────────────────
fn who(account: Option<&MarketplaceAccount>) -> String {
match account {
None => "anonymously (no account)".to_string(),
Some(a) => match &a.username {
Some(u) if !u.is_empty() => format!("with the account \"{}\" ({})", a.label, u),
_ => format!("with the account \"{}\"", a.label),
},
}
}
/// User-facing message for a failed fetch, naming the account used and, for
/// access problems, the usual organisation causes with the page that fixes each.
pub fn describe_fetch_error(err: &FetchError, account: Option<&MarketplaceAccount>, url: &str) -> String {
let host = host_of(url).unwrap_or_else(|_| url.to_string());
match err {
FetchError::Auth { .. } | FetchError::NotFound => {
let what = match err {
FetchError::Auth { status } => format!("access was denied (HTTP {})", status),
_ => "the repository was not found".to_string(),
};
let mut msg = format!("Could not read {} {}: {}.", url, who(account), what);
if account.is_none() {
msg.push_str(
"\n• The repository may be private — choose an account that can read it.",
);
}
if host_kind(&host) == HostKind::GitHub {
msg.push_str(
"\n• The organization may restrict third-party app access and not have approved \
the GitHub CLI or your token: \
https://docs.github.com/en/organizations/managing-oauth-access-to-your-organizations-data/about-oauth-app-access-restrictions\
\n• If the organization uses SAML single sign-on, the token must be authorized for it: \
https://github.com/settings/tokens\
\n• A fine-grained token only reaches repositories of the owner it was created for: \
https://github.com/settings/personal-access-tokens",
);
} else if account.is_some() {
msg.push_str("\n• Check that the account's token has not expired and can read this repository.");
}
msg
}
FetchError::Network(m) => format!(
"Could not reach {}: {}. The last fetched copy is still used.",
host, m
),
FetchError::Other(m) => format!("Fetching {} failed: {}", url, m),
}
}
- Step 4: Run the tests to verify they pass
Run: cd /workspace/triple-c/app/src-tauri && cargo test --lib marketplace::auth && cargo test --lib storage::secure
Expected: all pass — 10 in marketplace::auth::tests (the five #[tokio::test] ones bind 127.0.0.1:0 and never leave the machine) and the new marketplace_token_ids_are_validated_before_the_keychain. No test prints a token.
- Step 5: Run the whole backend suite
Run: cd /workspace/triple-c/app/src-tauri && cargo test --lib 2>&1 | grep -E "^test result|FAILED"
Expected: test result: ok. and no FAILED.
- Step 6: Format and commit
cd /workspace/triple-c/app/src-tauri
rustfmt --edition 2021 src/marketplace/auth.rs src/marketplace/mod.rs
# secure.rs: format only the new block by hand if rustfmt would reflow unrelated code
cd /workspace/triple-c
git add app/src-tauri/src/storage/secure.rs app/src-tauri/src/marketplace/auth.rs app/src-tauri/src/marketplace/mod.rs
git commit -m "Marketplace: account credentials, token validation and fetch-error advice
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 6: Item diff, MarketplaceManager, refresh and update detection
Files:
- Modify:
app/src-tauri/Cargo.toml(addsimilar = "2") - Create:
app/src-tauri/src/marketplace/diff.rs - Create:
app/src-tauri/src/marketplace/test_support.rs - Modify:
app/src-tauri/src/marketplace/mod.rs(created in Tasks 3–5 withpub mod tree; pub mod catalog; pub mod git; pub mod auth;) - Modify:
app/src-tauri/src/lib.rs(AppState.marketplace)
Interfaces:
-
Consumes:
models::marketplace::*(Task 2);tree::{TreeView, GitTree}(Task 3);catalog::{parse_catalog, item_files, item_fingerprint, ItemFile}(Task 3);git::{fetch, cache_path, cached_head, FetchError, Credential}(Task 4);auth::{resolve_credential, describe_fetch_error}(Task 5). -
Produces:
diff::item_diff,MarketplaceManager(full API from the contract),refresh_marketplace,load_cached_snapshot,compute_updates,pins_by_marketplace,head_for,test_support::GitFixture,AppState.marketplace: Arc<MarketplaceManager>. -
Step 1: Add the dependency
In app/src-tauri/Cargo.toml under [dependencies], after url = "2":
similar = "2"
If [dev-dependencies] has no tempfile yet (Task 4 adds it), add tempfile = "3" there.
Run: cd app/src-tauri && cargo check --lib
Expected: compiles (warnings allowed).
- Step 2: Create the git fixture helper
Create app/src-tauri/src/marketplace/test_support.rs:
//! Test-only helpers: throwaway git repositories built with the `git` CLI, so
//! marketplace code is exercised against real git objects over `file://`.
use std::fs;
use std::path::Path;
use std::process::Command;
pub struct GitFixture {
pub dir: tempfile::TempDir,
}
fn git(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.args(["-c", "user.name=Triple-C Test", "-c", "user.email=test@example.invalid"])
.args(args)
.current_dir(dir)
.output()
.expect("run git");
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
impl GitFixture {
/// `None` (with a note on stderr) when `git` is not installed; callers skip.
pub fn new() -> Option<Self> {
if Command::new("git").arg("--version").output().is_err() {
eprintln!("skipping: git is not installed");
return None;
}
let dir = tempfile::tempdir().expect("tempdir");
git(dir.path(), &["init", "-q", "-b", "main"]);
Some(Self { dir })
}
pub fn url(&self) -> String {
format!("file://{}", self.dir.path().display())
}
pub fn write(&self, path: &str, contents: &str) -> &Self {
let p = self.dir.path().join(path);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(&p, contents).unwrap();
self
}
pub fn write_exec(&self, path: &str, contents: &str) -> &Self {
self.write(path, contents);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let p = self.dir.path().join(path);
fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap();
}
self
}
pub fn remove(&self, path: &str) -> &Self {
let p = self.dir.path().join(path);
if p.is_dir() {
fs::remove_dir_all(&p).unwrap();
} else {
fs::remove_file(&p).unwrap();
}
self
}
/// Commit everything and return the new commit id (40 hex).
pub fn commit(&self, message: &str) -> String {
git(self.dir.path(), &["add", "-A"]);
git(self.dir.path(), &["commit", "-q", "--allow-empty", "-m", message]);
git(self.dir.path(), &["rev-parse", "HEAD"])
}
/// A repo with one item of every kind, committed. Returns the commit.
pub fn with_all_kinds(&self) -> String {
self.write(
"agents/code-reviewer.md",
"---\nname: code-reviewer\ndescription: Reviews code\n---\nReview the diff.\n",
)
.write(
"skills/example-skill/SKILL.md",
"---\nname: example-skill\ndescription: An example skill\n---\nDo the thing.\n",
)
.write(
"commands/example-command.md",
"---\ndescription: An example command\n---\nRun the example.\n",
)
.write(
"hooks/notify-on-stop/hook.json",
r#"{"name":"notify-on-stop","description":"Ping on stop","hooks":{"Stop":[{"hooks":[{"type":"command","command":"${HOOK_DIR}/notify.sh"}]}]}}"#,
)
.write_exec("hooks/notify-on-stop/notify.sh", "#!/bin/sh\necho done\n")
.write(
"plugins/.claude-plugin/marketplace.json",
r#"{"name":"upstream","owner":{"name":"Test"},"plugins":[{"name":"example-plugin","source":"./example-plugin","description":"An example plugin"}]}"#,
)
.write(
"plugins/example-plugin/.claude-plugin/plugin.json",
r#"{"name":"example-plugin","version":"0.1.0"}"#,
)
.write(
"plugins/example-plugin/skills/hello/SKILL.md",
"---\nname: hello\ndescription: Says hello\n---\nSay hello.\n",
);
self.commit("all kinds")
}
}
- Step 3: Write the failing diff tests
Create app/src-tauri/src/marketplace/diff.rs with only the tests first:
//! Text diff of one item between two commits, for the "Update" review.
use std::collections::BTreeMap;
use std::path::Path;
use similar::TextDiff;
use super::catalog::{item_files, ItemFile};
use super::tree::GitTree;
use crate::models::marketplace::{FileChange, FileDiff, ItemKind};
#[cfg(test)]
mod tests {
use super::*;
use crate::marketplace::git;
use crate::marketplace::test_support::GitFixture;
fn f(path: &str, text: &str, executable: bool) -> ItemFile {
ItemFile { rel_path: path.to_string(), data: text.as_bytes().to_vec(), executable }
}
#[test]
fn unchanged_files_are_omitted_and_changes_are_classified() {
let old = vec![f("a.md", "one\n", false), f("gone.sh", "x\n", true), f("same", "s\n", false)];
let new = vec![f("a.md", "two\n", false), f("new.txt", "n\n", false), f("same", "s\n", false)];
let diffs = diff_files(&old, &new);
let summary: Vec<(&str, FileChange)> =
diffs.iter().map(|d| (d.path.as_str(), d.change.clone())).collect();
assert_eq!(
summary,
vec![
("a.md", FileChange::Modified),
("gone.sh", FileChange::Removed),
("new.txt", FileChange::Added),
]
);
let a = diffs[0].unified.as_deref().unwrap();
assert!(a.contains("-one") && a.contains("+two"), "{a}");
}
#[test]
fn binary_files_have_no_text_diff() {
let old = vec![ItemFile { rel_path: "b.bin".into(), data: vec![0, 1, 2], executable: false }];
let new = vec![ItemFile { rel_path: "b.bin".into(), data: vec![0, 1, 3], executable: false }];
let diffs = diff_files(&old, &new);
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].unified, None);
}
#[test]
fn an_executable_bit_change_is_reported() {
let old = vec![f("run.sh", "echo\n", false)];
let new = vec![f("run.sh", "echo\n", true)];
let diffs = diff_files(&old, &new);
assert_eq!(diffs.len(), 1);
assert!(diffs[0].unified.as_deref().unwrap().contains("executable: false -> true"));
}
#[test]
fn item_diff_reads_both_commits_from_the_cache() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
fx.write(
"hooks/notify-on-stop/notify.sh",
"#!/bin/sh\ncurl https://example.invalid\n",
);
let c2 = fx.commit("change hook");
let data = tempfile::tempdir().unwrap();
let repo = git::cache_path(data.path(), "m1");
git::fetch(&repo, &fx.url(), None, None).unwrap();
let diffs = item_diff(&repo, ItemKind::Hook, "notify-on-stop", &c1, &c2).unwrap();
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].path, "notify.sh");
assert!(diffs[0].unified.as_deref().unwrap().contains("+curl https://example.invalid"));
}
}
- Step 4: Run tests to verify they fail
Add pub mod diff; and #[cfg(test)] pub(crate) mod test_support; to app/src-tauri/src/marketplace/mod.rs.
Run: cd app/src-tauri && cargo test --lib marketplace::diff
Expected: FAIL to compile — cannot find function diff_files / item_diff.
- Step 5: Implement the diff
Insert above the #[cfg(test)] block in diff.rs:
/// Files of `kind`/`key` at `commit`, or an empty list when the item does not
/// exist (or is not installable) at that commit — a removal upstream then reads
/// as every file removed rather than as an error.
fn files_at(repo_path: &Path, kind: ItemKind, key: &str, commit: &str) -> Result<Vec<ItemFile>, String> {
let tree = GitTree::open(repo_path, commit)?;
Ok(item_files(&tree, kind, key).unwrap_or_default())
}
pub fn item_diff(
repo_path: &Path,
kind: ItemKind,
key: &str,
from_commit: &str,
to_commit: &str,
) -> Result<Vec<FileDiff>, String> {
let old = files_at(repo_path, kind, key, from_commit)?;
let new = files_at(repo_path, kind, key, to_commit)?;
Ok(diff_files(&old, &new))
}
fn as_text(data: &[u8]) -> Option<&str> {
if data.contains(&0) {
return None;
}
std::str::from_utf8(data).ok()
}
fn unified(path: &str, old: &str, new: &str) -> String {
TextDiff::from_lines(old, new)
.unified_diff()
.context_radius(3)
.header(&format!("a/{path}"), &format!("b/{path}"))
.to_string()
}
/// Per-file diff, sorted by path; files identical in content and mode are left out.
pub(crate) fn diff_files(old: &[ItemFile], new: &[ItemFile]) -> Vec<FileDiff> {
let old: BTreeMap<&str, &ItemFile> = old.iter().map(|f| (f.rel_path.as_str(), f)).collect();
let new: BTreeMap<&str, &ItemFile> = new.iter().map(|f| (f.rel_path.as_str(), f)).collect();
let mut paths: Vec<&str> = old.keys().chain(new.keys()).copied().collect();
paths.sort_unstable();
paths.dedup();
let mut out = Vec::new();
for path in paths {
match (old.get(path), new.get(path)) {
(Some(o), Some(n)) => {
if o.data == n.data && o.executable == n.executable {
continue;
}
let text = match (as_text(&o.data), as_text(&n.data)) {
(Some(a), Some(b)) => {
let mut s = String::new();
if o.executable != n.executable {
s.push_str(&format!("# executable: {} -> {}\n", o.executable, n.executable));
}
s.push_str(&unified(path, a, b));
Some(s)
}
_ => None,
};
out.push(FileDiff { path: path.to_string(), change: FileChange::Modified, unified: text });
}
(Some(o), None) => out.push(FileDiff {
path: path.to_string(),
change: FileChange::Removed,
unified: as_text(&o.data).map(|a| unified(path, a, "")),
}),
(None, Some(n)) => out.push(FileDiff {
path: path.to_string(),
change: FileChange::Added,
unified: as_text(&n.data).map(|b| unified(path, "", b)),
}),
(None, None) => {}
}
}
out
}
- Step 6: Run the diff tests
Run: cd app/src-tauri && cargo test --lib marketplace::diff
Expected: 4 passed (the last one prints "skipping" and passes when git is absent).
- Step 7: Write the failing manager/refresh tests
Append to app/src-tauri/src/marketplace/mod.rs:
#[cfg(test)]
mod tests {
use super::*;
use crate::models::marketplace::{Marketplace, MarketplaceInstall};
use crate::marketplace::test_support::GitFixture;
fn settings_with(url: &str) -> AppSettings {
let mut s = AppSettings::default();
s.marketplaces.push(Marketplace {
id: "m1".into(),
name: "Test".into(),
url: url.into(),
branch: None,
account_id: None,
});
s
}
fn install(kind: ItemKind, key: &str, commit: &str) -> MarketplaceInstall {
MarketplaceInstall { marketplace_id: "m1".into(), kind, key: key.into(), commit: commit.into() }
}
#[tokio::test]
async fn refresh_parses_the_catalog_at_head() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let snap = refresh_marketplace(&mgr, &settings_with(&fx.url()), "m1").await;
assert_eq!(snap.fetch_error, None);
assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str()));
assert!(snap.fetched_at.is_some());
let mut keys: Vec<String> =
snap.items.iter().map(|i| format!("{:?}:{}", i.kind, i.key)).collect();
keys.sort();
assert_eq!(
keys,
vec![
"Agent:code-reviewer",
"Command:example-command",
"Hook:notify-on-stop",
"Plugin:example-plugin",
"Skill:example-skill",
]
);
assert_eq!(mgr.snapshot("m1"), Some(snap));
}
#[tokio::test]
async fn refresh_failure_keeps_snapshot() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
let url = fx.url();
let data = tempfile::tempdir().unwrap();
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let settings = settings_with(&url);
let first = refresh_marketplace(&mgr, &settings, "m1").await;
assert_eq!(first.fetch_error, None);
drop(fx); // the source repository disappears (offline, deleted, …)
let second = refresh_marketplace(&mgr, &settings, "m1").await;
assert!(second.fetch_error.is_some(), "expected a fetch error");
assert_eq!(second.head_commit.as_deref(), Some(c1.as_str()));
assert_eq!(second.items, first.items);
assert_eq!(second.fetched_at, first.fetched_at);
}
#[tokio::test]
async fn cached_snapshot_loads_without_network() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
let settings = settings_with(&fx.url());
{
let mgr = MarketplaceManager::new(data.path().to_path_buf());
refresh_marketplace(&mgr, &settings, "m1").await;
}
drop(fx);
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let snap = load_cached_snapshot(&mgr, &settings.marketplaces[0]);
assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str()));
assert_eq!(snap.items.len(), 5);
assert_eq!(snap.fetch_error, None);
}
#[tokio::test]
async fn only_items_whose_own_files_changed_have_updates() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
fx.write(
"agents/code-reviewer.md",
"---\nname: code-reviewer\ndescription: Reviews code\n---\nReview harder.\n",
);
let c2 = fx.commit("tweak agent");
let data = tempfile::tempdir().unwrap();
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let mut settings = settings_with(&fx.url());
settings.global_marketplace_installs = vec![
install(ItemKind::Agent, "code-reviewer", &c1),
install(ItemKind::Hook, "notify-on-stop", &c1),
];
let mut project = crate::models::Project::new("p".into(), vec![]);
project.marketplace_installs = vec![install(ItemKind::Skill, "example-skill", &c1)];
refresh_marketplace(&mgr, &settings, "m1").await;
let updates = compute_updates(&mgr, &settings, &[project]);
assert_eq!(updates.len(), 1, "{updates:?}");
assert_eq!(updates[0].item.key, "code-reviewer");
assert_eq!(updates[0].pinned, c1);
assert_eq!(updates[0].head, c2);
}
#[test]
fn pins_are_grouped_and_deduplicated_per_marketplace() {
let a = "a".repeat(40);
let b = "b".repeat(40);
let mut settings = settings_with("https://example.invalid/r.git");
settings.global_marketplace_installs = vec![
install(ItemKind::Agent, "x", &b),
install(ItemKind::Hook, "y", &a),
];
let mut project = crate::models::Project::new("p".into(), vec![]);
project.marketplace_installs = vec![install(ItemKind::Agent, "z", &a)];
let pins = pins_by_marketplace(&settings, &[project]);
assert_eq!(pins.get("m1"), Some(&vec![a.clone(), b.clone()]));
}
#[test]
fn reports_are_persisted_per_project() {
let data = tempfile::tempdir().unwrap();
let report = SyncReport { installed: vec!["agent:x".into()], ..Default::default() };
MarketplaceManager::new(data.path().to_path_buf()).put_report("proj-1", report.clone());
let fresh = MarketplaceManager::new(data.path().to_path_buf());
assert_eq!(fresh.report("proj-1"), Some(report));
assert_eq!(fresh.report("proj-2"), None);
}
#[tokio::test]
async fn only_one_gh_login_may_hold_the_cancel_slot() {
let mgr = MarketplaceManager::new(std::env::temp_dir());
let (tx1, rx1) = tokio::sync::oneshot::channel();
let (tx2, _rx2) = tokio::sync::oneshot::channel();
assert!(mgr.set_gh_login_cancel(Some(tx1)).await);
assert!(!mgr.set_gh_login_cancel(Some(tx2)).await);
mgr.cancel_gh_login().await;
assert!(rx1.await.is_ok(), "cancel must signal the running login");
let (tx3, _rx3) = tokio::sync::oneshot::channel();
assert!(mgr.set_gh_login_cancel(Some(tx3)).await, "slot is free after cancel");
}
}
- Step 8: Run tests to verify they fail
Run: cd app/src-tauri && cargo test --lib marketplace::tests
Expected: FAIL to compile — MarketplaceManager, refresh_marketplace, … not found.
- Step 9: Implement the manager and refresh
Make the top of app/src-tauri/src/marketplace/mod.rs read (keep the pub mod lines Tasks 3–5 added; the full list after this task):
//! Marketplaces: git repos of agents, skills, commands, hooks and plugins that
//! are fetched on the host and synced into containers. See
//! `docs/superpowers/specs/2026-09-27-marketplace-design.md`.
pub mod auth;
pub mod catalog;
pub mod diff;
pub mod git;
pub mod tree;
#[cfg(test)]
pub(crate) mod test_support;
use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tokio::sync::oneshot;
use crate::models::marketplace::{
CatalogItem, ItemKind, ItemUpdate, Marketplace, MarketplaceInstall, MarketplaceSnapshot,
SyncReport,
};
use crate::models::{AppSettings, Project};
use catalog::{item_fingerprint, parse_catalog};
use tree::GitTree;
pub struct MarketplaceManager {
data_root: PathBuf,
snapshots: Mutex<HashMap<String, MarketplaceSnapshot>>,
reports: Mutex<HashMap<String, SyncReport>>,
gh_login_cancel: tokio::sync::Mutex<Option<oneshot::Sender<()>>>,
}
/// Project ids become file names; anything outside this set is not persisted.
fn safe_file_stem(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
&& id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
impl MarketplaceManager {
/// `data_root` is `<data_dir>/triple-c`.
pub fn new(data_root: PathBuf) -> Self {
Self {
data_root,
snapshots: Mutex::new(HashMap::new()),
reports: Mutex::new(HashMap::new()),
gh_login_cancel: tokio::sync::Mutex::new(None),
}
}
pub fn data_root(&self) -> &Path {
&self.data_root
}
pub fn snapshot(&self, marketplace_id: &str) -> Option<MarketplaceSnapshot> {
self.snapshots.lock().unwrap().get(marketplace_id).cloned()
}
pub fn put_snapshot(&self, snap: MarketplaceSnapshot) {
self.snapshots.lock().unwrap().insert(snap.marketplace_id.clone(), snap);
}
pub fn remove_snapshot(&self, marketplace_id: &str) {
self.snapshots.lock().unwrap().remove(marketplace_id);
}
fn report_path(&self, project_id: &str) -> PathBuf {
self.data_root.join("marketplace-sync").join(format!("{project_id}.json"))
}
pub fn report(&self, project_id: &str) -> Option<SyncReport> {
if let Some(r) = self.reports.lock().unwrap().get(project_id) {
return Some(r.clone());
}
if !safe_file_stem(project_id) {
return None;
}
let text = std::fs::read_to_string(self.report_path(project_id)).ok()?;
let report: SyncReport = serde_json::from_str(&text).ok()?;
self.reports.lock().unwrap().insert(project_id.to_string(), report.clone());
Some(report)
}
pub fn put_report(&self, project_id: &str, report: SyncReport) {
self.reports.lock().unwrap().insert(project_id.to_string(), report.clone());
if !safe_file_stem(project_id) {
return;
}
let path = self.report_path(project_id);
let write = || -> std::io::Result<()> {
std::fs::create_dir_all(path.parent().unwrap())?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, serde_json::to_vec_pretty(&report).unwrap_or_default())?;
std::fs::rename(&tmp, &path)
};
if let Err(e) = write() {
log::warn!("Could not persist the marketplace sync report for {}: {}", project_id, e);
}
}
/// Claim (`Some`) or release (`None`) the single gh-login slot. Claiming
/// fails while another login holds it.
pub async fn set_gh_login_cancel(&self, tx: Option<oneshot::Sender<()>>) -> bool {
let mut slot = self.gh_login_cancel.lock().await;
match tx {
Some(tx) => {
if slot.is_some() {
return false;
}
*slot = Some(tx);
true
}
None => {
*slot = None;
true
}
}
}
pub async fn cancel_gh_login(&self) {
if let Some(tx) = self.gh_login_cancel.lock().await.take() {
let _ = tx.send(());
}
}
}
/// Head commit for a marketplace: the in-memory snapshot's, else the cache's.
pub fn head_for(mgr: &MarketplaceManager, m: &Marketplace) -> Option<String> {
mgr.snapshot(&m.id)
.and_then(|s| s.head_commit)
.or_else(|| git::cached_head(&git::cache_path(mgr.data_root(), &m.id)).ok().flatten())
}
fn parse_at(repo: &Path, commit: &str) -> Result<Vec<CatalogItem>, String> {
let tree = GitTree::open(repo, commit)?;
Ok(parse_catalog(&tree))
}
pub fn load_cached_snapshot(mgr: &MarketplaceManager, marketplace: &Marketplace) -> MarketplaceSnapshot {
let repo = git::cache_path(mgr.data_root(), &marketplace.id);
let mut snap = MarketplaceSnapshot { marketplace_id: marketplace.id.clone(), ..Default::default() };
match git::cached_head(&repo) {
Ok(Some(head)) => match parse_at(&repo, &head) {
Ok(items) => {
snap.head_commit = Some(head);
snap.items = items;
}
Err(e) => snap.fetch_error = Some(format!("The cached copy could not be read: {e}")),
},
Ok(None) => {}
Err(e) => snap.fetch_error = Some(format!("The cached copy could not be read: {e}")),
}
snap
}
fn failed_snapshot(mgr: &MarketplaceManager, m: &Marketplace, message: String) -> MarketplaceSnapshot {
let mut snap = mgr.snapshot(&m.id).unwrap_or_else(|| load_cached_snapshot(mgr, m));
snap.fetch_error = Some(message);
mgr.put_snapshot(snap.clone());
snap
}
pub async fn refresh_marketplace(
mgr: &MarketplaceManager,
settings: &AppSettings,
marketplace_id: &str,
) -> MarketplaceSnapshot {
let Some(m) = settings.marketplaces.iter().find(|m| m.id == marketplace_id).cloned() else {
return MarketplaceSnapshot {
marketplace_id: marketplace_id.to_string(),
fetch_error: Some("This marketplace is no longer configured.".to_string()),
..Default::default()
};
};
let account = m
.account_id
.as_ref()
.and_then(|id| settings.marketplace_accounts.iter().find(|a| &a.id == id))
.cloned();
let cred = match &account {
Some(a) => match auth::resolve_credential(a).await {
Ok(c) => Some(c),
Err(e) => return failed_snapshot(mgr, &m, e),
},
None => None,
};
let repo = git::cache_path(mgr.data_root(), &m.id);
let (url, branch) = (m.url.clone(), m.branch.clone());
let joined = tokio::task::spawn_blocking(move || {
let head = git::fetch(&repo, &url, branch.as_deref(), cred)?;
let items = parse_at(&repo, &head).map_err(git::FetchError::Other)?;
Ok::<_, git::FetchError>((head, items))
})
.await;
match joined {
Ok(Ok((head, items))) => {
let snap = MarketplaceSnapshot {
marketplace_id: m.id.clone(),
head_commit: Some(head),
fetched_at: Some(chrono::Utc::now().to_rfc3339()),
fetch_error: None,
items,
};
mgr.put_snapshot(snap.clone());
snap
}
Ok(Err(e)) => failed_snapshot(mgr, &m, auth::describe_fetch_error(&e, account.as_ref(), &m.url)),
Err(e) => failed_snapshot(mgr, &m, format!("The refresh task failed: {e}")),
}
}
fn item_changed(repo: &Path, inst: &MarketplaceInstall, head: &str) -> Result<bool, String> {
let old = GitTree::open(repo, &inst.commit)?;
let new = GitTree::open(repo, head)?;
Ok(item_fingerprint(&old, inst.kind, &inst.key)? != item_fingerprint(&new, inst.kind, &inst.key)?)
}
pub fn compute_updates(mgr: &MarketplaceManager, settings: &AppSettings, projects: &[Project]) -> Vec<ItemUpdate> {
let mut seen = BTreeSet::new();
let mut out = Vec::new();
let all = settings
.global_marketplace_installs
.iter()
.chain(projects.iter().flat_map(|p| p.marketplace_installs.iter()));
for inst in all {
if !seen.insert((inst.item_ref(), inst.commit.clone())) {
continue;
}
let Some(m) = settings.marketplaces.iter().find(|m| m.id == inst.marketplace_id) else { continue };
let Some(head) = head_for(mgr, m) else { continue };
if head == inst.commit {
continue;
}
let repo = git::cache_path(mgr.data_root(), &m.id);
match item_changed(&repo, inst, &head) {
Ok(true) => out.push(ItemUpdate { item: inst.item_ref(), pinned: inst.commit.clone(), head }),
Ok(false) => {}
Err(e) => log::debug!("Update check skipped for {}: {}", inst.key, e),
}
}
out
}
pub fn pins_by_marketplace(settings: &AppSettings, projects: &[Project]) -> HashMap<String, Vec<String>> {
let mut map: HashMap<String, BTreeSet<String>> = HashMap::new();
let all = settings
.global_marketplace_installs
.iter()
.chain(projects.iter().flat_map(|p| p.marketplace_installs.iter()));
for inst in all {
map.entry(inst.marketplace_id.clone()).or_default().insert(inst.commit.clone());
}
map.into_iter().map(|(k, v)| (k, v.into_iter().collect())).collect()
}
(ItemKind is imported for the tests' use super::*.)
- Step 10: Run tests to verify they pass
Run: cd app/src-tauri && cargo test --lib marketplace::
Expected: all marketplace tests pass (git-backed ones print "skipping" only when git is absent).
- Step 11: Wire the manager into AppState
In app/src-tauri/src/lib.rs, add the field to AppState (after pending_settings_import):
pub marketplace: Arc<marketplace::MarketplaceManager>,
In run(), after let lifecycle = Arc::new(Lifecycle::new());:
let marketplace = Arc::new(marketplace::MarketplaceManager::new(
dirs::data_dir()
.map(|d| d.join("triple-c"))
.unwrap_or_else(|| std::env::temp_dir().join("triple-c")),
));
let marketplace_setup = marketplace.clone();
and in .manage(AppState { … }) add marketplace, after pending_settings_import: …,. (marketplace_setup is used in Task 11; until then add let _ = &marketplace_setup; right after its declaration to keep the build warning-free, and delete that line in Task 11.)
Run: cd app/src-tauri && cargo check --lib
Expected: compiles.
- Step 12: Commit
cd /workspace/triple-c
rustfmt --edition 2021 app/src-tauri/src/marketplace/mod.rs app/src-tauri/src/marketplace/diff.rs app/src-tauri/src/marketplace/test_support.rs
git add app/src-tauri/Cargo.toml app/src-tauri/Cargo.lock app/src-tauri/src/marketplace app/src-tauri/src/lib.rs
git commit -m "Marketplace: item diff, manager, refresh and update detection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 7: Payload builder
Files:
- Create:
app/src-tauri/src/marketplace/payload.rs - Modify:
app/src-tauri/src/marketplace/mod.rs(addpub mod payload;)
Interfaces:
-
Consumes:
models::marketplace::{MarketplaceInstall, Marketplace, ItemKind, SkippedItem, is_valid_item_key, is_valid_commit, marketplace_slug}(Task 2);tree::GitTree,catalog::{item_files, plugin_catalog_entry, rendered_hook_settings, ItemFile}(Task 3);git::{cache_path, has_commit}(Task 4);test_support::GitFixture(Task 6). -
Produces:
payload::{PayloadInput, Payload, build_payload}exactly as in the contract; the tar layout andmanifest.jsonshape from the contract (consumed by the Task 8 script). -
Step 1: Write the failing tests
Create app/src-tauri/src/marketplace/payload.rs with the imports and tests first:
//! Builds the tar a project's container receives: every effective install's
//! files, read from the cache at its pinned commit, plus `manifest.json` and a
//! generated Claude Code catalog per marketplace that contributes plugins.
//! Layout: see the Interface Contract in the plan / spec §4.
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde_json::{json, Value};
use super::catalog::{item_files, plugin_catalog_entry, rendered_hook_settings, ItemFile};
use super::git;
use super::tree::GitTree;
use crate::models::marketplace::{
is_valid_commit, is_valid_item_key, marketplace_slug, ItemKind, Marketplace, MarketplaceInstall,
SkippedItem,
};
#[cfg(test)]
mod tests {
use super::*;
use crate::marketplace::test_support::GitFixture;
use std::collections::HashMap;
use std::io::Read;
struct Entry {
data: Vec<u8>,
mode: u32,
}
fn unpack(tar_bytes: &[u8]) -> HashMap<String, Entry> {
let mut archive = tar::Archive::new(tar_bytes);
let mut out = HashMap::new();
for e in archive.entries().unwrap() {
let mut e = e.unwrap();
let path = e.path().unwrap().to_string_lossy().into_owned();
let mode = e.header().mode().unwrap();
let mut data = Vec::new();
e.read_to_end(&mut data).unwrap();
out.insert(path, Entry { data, mode });
}
out
}
fn market(id: &str) -> Marketplace {
Marketplace { id: id.into(), name: "Team Tools".into(), url: "https://example.invalid/r.git".into(), branch: None, account_id: None }
}
fn inst(kind: ItemKind, key: &str, commit: &str) -> MarketplaceInstall {
MarketplaceInstall { marketplace_id: "m1aaaaaaaa".into(), kind, key: key.into(), commit: commit.into() }
}
/// Fetch the fixture into `<data>/marketplaces/m1aaaaaaaa.git`.
fn cache(fx: &GitFixture, data: &Path) {
let repo = git::cache_path(data, "m1aaaaaaaa");
git::fetch(&repo, &fx.url(), None, None).unwrap();
}
#[test]
fn every_kind_lands_at_its_contract_path() {
let Some(fx) = GitFixture::new() else { return };
let c = fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
cache(&fx, data.path());
let installs = vec![
inst(ItemKind::Agent, "code-reviewer", &c),
inst(ItemKind::Skill, "example-skill", &c),
inst(ItemKind::Command, "example-command", &c),
inst(ItemKind::Hook, "notify-on-stop", &c),
inst(ItemKind::Plugin, "example-plugin", &c),
];
let marketplaces = vec![market("m1aaaaaaaa")];
let p = build_payload(&PayloadInput { installs: &installs, marketplaces: &marketplaces, data_root: data.path() }).unwrap();
assert!(p.skipped.is_empty(), "{:?}", p.skipped);
let files = unpack(&p.tar);
let slug = marketplace_slug("Team Tools", "m1aaaaaaaa");
for path in [
"agents/code-reviewer.md".to_string(),
"skills/example-skill/SKILL.md".to_string(),
"commands/example-command.md".to_string(),
"hooks/notify-on-stop/hook.json".to_string(),
"hooks/notify-on-stop/notify.sh".to_string(),
format!("plugins/{slug}/.claude-plugin/marketplace.json"),
format!("plugins/{slug}/example-plugin/.claude-plugin/plugin.json"),
format!("plugins/{slug}/example-plugin/skills/hello/SKILL.md"),
"manifest.json".to_string(),
] {
assert!(files.contains_key(&path), "missing {path}; have {:?}", files.keys().collect::<Vec<_>>());
}
assert_eq!(files["hooks/notify-on-stop/notify.sh"].mode & 0o777, 0o755);
assert_eq!(files["agents/code-reviewer.md"].mode & 0o777, 0o644);
}
#[test]
fn manifest_and_generated_catalog_match_the_contract() {
let Some(fx) = GitFixture::new() else { return };
let c = fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
cache(&fx, data.path());
let installs = vec![inst(ItemKind::Hook, "notify-on-stop", &c), inst(ItemKind::Plugin, "example-plugin", &c)];
let marketplaces = vec![market("m1aaaaaaaa")];
let p = build_payload(&PayloadInput { installs: &installs, marketplaces: &marketplaces, data_root: data.path() }).unwrap();
let slug = marketplace_slug("Team Tools", "m1aaaaaaaa");
let files = unpack(&p.tar);
let manifest: Value = serde_json::from_slice(&files["manifest.json"].data).unwrap();
assert_eq!(manifest, p.manifest);
assert_eq!(manifest["version"], 1);
let hook = &manifest["items"][0];
assert_eq!(hook["kind"], "hook");
assert_eq!(hook["dir"], "hooks/notify-on-stop");
assert_eq!(
hook["settings"]["Stop"][0]["hooks"][0]["command"],
"/home/claude/.claude/triple-c/hooks/notify-on-stop/notify.sh"
);
let plugin = &manifest["items"][1];
assert_eq!(plugin["kind"], "plugin");
assert_eq!(plugin["slug"], slug.as_str());
assert_eq!(
manifest["plugin_marketplaces"],
json!([{ "slug": slug, "dir": format!("plugins/{slug}"), "plugins": ["example-plugin"] }])
);
let catalog: Value =
serde_json::from_slice(&files[&format!("plugins/{slug}/.claude-plugin/marketplace.json")].data).unwrap();
assert_eq!(catalog["name"], format!("triple-c-{slug}"));
assert_eq!(catalog["owner"]["name"], "Triple-C");
assert_eq!(catalog["plugins"][0]["name"], "example-plugin");
assert_eq!(catalog["plugins"][0]["source"], "./example-plugin");
}
#[test]
fn items_that_cannot_be_built_are_skipped_not_fatal() {
let Some(fx) = GitFixture::new() else { return };
let c = fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
cache(&fx, data.path());
let mut gone = inst(ItemKind::Agent, "code-reviewer", &c);
gone.marketplace_id = "removed".into();
let installs = vec![
gone,
inst(ItemKind::Agent, "code-reviewer", &"0".repeat(40)),
inst(ItemKind::Agent, "does-not-exist", &c),
inst(ItemKind::Command, "example-command", &c),
];
let marketplaces = vec![market("m1aaaaaaaa")];
let p = build_payload(&PayloadInput { installs: &installs, marketplaces: &marketplaces, data_root: data.path() }).unwrap();
let skipped: Vec<&str> = p.skipped.iter().map(|s| s.item.as_str()).collect();
assert_eq!(skipped, vec!["agent:code-reviewer", "agent:code-reviewer", "agent:does-not-exist"]);
assert!(p.skipped[0].reason.contains("marketplace"), "{}", p.skipped[0].reason);
assert!(p.skipped[1].reason.contains("cache"), "{}", p.skipped[1].reason);
assert_eq!(p.manifest["items"].as_array().unwrap().len(), 1);
}
#[test]
fn a_second_marketplace_cannot_shadow_an_installed_name() {
let Some(fx) = GitFixture::new() else { return };
let c = fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
cache(&fx, data.path());
let other = git::cache_path(data.path(), "m2bbbbbbbb");
git::fetch(&other, &fx.url(), None, None).unwrap();
let mut second = inst(ItemKind::Agent, "code-reviewer", &c);
second.marketplace_id = "m2bbbbbbbb".into();
let installs = vec![inst(ItemKind::Agent, "code-reviewer", &c), second];
let marketplaces = vec![market("m1aaaaaaaa"), market("m2bbbbbbbb")];
let p = build_payload(&PayloadInput { installs: &installs, marketplaces: &marketplaces, data_root: data.path() }).unwrap();
assert_eq!(p.manifest["items"].as_array().unwrap().len(), 1);
assert_eq!(p.skipped.len(), 1);
assert!(p.skipped[0].reason.contains("another marketplace"));
}
#[test]
fn an_empty_install_set_still_yields_a_manifest() {
let data = tempfile::tempdir().unwrap();
let p = build_payload(&PayloadInput { installs: &[], marketplaces: &[], data_root: data.path() }).unwrap();
assert_eq!(p.manifest, json!({ "version": 1, "items": [], "plugin_marketplaces": [] }));
assert!(unpack(&p.tar).contains_key("manifest.json"));
}
}
- Step 2: Run tests to verify they fail
Add pub mod payload; to app/src-tauri/src/marketplace/mod.rs.
Run: cd app/src-tauri && cargo test --lib marketplace::payload
Expected: FAIL to compile — PayloadInput, build_payload not found.
- Step 3: Implement
Insert above #[cfg(test)] in payload.rs:
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 Path,
}
pub struct Payload {
pub tar: Vec<u8>,
pub manifest: Value,
pub skipped: Vec<SkippedItem>,
}
fn kind_str(kind: ItemKind) -> &'static str {
match kind {
ItemKind::Agent => "agent",
ItemKind::Skill => "skill",
ItemKind::Command => "command",
ItemKind::Hook => "hook",
ItemKind::Plugin => "plugin",
}
}
/// A relative path from `item_files` is joined under a directory we chose, so
/// it must not be able to climb out of it. The catalog already refuses such
/// entries; this is the second line.
fn safe_rel(rel: &str) -> bool {
!rel.is_empty()
&& !rel.starts_with('/')
&& !rel.contains('\\')
&& rel.split('/').all(|seg| !seg.is_empty() && seg != "." && seg != "..")
}
struct TarWriter {
builder: tar::Builder<Vec<u8>>,
mtime: u64,
}
impl TarWriter {
fn new() -> Self {
let mtime = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
Self { builder: tar::Builder::new(Vec::new()), mtime }
}
fn file(&mut self, path: &str, data: &[u8], executable: bool) -> Result<(), String> {
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(if executable { 0o755 } else { 0o644 });
header.set_mtime(self.mtime);
header.set_entry_type(tar::EntryType::Regular);
self.builder
.append_data(&mut header, path, data)
.map_err(|e| format!("Could not add {path} to the marketplace payload: {e}"))
}
fn finish(self) -> Result<Vec<u8>, String> {
self.builder.into_inner().map_err(|e| format!("Could not finish the marketplace payload: {e}"))
}
}
struct PluginGroup {
entries: Vec<Value>,
keys: Vec<String>,
}
/// Files of one install, validated for use as payload paths.
fn install_files(repo: &Path, inst: &MarketplaceInstall) -> Result<(GitTree, Vec<ItemFile>), String> {
let tree = GitTree::open(repo, &inst.commit)?;
let files = item_files(&tree, inst.kind, &inst.key)?;
if let Some(bad) = files.iter().find(|f| !safe_rel(&f.rel_path)) {
return Err(format!("contains an unsafe path ({})", bad.rel_path));
}
Ok((tree, files))
}
pub fn build_payload(input: &PayloadInput) -> Result<Payload, String> {
let mut tar = TarWriter::new();
let mut items: Vec<Value> = Vec::new();
let mut skipped: Vec<SkippedItem> = Vec::new();
let mut plugin_groups: BTreeMap<String, PluginGroup> = BTreeMap::new();
// Non-plugin items share one namespace in ~/.claude; plugins are namespaced
// by their per-marketplace catalog, so they never collide.
let mut taken: BTreeSet<(ItemKind, String)> = BTreeSet::new();
for inst in input.installs {
let label = format!("{}:{}", kind_str(inst.kind), inst.key);
let mut skip = |reason: String| skipped.push(SkippedItem { item: label.clone(), reason });
let Some(m) = input.marketplaces.iter().find(|m| m.id == inst.marketplace_id) else {
skip("its marketplace has been removed".to_string());
continue;
};
if !is_valid_item_key(&inst.key) || !is_valid_commit(&inst.commit) {
skip("the saved install entry is invalid".to_string());
continue;
}
if inst.kind != ItemKind::Plugin && taken.contains(&(inst.kind, inst.key.clone())) {
skip(format!("another marketplace's {label} is already installed"));
continue;
}
let repo = git::cache_path(input.data_root, &m.id);
if !git::has_commit(&repo, &inst.commit) {
skip(format!(
"pinned commit {} is not in the local cache of \"{}\" — refresh the marketplace",
&inst.commit[..8],
m.name
));
continue;
}
let (tree, files) = match install_files(&repo, inst) {
Ok(v) => v,
Err(e) => {
skip(e);
continue;
}
};
let key = &inst.key;
let mut item = json!({
"kind": kind_str(inst.kind),
"key": key,
"marketplace": m.id,
"commit": inst.commit,
});
match inst.kind {
ItemKind::Agent | ItemKind::Command => {
let dir = if inst.kind == ItemKind::Agent { "agents" } else { "commands" };
let Some(f) = files.first() else {
skip("has no files".to_string());
continue;
};
let path = format!("{dir}/{key}.md");
tar.file(&path, &f.data, false)?;
item["file"] = json!(path);
}
ItemKind::Skill | ItemKind::Hook => {
let dir = if inst.kind == ItemKind::Skill { format!("skills/{key}") } else { format!("hooks/{key}") };
if inst.kind == ItemKind::Hook {
match rendered_hook_settings(&tree, key) {
Ok(settings) => item["settings"] = settings,
Err(e) => {
skip(e);
continue;
}
}
}
for f in &files {
tar.file(&format!("{dir}/{}", f.rel_path), &f.data, f.executable)?;
}
item["dir"] = json!(dir);
}
ItemKind::Plugin => {
let mut entry = match plugin_catalog_entry(&tree, key) {
Ok(e) => e,
Err(e) => {
skip(e);
continue;
}
};
entry["source"] = json!(format!("./{key}"));
let slug = marketplace_slug(&m.name, &m.id);
for f in &files {
tar.file(&format!("plugins/{slug}/{key}/{}", f.rel_path), &f.data, f.executable)?;
}
let group = plugin_groups
.entry(slug.clone())
.or_insert_with(|| PluginGroup { entries: Vec::new(), keys: Vec::new() });
group.entries.push(entry);
group.keys.push(key.clone());
item["slug"] = json!(slug);
}
}
if inst.kind != ItemKind::Plugin {
taken.insert((inst.kind, key.clone()));
}
items.push(item);
}
let mut plugin_marketplaces = Vec::new();
for (slug, group) in plugin_groups {
let catalog = json!({
"name": format!("triple-c-{slug}"),
"owner": { "name": "Triple-C" },
"plugins": group.entries,
});
let bytes = serde_json::to_vec_pretty(&catalog).map_err(|e| e.to_string())?;
tar.file(&format!("plugins/{slug}/.claude-plugin/marketplace.json"), &bytes, false)?;
plugin_marketplaces.push(json!({ "slug": slug, "dir": format!("plugins/{slug}"), "plugins": group.keys }));
}
let manifest = json!({ "version": 1, "items": items, "plugin_marketplaces": plugin_marketplaces });
let bytes = serde_json::to_vec_pretty(&manifest).map_err(|e| e.to_string())?;
tar.file("manifest.json", &bytes, false)?;
Ok(Payload { tar: tar.finish()?, manifest, skipped })
}
- Step 4: Run tests to verify they pass
Run: cd app/src-tauri && cargo test --lib marketplace::payload
Expected: 5 passed.
- Step 5: Commit
cd /workspace/triple-c
rustfmt --edition 2021 app/src-tauri/src/marketplace/payload.rs app/src-tauri/src/marketplace/mod.rs
git add app/src-tauri/src/marketplace/payload.rs app/src-tauri/src/marketplace/mod.rs
git commit -m "Marketplace: build the per-project payload tar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 8: Container sync script
The script ships inside the app (see CONTRACT CHANGES 1): container/ is not modified, so it works on every existing project without an image migration.
Files:
- Create:
app/src-tauri/src/marketplace/sync.sh - Create:
app/src-tauri/src/marketplace/sync.rs(constants only in this task; Task 9 adds the rest) - Create:
app/src-tauri/src/marketplace/sync_script_tests.rs - Modify:
app/src-tauri/src/marketplace/mod.rs(addpub mod sync;and#[cfg(test)] mod sync_script_tests;)
Interfaces:
- Consumes: the payload layout and
manifest.jsonshape from the contract (Task 7);models::marketplace::SyncReport(Task 2). - Produces:
sync::SYNC_SCRIPT(include_str!("sync.sh")),sync::INCOMING_DIR. Script contract: envHOME(required),MARKETPLACE_INCOMING(optional, default$HOME/.claude/triple-c/marketplace/incoming, must containpayload.tar),MARKETPLACE_LOCK(optional); stdout's last line is aSyncReportJSON object; exit status 0 unlessHOMEis unset.
State file ~/.claude/triple-c/marketplace/state.json (script-private):
{ "version": 1,
"items": {
"agent:code-reviewer": { "commit": "<sha>", "path": "/home/claude/.claude/agents/code-reviewer.md" },
"skill:example-skill": { "commit": "<sha>", "path": "/home/claude/.claude/skills/example-skill" },
"hook:notify-on-stop": { "commit": "<sha>", "path": "/home/claude/.claude/triple-c/hooks/notify-on-stop", "entries": { "Stop": [ … ] } },
"plugin:example-plugin": { "commit": "<sha>", "slug": "team-tools-m1aaaaaa" } },
"plugin_marketplaces": ["team-tools-m1aaaaaa"] }
- Step 1: Create the constants module
Create app/src-tauri/src/marketplace/sync.rs:
//! Pushes a project's marketplace payload into its container and runs the
//! sync script there (spec §4).
/// Where the payload and the script are uploaded. Owned by `claude`.
pub const INCOMING_DIR: &str = "/home/claude/.claude/triple-c/marketplace/incoming";
/// The sync script. Shipped with the app and uploaded on every sync, so a new
/// app version reaches existing containers without an image migration.
pub const SYNC_SCRIPT: &str = include_str!("sync.sh");
and create an empty app/src-tauri/src/marketplace/sync.sh (the tests need the file to exist to compile). Add to mod.rs: pub mod sync; and
#[cfg(test)]
mod sync_script_tests;
- Step 2: Write the failing script tests
Create app/src-tauri/src/marketplace/sync_script_tests.rs:
//! Runs the real `sync.sh` against a throwaway `$HOME`, with a stub `claude`
//! on `PATH` that records its arguments. Skipped when `jq` or `tar` is missing.
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::{json, Value};
use super::sync::SYNC_SCRIPT;
use crate::models::marketplace::SyncReport;
const C1: &str = "1111111111111111111111111111111111111111";
const C2: &str = "2222222222222222222222222222222222222222";
const SLUG: &str = "team-tools-m1aaaaaa";
fn have(tool: &str) -> bool {
Command::new(tool).arg("--version").output().map(|o| o.status.success()).unwrap_or(false)
}
struct Env {
_root: tempfile::TempDir,
home: PathBuf,
incoming: PathBuf,
stub_dir: PathBuf,
log: PathBuf,
script: PathBuf,
lock: PathBuf,
}
fn env() -> Option<Env> {
if !have("jq") || !have("tar") {
eprintln!("skipping: jq or tar is not installed");
return None;
}
let root = tempfile::tempdir().unwrap();
let home = root.path().join("home");
let incoming = root.path().join("incoming");
let stub_dir = root.path().join("bin");
for d in [&home, &incoming, &stub_dir] {
fs::create_dir_all(d).unwrap();
}
let log = root.path().join("claude.log");
let stub = stub_dir.join("claude");
fs::write(&stub, "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CLAUDE_LOG\"\nexit 0\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&stub, fs::Permissions::from_mode(0o755)).unwrap();
}
let script = root.path().join("sync.sh");
fs::write(&script, SYNC_SCRIPT).unwrap();
let lock = root.path().join("lock");
Some(Env { home, incoming, stub_dir, log, script, lock, _root: root })
}
/// Write `payload.tar` into the incoming dir: `files` plus `manifest.json`.
fn payload(env: &Env, files: &[(&str, &str, bool)], manifest: Value) {
let mut b = tar::Builder::new(Vec::new());
let mut add = |path: &str, data: &[u8], exec: bool| {
let mut h = tar::Header::new_gnu();
h.set_size(data.len() as u64);
h.set_mode(if exec { 0o755 } else { 0o644 });
h.set_entry_type(tar::EntryType::Regular);
b.append_data(&mut h, path, data).unwrap();
};
for (path, text, exec) in files {
add(path, text.as_bytes(), *exec);
}
add("manifest.json", manifest.to_string().as_bytes(), false);
fs::write(env.incoming.join("payload.tar"), b.into_inner().unwrap()).unwrap();
}
fn run(env: &Env) -> SyncReport {
let out = Command::new("sh")
.arg(&env.script)
.env_clear()
.env("HOME", &env.home)
.env("PATH", format!("{}:/usr/local/bin:/usr/bin:/bin", env.stub_dir.display()))
.env("MARKETPLACE_INCOMING", &env.incoming)
.env("MARKETPLACE_LOCK", &env.lock)
.env("CLAUDE_LOG", &env.log)
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "script failed: {}", String::from_utf8_lossy(&out.stderr));
let last = stdout.lines().rev().find(|l| !l.trim().is_empty()).expect("a report line");
serde_json::from_str(last).unwrap_or_else(|e| panic!("bad report {last:?}: {e}"))
}
fn claude_log(env: &Env) -> Vec<String> {
fs::read_to_string(&env.log).unwrap_or_default().lines().map(str::to_string).collect()
}
fn read_json(p: &Path) -> Value {
serde_json::from_str(&fs::read_to_string(p).unwrap()).unwrap()
}
fn hook_settings() -> Value {
json!({ "Stop": [{ "hooks": [{ "type": "command",
"command": "/home/claude/.claude/triple-c/hooks/notify-on-stop/notify.sh" }] }] })
}
fn all_kinds(commit: &str) -> (Vec<(&'static str, &'static str, bool)>, Value) {
(
vec![
("agents/code-reviewer.md", "agent body\n", false),
("skills/example-skill/SKILL.md", "skill body\n", false),
("commands/example-command.md", "command body\n", false),
("hooks/notify-on-stop/hook.json", "{}", false),
("hooks/notify-on-stop/notify.sh", "#!/bin/sh\necho hi\n", true),
],
json!({ "version": 1, "plugin_marketplaces": [], "items": [
{ "kind": "agent", "key": "code-reviewer", "marketplace": "m1", "commit": commit, "file": "agents/code-reviewer.md" },
{ "kind": "skill", "key": "example-skill", "marketplace": "m1", "commit": commit, "dir": "skills/example-skill" },
{ "kind": "command", "key": "example-command", "marketplace": "m1", "commit": commit, "file": "commands/example-command.md" },
{ "kind": "hook", "key": "notify-on-stop", "marketplace": "m1", "commit": commit, "dir": "hooks/notify-on-stop", "settings": hook_settings() }
]}),
)
}
fn empty_manifest() -> Value {
json!({ "version": 1, "items": [], "plugin_marketplaces": [] })
}
fn sorted(mut v: Vec<String>) -> Vec<String> {
v.sort();
v
}
#[test]
fn sync_installs_all_kinds() {
let Some(env) = env() else { return };
let (files, manifest) = all_kinds(C1);
payload(&env, &files, manifest);
let r = run(&env);
assert_eq!(r.errors, Vec::<String>::new());
assert_eq!(
sorted(r.installed),
vec!["agent:code-reviewer", "command:example-command", "hook:notify-on-stop", "skill:example-skill"]
);
let claude = env.home.join(".claude");
assert_eq!(fs::read_to_string(claude.join("agents/code-reviewer.md")).unwrap(), "agent body\n");
assert!(claude.join("skills/example-skill/SKILL.md").is_file());
assert!(claude.join("commands/example-command.md").is_file());
let script = claude.join("triple-c/hooks/notify-on-stop/notify.sh");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_ne!(fs::metadata(&script).unwrap().permissions().mode() & 0o111, 0, "hook script must stay executable");
}
assert_eq!(read_json(&claude.join("settings.json"))["hooks"], hook_settings());
assert!(!env.incoming.join("payload.tar").exists(), "payload is consumed");
// A second identical run is a no-op in the report.
payload(&env, &all_kinds(C1).0, all_kinds(C1).1);
let again = run(&env);
assert!(again.installed.is_empty() && again.updated.is_empty() && again.removed.is_empty(), "{again:?}");
assert_eq!(read_json(&claude.join("settings.json"))["hooks"]["Stop"].as_array().unwrap().len(), 1);
}
#[test]
fn sync_updates_report_changed_commits() {
let Some(env) = env() else { return };
let (files, manifest) = all_kinds(C1);
payload(&env, &files, manifest);
run(&env);
let (files, manifest) = all_kinds(C2);
payload(&env, &files, manifest);
let r = run(&env);
assert_eq!(r.updated.len(), 4, "{r:?}");
assert!(r.installed.is_empty());
}
#[test]
fn sync_removes_deselected() {
let Some(env) = env() else { return };
let (files, manifest) = all_kinds(C1);
payload(&env, &files, manifest);
run(&env);
payload(&env, &[], empty_manifest());
let r = run(&env);
assert_eq!(
sorted(r.removed),
vec!["agent:code-reviewer", "command:example-command", "hook:notify-on-stop", "skill:example-skill"]
);
let claude = env.home.join(".claude");
assert!(!claude.join("agents/code-reviewer.md").exists());
assert!(!claude.join("skills/example-skill").exists());
assert!(!claude.join("commands/example-command.md").exists());
assert!(!claude.join("triple-c/hooks/notify-on-stop").exists());
assert_eq!(read_json(&claude.join("settings.json")).get("hooks"), None);
}
#[test]
fn sync_skips_user_owned_agent() {
let Some(env) = env() else { return };
let mine = env.home.join(".claude/agents/code-reviewer.md");
fs::create_dir_all(mine.parent().unwrap()).unwrap();
fs::write(&mine, "mine\n").unwrap();
let (files, manifest) = all_kinds(C1);
payload(&env, &files, manifest);
let r = run(&env);
assert_eq!(r.skipped.len(), 1, "{r:?}");
assert_eq!(r.skipped[0].item, "agent:code-reviewer");
assert!(r.skipped[0].reason.contains("was not installed by Triple-C"), "{}", r.skipped[0].reason);
assert_eq!(fs::read_to_string(&mine).unwrap(), "mine\n");
// Deselecting everything must not delete the user's own file either.
payload(&env, &[], empty_manifest());
let r = run(&env);
assert!(!r.removed.contains(&"agent:code-reviewer".to_string()));
assert_eq!(fs::read_to_string(&mine).unwrap(), "mine\n");
}
#[test]
fn sync_preserves_user_hooks() {
let Some(env) = env() else { return };
let settings_path = env.home.join(".claude/settings.json");
fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
let original = json!({
"model": "opus",
"hooks": {
"Stop": [{ "hooks": [{ "type": "command", "command": "echo mine" }] }],
"PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo pre" }] }]
}
});
fs::write(&settings_path, serde_json::to_string_pretty(&original).unwrap()).unwrap();
let (files, manifest) = all_kinds(C1);
payload(&env, &files, manifest);
run(&env);
let merged = read_json(&settings_path);
assert_eq!(merged["model"], "opus");
assert_eq!(merged["hooks"]["PreToolUse"], original["hooks"]["PreToolUse"]);
assert_eq!(merged["hooks"]["Stop"][0], original["hooks"]["Stop"][0], "user hook stays first");
assert_eq!(merged["hooks"]["Stop"][1], hook_settings()["Stop"][0]);
// An update with a changed hook entry replaces only ours.
let (files, mut manifest) = all_kinds(C2);
manifest["items"][3]["settings"]["Stop"][0]["hooks"][0]["timeout"] = json!(5);
payload(&env, &files, manifest);
run(&env);
let updated = read_json(&settings_path);
assert_eq!(updated["hooks"]["Stop"].as_array().unwrap().len(), 2);
assert_eq!(updated["hooks"]["Stop"][0], original["hooks"]["Stop"][0]);
assert_eq!(updated["hooks"]["Stop"][1]["hooks"][0]["timeout"], 5);
// Uninstalling everything restores the user's settings exactly.
payload(&env, &[], empty_manifest());
run(&env);
assert_eq!(read_json(&settings_path), original);
}
#[test]
fn sync_plugin_calls() {
let Some(env) = env() else { return };
let files = [
("plugins/team-tools-m1aaaaaa/.claude-plugin/marketplace.json", r#"{"name":"triple-c-team-tools-m1aaaaaa","owner":{"name":"Triple-C"},"plugins":[{"name":"example-plugin","source":"./example-plugin"}]}"#, false),
("plugins/team-tools-m1aaaaaa/example-plugin/.claude-plugin/plugin.json", r#"{"name":"example-plugin"}"#, false),
];
let manifest = |commit: &str| {
json!({ "version": 1,
"items": [{ "kind": "plugin", "key": "example-plugin", "marketplace": "m1", "commit": commit, "slug": SLUG }],
"plugin_marketplaces": [{ "slug": SLUG, "dir": format!("plugins/{SLUG}"), "plugins": ["example-plugin"] }] })
};
let tree = env.home.join(".claude/triple-c/plugins").join(SLUG);
payload(&env, &files, manifest(C1));
let r = run(&env);
assert_eq!(r.installed, vec!["plugin:example-plugin"]);
assert_eq!(
claude_log(&env),
vec![
format!("plugin marketplace add {}", tree.display()),
format!("plugin install example-plugin@triple-c-{SLUG}"),
]
);
assert!(tree.join(".claude-plugin/marketplace.json").is_file());
// Same commit again: catalog refreshed, nothing reinstalled.
fs::remove_file(&env.log).unwrap();
payload(&env, &files, manifest(C1));
run(&env);
assert_eq!(claude_log(&env), vec![format!("plugin marketplace update triple-c-{SLUG}")]);
// New commit: uninstall + install.
fs::remove_file(&env.log).unwrap();
payload(&env, &files, manifest(C2));
let r = run(&env);
assert_eq!(r.updated, vec!["plugin:example-plugin"]);
assert_eq!(
claude_log(&env),
vec![
format!("plugin marketplace update triple-c-{SLUG}"),
format!("plugin uninstall example-plugin@triple-c-{SLUG}"),
format!("plugin install example-plugin@triple-c-{SLUG}"),
]
);
// Deselected: uninstall, drop the registration and the tree.
fs::remove_file(&env.log).unwrap();
payload(&env, &[], empty_manifest());
let r = run(&env);
assert_eq!(r.removed, vec!["plugin:example-plugin"]);
assert_eq!(
claude_log(&env),
vec![
format!("plugin uninstall example-plugin@triple-c-{SLUG}"),
format!("plugin marketplace remove triple-c-{SLUG}"),
]
);
assert!(!tree.exists());
}
#[test]
fn sync_rejects_bad_keys() {
let Some(env) = env() else { return };
payload(
&env,
&[("agents/x.md", "x", false)],
json!({ "version": 1, "plugin_marketplaces": [], "items": [
{ "kind": "agent", "key": "../../evil", "marketplace": "m1", "commit": C1 },
{ "kind": "agent", "key": "-rf", "marketplace": "m1", "commit": C1 },
{ "kind": "agent", "key": "ok", "marketplace": "m1", "commit": "not-a-sha" }
]}),
);
let r = run(&env);
assert_eq!(r.skipped.len(), 3, "{r:?}");
assert!(r.installed.is_empty());
assert!(!env.home.join("evil.md").exists());
}
#[test]
fn a_missing_payload_is_reported_not_fatal() {
let Some(env) = env() else { return };
let r = run(&env);
assert_eq!(r.errors, vec!["no payload was uploaded"]);
}
- Step 3: Run tests to verify they fail
Run: cd app/src-tauri && cargo test --lib marketplace::sync_script_tests
Expected: FAIL — every test panics with "a report line" / "bad report" because sync.sh is empty (on a machine without jq the tests print "skipping" and pass; CI's ubuntu runner has jq).
- Step 4: Write the script
Replace app/src-tauri/src/marketplace/sync.sh with:
#!/bin/sh
# Triple-C marketplace sync: applies the payload the app uploaded.
#
# A constant script, shipped inside the app and uploaded next to the payload on
# every sync. Nothing is ever interpolated into it: its only inputs are the
# files under $MARKETPLACE_INCOMING (written by the host) and $HOME. Item keys
# and slugs are re-validated here although the host validated them, and every
# destination path is derived from them rather than taken from the manifest.
#
# Progress and tool output go to stderr. stdout carries exactly one line: the
# JSON report. Exit status is 0 unless HOME is unset; per-item failures are
# reported, never fatal.
set -u
if [ -z "${HOME:-}" ]; then
echo "triple-c-marketplace-sync: HOME is not set" >&2
exit 2
fi
PATH="$HOME/.claude/bin:$HOME/.local/bin:$PATH"
export PATH
CLAUDE_DIR="$HOME/.claude"
BASE="$CLAUDE_DIR/triple-c"
INCOMING="${MARKETPLACE_INCOMING:-$BASE/marketplace/incoming}"
LOCK="${MARKETPLACE_LOCK:-/tmp/.triple-c-claude-update.lock}"
STATE="$BASE/marketplace/state.json"
WORK="$BASE/marketplace/work"
SETTINGS="$CLAUDE_DIR/settings.json"
TAB=$(printf '\t')
if ! command -v jq >/dev/null 2>&1; then
printf '%s\n' '{"errors":["jq is not installed in this container, so marketplace items were not applied"]}'
exit 0
fi
R=$(mktemp -d) || exit 2
trap 'rm -rf "$R"' EXIT
for f in installed updated removed skipped errors newstate new_slugs final_slugs; do
: >"$R/$f"
done
report() { printf '%s\n' "$2" >>"$R/$1"; }
skip() { printf '%s\t%s\n' "$1" "$2" >>"$R/skipped"; }
fail() { printf '%s\n' "$1" >>"$R/errors"; }
record() { printf '%s\t%s\n' "$1" "$2" >>"$R/newstate"; }
emit_report() {
jq -cn \
--rawfile i "$R/installed" --rawfile u "$R/updated" --rawfile d "$R/removed" \
--rawfile s "$R/skipped" --rawfile e "$R/errors" '
def lines: split("\n") | map(select(length > 0));
{ installed: ($i | lines), updated: ($u | lines), removed: ($d | lines),
skipped: ($s | lines | map(split("\t") | { item: .[0], reason: (.[1:] | join("\t")) })),
errors: ($e | lines) }'
}
valid_key() {
case "$1" in
'' | [!A-Za-z0-9]* | *[!A-Za-z0-9._-]*) return 1 ;;
esac
[ "${#1}" -le 64 ]
}
valid_slug() {
case "$1" in
'' | -* | *[!a-z0-9-]*) return 1 ;;
esac
[ "${#1}" -le 64 ]
}
valid_commit() {
case "$1" in
'' | *[!0-9a-f]*) return 1 ;;
esac
[ "${#1}" -eq 40 ]
}
# Run `claude` serialised with the entrypoint's and every session's
# `claude update`, which rewrite ~/.claude/bin under the same lock.
claude_cmd() {
if command -v flock >/dev/null 2>&1; then
flock -w 120 "$LOCK" claude "$@" </dev/null >&2
else
claude "$@" </dev/null >&2
fi
}
owned() { jq -e --arg id "$1" '.items | has($id)' "$STATE" >/dev/null 2>&1; }
prev_commit() { jq -r --arg id "$1" '.items[$id].commit // ""' "$STATE"; }
in_manifest() {
jq -e --arg id "$1" 'any(.items[]; (.kind + ":" + .key) == $id)' "$MANIFEST" >/dev/null 2>&1
}
carry_forward() { record "$1" "$(jq -c --arg id "$1" '.items[$id]' "$STATE")"; }
outcome() {
p=$(prev_commit "$1")
if [ -z "$p" ]; then
report installed "$1"
elif [ "$p" != "$2" ]; then
report updated "$1"
fi
}
# ── Unpack ───────────────────────────────────────────────────────────────────
if [ ! -f "$INCOMING/payload.tar" ]; then
fail "no payload was uploaded"
emit_report
exit 0
fi
mkdir -p "$BASE/marketplace" "$BASE/hooks" "$BASE/plugins"
rm -rf "$WORK"
mkdir -p "$WORK"
if ! tar -xf "$INCOMING/payload.tar" -C "$WORK" >&2; then
rm -f "$INCOMING/payload.tar"
fail "the payload could not be unpacked"
emit_report
exit 0
fi
rm -f "$INCOMING/payload.tar"
MANIFEST="$WORK/manifest.json"
if ! jq -e '.version == 1' "$MANIFEST" >/dev/null 2>&1; then
fail "the payload manifest is missing or has an unsupported version"
emit_report
exit 0
fi
if ! jq -e '(.items | type) == "object"' "$STATE" >/dev/null 2>&1; then
printf '%s\n' '{"version":1,"items":{},"plugin_marketplaces":[]}' >"$STATE"
fi
# ── Agents, skills, commands, hooks ──────────────────────────────────────────
jq -r '.items[] | select(.kind != "plugin") | [.kind, .key, .commit] | @tsv' "$MANIFEST" >"$R/items.tsv"
while IFS="$TAB" read -r kind key commit; do
id="$kind:$key"
if ! valid_key "$key"; then skip "$id" "invalid item name"; continue; fi
if ! valid_commit "$commit"; then skip "$id" "invalid commit"; continue; fi
case "$kind" in
agent | command)
dir="$CLAUDE_DIR/${kind}s"
src="$WORK/${kind}s/$key.md"
dest="$dir/$key.md"
if [ ! -f "$src" ]; then fail "$id: missing from the payload"; continue; fi
if [ -e "$dest" ] && ! owned "$id"; then
skip "$id" "~/.claude/${kind}s/$key.md already exists and was not installed by Triple-C"
continue
fi
if ! { mkdir -p "$dir" && cp "$src" "$dest.tmp.$$" && mv -f "$dest.tmp.$$" "$dest"; }; then
rm -f "$dest.tmp.$$"
fail "$id: could not write $dest"
continue
fi
outcome "$id" "$commit"
record "$id" "$(jq -cn --arg c "$commit" --arg p "$dest" '{commit: $c, path: $p}')"
;;
skill)
dir="$CLAUDE_DIR/skills"
src="$WORK/skills/$key"
dest="$dir/$key"
if [ ! -d "$src" ]; then fail "$id: missing from the payload"; continue; fi
if [ -e "$dest" ] && ! owned "$id"; then
skip "$id" "~/.claude/skills/$key already exists and was not installed by Triple-C"
continue
fi
if ! { mkdir -p "$dir" && rm -rf "$dest" && cp -R "$src" "$dest"; }; then
fail "$id: could not write $dest"
continue
fi
outcome "$id" "$commit"
record "$id" "$(jq -cn --arg c "$commit" --arg p "$dest" '{commit: $c, path: $p}')"
;;
hook)
src="$WORK/hooks/$key"
dest="$BASE/hooks/$key"
entries=$(jq -c --arg k "$key" \
'first(.items[] | select(.kind == "hook" and .key == $k) | .settings) // {}' "$MANIFEST")
if ! printf '%s' "$entries" | jq -e 'type == "object" and all(.[]; type == "array")' >/dev/null 2>&1; then
skip "$id" "its hook settings are not an object of arrays"
continue
fi
if [ ! -d "$src" ]; then fail "$id: missing from the payload"; continue; fi
if ! { rm -rf "$dest" && cp -R "$src" "$dest"; }; then
fail "$id: could not write $dest"
continue
fi
outcome "$id" "$commit"
record "$id" "$(jq -cn --arg c "$commit" --arg p "$dest" --argjson e "$entries" \
'{commit: $c, path: $p, entries: $e}')"
;;
*)
skip "$id" "unknown item kind"
;;
esac
done <"$R/items.tsv"
# ── Removals (non-plugin) ────────────────────────────────────────────────────
cut -f1 "$R/newstate" >"$R/new_ids"
jq -r '.items | keys[]' "$STATE" >"$R/old_ids"
while read -r id; do
case "$id" in plugin:*) continue ;; esac
if grep -qxF "$id" "$R/new_ids"; then continue; fi
# Still selected but failed this run: keep the old files and record.
if in_manifest "$id"; then carry_forward "$id"; continue; fi
path=$(jq -r --arg id "$id" '.items[$id].path // ""' "$STATE")
case "$path" in
"$CLAUDE_DIR"/*) rm -rf "$path" && report removed "$id" || fail "$id: could not remove $path" ;;
*) fail "$id: refusing to remove unexpected path $path" ;;
esac
done <"$R/old_ids"
# ── Hook entries in settings.json ────────────────────────────────────────────
MERGE_ENTRIES='[.[] | .entries? // empty]
| reduce .[] as $e ({}; reduce ($e | to_entries[]) as $x (.; .[$x.key] += $x.value))'
OLD_HOOKS=$(jq -c "[.items[]] | $MERGE_ENTRIES" "$STATE")
NEW_HOOKS=$(cut -f2- "$R/newstate" | jq -cs "$MERGE_ENTRIES")
HOOKS_FAILED=0
if [ "$OLD_HOOKS" != "{}" ] || [ "$NEW_HOOKS" != "{}" ]; then
if [ -f "$SETTINGS" ]; then
current="$SETTINGS"
else
printf '{}\n' >"$R/empty.json"
current="$R/empty.json"
fi
if jq --argjson old "$OLD_HOOKS" --argjson new "$NEW_HOOKS" '
def remove_first($x):
(to_entries | map(select(.value == $x)) | first(.[].key) // null) as $i
| if $i == null then . else del(.[$i]) end;
reduce ($old | to_entries[]) as $ev (.;
if (.hooks[$ev.key] | type) == "array"
then reduce $ev.value[] as $g (.; .hooks[$ev.key] |= remove_first($g))
else . end)
| reduce ($new | to_entries[]) as $ev (.;
.hooks[$ev.key] = ((.hooks[$ev.key] // []) + $ev.value))
| if (.hooks | type) == "object" then .hooks |= with_entries(select(.value != [])) else . end
| if .hooks == {} then del(.hooks) else . end
' "$current" >"$SETTINGS.tmp.$$"; then
mv -f "$SETTINGS.tmp.$$" "$SETTINGS"
else
rm -f "$SETTINGS.tmp.$$"
HOOKS_FAILED=1
fail "~/.claude/settings.json is not valid JSON, so hook changes were not applied"
fi
fi
# ── Plugins ──────────────────────────────────────────────────────────────────
jq -r '.plugin_marketplaces[]?' "$STATE" >"$R/old_slugs"
jq -r '.plugin_marketplaces[] | .slug' "$MANIFEST" >"$R/new_slugs"
while read -r slug; do
if ! valid_slug "$slug"; then fail "invalid plugin marketplace name"; continue; fi
mname="triple-c-$slug"
dest="$BASE/plugins/$slug"
if ! { rm -rf "$dest" && cp -R "$WORK/plugins/$slug" "$dest"; }; then
fail "$mname: could not write $dest"
continue
fi
if grep -qxF "$slug" "$R/old_slugs"; then
claude_cmd plugin marketplace update "$mname" || fail "$mname: marketplace update failed"
elif ! claude_cmd plugin marketplace add "$dest"; then
claude_cmd plugin marketplace update "$mname" || { fail "$mname: could not be registered"; continue; }
fi
printf '%s\n' "$slug" >>"$R/final_slugs"
jq -r --arg s "$slug" '.items[] | select(.kind == "plugin" and .slug == $s) | [.key, .commit] | @tsv' \
"$MANIFEST" >"$R/plugins.tsv"
while IFS="$TAB" read -r key commit; do
id="plugin:$key"
if ! valid_key "$key"; then skip "$id" "invalid item name"; continue; fi
if ! valid_commit "$commit"; then skip "$id" "invalid commit"; continue; fi
p=$(prev_commit "$id")
if [ -z "$p" ]; then
claude_cmd plugin install "$key@$mname" || { fail "$id: install failed"; continue; }
report installed "$id"
elif [ "$p" != "$commit" ]; then
claude_cmd plugin uninstall "$key@$mname"
claude_cmd plugin install "$key@$mname" || { fail "$id: reinstall failed"; continue; }
report updated "$id"
fi
record "$id" "$(jq -cn --arg c "$commit" --arg s "$slug" '{commit: $c, slug: $s}')"
done <"$R/plugins.tsv"
done <"$R/new_slugs"
# Plugins no longer selected.
while read -r id; do
case "$id" in plugin:*) ;; *) continue ;; esac
if grep -qxF "$id" "$R/new_ids" || cut -f1 "$R/newstate" | grep -qxF "$id"; then continue; fi
if in_manifest "$id"; then carry_forward "$id"; continue; fi
key=${id#plugin:}
slug=$(jq -r --arg id "$id" '.items[$id].slug // ""' "$STATE")
if valid_key "$key" && valid_slug "$slug" && claude_cmd plugin uninstall "$key@triple-c-$slug"; then
report removed "$id"
else
fail "$id: uninstall failed"
carry_forward "$id"
fi
done <"$R/old_ids"
# Plugin marketplaces with nothing left in them.
cut -f2- "$R/newstate" | jq -r 'select(has("slug")) | .slug' >>"$R/final_slugs"
while read -r slug; do
if grep -qxF "$slug" "$R/final_slugs"; then continue; fi
valid_slug "$slug" || continue
claude_cmd plugin marketplace remove "triple-c-$slug" || fail "triple-c-$slug: could not be removed"
rm -rf "$BASE/plugins/$slug"
done <"$R/old_slugs"
# ── State ────────────────────────────────────────────────────────────────────
jq -Rn '[inputs | split("\t") | { key: .[0], value: (.[1:] | join("\t") | fromjson) }] | from_entries' \
<"$R/newstate" >"$R/items.json"
if [ "$HOOKS_FAILED" = 1 ]; then
# settings.json still holds the old entries, so the old records stay true.
jq -s '.[0] as $new | .[1].items as $old
| ($new | with_entries(select(.key | startswith("hook:") | not)))
+ ($old | with_entries(select(.key | startswith("hook:"))))' \
"$R/items.json" "$STATE" >"$R/items2.json" && mv -f "$R/items2.json" "$R/items.json"
fi
if jq -n --slurpfile it "$R/items.json" --rawfile sl "$R/final_slugs" \
'{ version: 1, items: $it[0], plugin_marketplaces: ($sl | split("\n") | map(select(length > 0)) | unique) }' \
>"$STATE.tmp.$$"; then
mv -f "$STATE.tmp.$$" "$STATE"
else
rm -f "$STATE.tmp.$$"
fail "the marketplace state could not be saved"
fi
rm -rf "$WORK"
emit_report
- Step 5: Run the script tests
Run: cd app/src-tauri && cargo test --lib marketplace::sync_script_tests
Expected: 8 passed.
Also check the script is POSIX: sh -n app/src-tauri/src/marketplace/sync.sh && (command -v dash >/dev/null && dash -n app/src-tauri/src/marketplace/sync.sh || true)
Expected: no output.
- Step 6: Commit
cd /workspace/triple-c
git add app/src-tauri/src/marketplace/sync.sh app/src-tauri/src/marketplace/sync.rs app/src-tauri/src/marketplace/sync_script_tests.rs app/src-tauri/src/marketplace/mod.rs
git commit -m "Marketplace: container sync script and its tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 9: Sync orchestration and the container-start hook
Files:
- Modify:
app/src-tauri/src/marketplace/sync.rs - Modify:
app/src-tauri/src/marketplace/mod.rs(sync_project,should_sync,spawn_project_sync,SYNC_FINISHED_EVENT) - Modify:
app/src-tauri/src/commands/project_commands.rs(start_project_container_locked, aftersync_bedrock_credentials)
Interfaces:
-
Consumes:
docker::exec::{exec_oneshot_as, exec_oneshot_streams_as, upload_bytes_to_container}(existing:exec_oneshot_as(container_id, user, cmd, env) -> Result<(String, i64), String>,exec_oneshot_streams_as(...) -> Result<(String, String, i64), String>,upload_bytes_to_container(container_id, dest_dir, file_name, data, mode) -> Result<String, String>; uploaded files are root-owned anddest_dirmust exist);payload::{build_payload, PayloadInput, Payload}(Task 7);models::marketplace::effective_installs(Task 2);AppState.marketplace(Task 6). -
Produces:
sync::{sync_container, parse_report, report_from_result};marketplace::{sync_project, should_sync, spawn_project_sync, SYNC_FINISHED_EVENT}. Eventmarketplace-sync-finishedwith payload{ "project_id": String, "report": SyncReport }. -
Step 1: Write the failing tests
Append to app/src-tauri/src/marketplace/sync.rs:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_report_is_the_last_non_empty_stdout_line() {
let out = "noise\n{\"installed\":[\"agent:a\"],\"errors\":[]}\n\n";
let r = parse_report(out).unwrap();
assert_eq!(r.installed, vec!["agent:a"]);
assert!(r.skipped.is_empty());
}
#[test]
fn missing_or_garbled_reports_are_errors() {
assert!(parse_report("").unwrap_err().contains("no report"));
assert!(parse_report("not json\n").unwrap_err().contains("could not be read"));
}
#[test]
fn sync_failure_does_not_fail_start() {
// A failed sync becomes a report with the error in it — never an Err
// that could propagate into container start.
let r = report_from_result(Err("container went away".into()));
assert_eq!(r.errors, vec!["container went away"]);
assert!(!r.finished_at.is_empty());
let ok = report_from_result(Ok(SyncReport { installed: vec!["hook:h".into()], ..Default::default() }));
assert_eq!(ok.installed, vec!["hook:h"]);
assert!(chrono::DateTime::parse_from_rfc3339(&ok.finished_at).is_ok());
}
#[test]
fn the_embedded_script_is_the_sync_script() {
assert!(SYNC_SCRIPT.starts_with("#!/bin/sh"));
assert!(SYNC_SCRIPT.contains("MARKETPLACE_INCOMING"));
}
}
Append to the tests module in app/src-tauri/src/marketplace/mod.rs:
#[test]
fn a_project_that_never_had_items_is_not_synced() {
let data = tempfile::tempdir().unwrap();
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let settings = settings_with("https://example.invalid/r.git");
let project = crate::models::Project::new("p".into(), vec![]);
assert!(!should_sync(&mgr, &settings, &project));
}
#[test]
fn a_project_with_items_or_a_previous_sync_is_synced() {
let data = tempfile::tempdir().unwrap();
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let mut settings = settings_with("https://example.invalid/r.git");
let project = crate::models::Project::new("p".into(), vec![]);
settings.global_marketplace_installs = vec![install(ItemKind::Agent, "a", &"a".repeat(40))];
assert!(should_sync(&mgr, &settings, &project), "global items apply");
// Everything was uninstalled since the last sync: the container still
// holds the old files, so it must be synced to remove them.
settings.global_marketplace_installs.clear();
mgr.put_report(&project.id, SyncReport::default());
assert!(should_sync(&mgr, &settings, &project));
}
- Step 2: Run tests to verify they fail
Run: cd app/src-tauri && cargo test --lib marketplace::
Expected: FAIL to compile — parse_report, report_from_result, should_sync not found.
- Step 3: Implement
sync.rs
Replace the body of app/src-tauri/src/marketplace/sync.rs above the tests with:
//! Pushes a project's marketplace payload into its container and runs the
//! sync script there (spec §4).
use std::time::Duration;
use super::payload::Payload;
use crate::docker::exec::{exec_oneshot_as, exec_oneshot_streams_as, upload_bytes_to_container};
use crate::models::marketplace::SyncReport;
/// Where the payload and the script are uploaded. Owned by `claude`.
pub const INCOMING_DIR: &str = "/home/claude/.claude/triple-c/marketplace/incoming";
/// The sync script. Shipped with the app and uploaded on every sync, so a new
/// app version reaches existing containers without an image migration.
pub const SYNC_SCRIPT: &str = include_str!("sync.sh");
/// True once the entrypoint has finished: its last step execs this exact
/// command line. Before that it may still be merging `settings.json` or running
/// `claude update`, both of which the sync would race.
const READY_PROBE: &str = "pgrep -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null";
const READY_TIMEOUT: Duration = Duration::from_secs(180);
const READY_POLL: Duration = Duration::from_secs(2);
/// Run as root: `~/.claude` is a volume and `triple-c/` may not exist yet, and
/// the uploads below are root-owned files in a directory `claude` must own so
/// the script can delete them.
const PREPARE_SCRIPT: &str = r#"set -e
d=/home/claude/.claude/triple-c/marketplace/incoming
mkdir -p "$d"
chown -R claude:claude /home/claude/.claude/triple-c
rm -f "$d/payload.tar" "$d/sync.sh""#;
fn sh(script: &str) -> Vec<String> {
vec!["sh".to_string(), "-c".to_string(), script.to_string()]
}
async fn wait_until_ready(container_id: &str) -> Result<(), String> {
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
loop {
let (_, code) = exec_oneshot_as(container_id, "root", sh(READY_PROBE), vec![]).await?;
if code == 0 {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Err(format!(
"The container did not finish starting within {} seconds, so marketplace items \
were not applied. They are applied on the next start, or with Apply now.",
READY_TIMEOUT.as_secs()
));
}
tokio::time::sleep(READY_POLL).await;
}
}
fn tail(text: &str, max: usize) -> &str {
let text = text.trim();
if text.len() <= max {
return text;
}
let mut start = text.len() - max;
while !text.is_char_boundary(start) {
start += 1;
}
&text[start..]
}
/// Wait for readiness, upload the payload and the script, run the script as
/// `claude`, and return its report.
pub async fn sync_container(container_id: &str, payload: &Payload) -> Result<SyncReport, String> {
wait_until_ready(container_id).await?;
let (out, code) = exec_oneshot_as(container_id, "root", sh(PREPARE_SCRIPT), vec![]).await?;
if code != 0 {
return Err(format!("Could not prepare the container for the marketplace sync: {}", tail(&out, 500)));
}
upload_bytes_to_container(container_id, INCOMING_DIR, "payload.tar", &payload.tar, 0o644).await?;
upload_bytes_to_container(container_id, INCOMING_DIR, "sync.sh", SYNC_SCRIPT.as_bytes(), 0o755).await?;
let (stdout, stderr, code) = exec_oneshot_streams_as(
container_id,
"claude",
vec!["sh".to_string(), format!("{INCOMING_DIR}/sync.sh")],
vec!["HOME=/home/claude".to_string()],
)
.await?;
parse_report(&stdout).map_err(|e| {
format!("The marketplace sync script failed (exit {code}): {e}. {}", tail(&stderr, 500))
})
}
/// The script's report is the last non-empty line of stdout.
pub fn parse_report(stdout: &str) -> Result<SyncReport, String> {
let line = stdout
.lines()
.rev()
.map(str::trim)
.find(|l| !l.is_empty())
.ok_or_else(|| "the sync script printed no report".to_string())?;
serde_json::from_str(line).map_err(|e| format!("the sync script's report could not be read: {e}"))
}
/// A sync never fails its caller: an error becomes a report that says so.
pub fn report_from_result(r: Result<SyncReport, String>) -> SyncReport {
let mut report = match r {
Ok(report) => report,
Err(e) => SyncReport { errors: vec![e], ..Default::default() },
};
report.finished_at = chrono::Utc::now().to_rfc3339();
report
}
- Step 4: Implement the project-level sync in
mod.rs
Add pub mod sync; if not present, extend the imports with use std::sync::Arc;, use tauri::Emitter; and use crate::models::marketplace::effective_installs;, then add:
/// Emitted when a background sync finishes. Payload `{ project_id, report }`.
pub const SYNC_FINISHED_EVENT: &str = "marketplace-sync-finished";
fn project_installs(settings: &AppSettings, project: &Project) -> Vec<MarketplaceInstall> {
effective_installs(
&settings.global_marketplace_installs,
&project.marketplace_disabled,
&project.marketplace_installs,
)
}
/// Build the project's payload and sync it into its running container. The
/// report is stored (and persisted) whatever happens.
pub async fn sync_project(
mgr: &MarketplaceManager,
settings: &AppSettings,
project: &Project,
container_id: &str,
) -> SyncReport {
let installs = project_installs(settings, project);
let marketplaces = settings.marketplaces.clone();
let root = mgr.data_root().to_path_buf();
let built = tokio::task::spawn_blocking(move || {
payload::build_payload(&payload::PayloadInput {
installs: &installs,
marketplaces: &marketplaces,
data_root: &root,
})
})
.await
.map_err(|e| format!("Building the marketplace payload failed: {e}"))
.and_then(|r| r);
let result = match built {
Ok(p) => sync::sync_container(container_id, &p).await.map(|mut report| {
let mut skipped = p.skipped.clone();
skipped.extend(report.skipped);
report.skipped = skipped;
report
}),
Err(e) => Err(e),
};
let report = sync::report_from_result(result);
mgr.put_report(&project.id, report.clone());
report
}
/// A project with no items that has never been synced has nothing to add and
/// nothing to remove, so its start does not wait on a sync at all.
pub fn should_sync(mgr: &MarketplaceManager, settings: &AppSettings, project: &Project) -> bool {
!project_installs(settings, project).is_empty() || mgr.report(&project.id).is_some()
}
/// Sync in the background after a container start. The sync waits for the
/// entrypoint to finish (which can include a two-minute `claude update`), and
/// its failure must never fail the start — so the start never awaits it.
pub fn spawn_project_sync(
app: tauri::AppHandle,
mgr: Arc<MarketplaceManager>,
settings: AppSettings,
project: Project,
container_id: String,
) {
if !should_sync(&mgr, &settings, &project) {
return;
}
tauri::async_runtime::spawn(async move {
let report = sync_project(&mgr, &settings, &project, &container_id).await;
if !report.errors.is_empty() {
log::warn!("Marketplace sync for project {} reported errors: {:?}", project.id, report.errors);
}
let _ = app.emit(
SYNC_FINISHED_EVENT,
serde_json::json!({ "project_id": project.id, "report": report }),
);
});
}
- Step 5: Run the tests
Run: cd app/src-tauri && cargo test --lib marketplace::
Expected: all pass.
- Step 6: Hook the sync into container start
In app/src-tauri/src/commands/project_commands.rs, inside start_project_container_locked, directly after the existing block
if let Err(e) = docker::sync_bedrock_credentials(&container_id, &project).await {
log::warn!("Failed to sync AWS credentials for project {}: {}", project.id, e);
}
insert:
// Marketplace items sync in the background — see `spawn_project_sync`
// for why the start never waits on it or fails because of it.
crate::marketplace::spawn_project_sync(
app_handle.clone(),
state.marketplace.clone(),
state.settings_store.get(),
project.clone(),
container_id.clone(),
);
Run: cd app/src-tauri && cargo check --lib && cargo test --lib project_commands
Expected: compiles; existing project command tests still pass.
- Step 7: Commit
cd /workspace/triple-c
rustfmt --edition 2021 app/src-tauri/src/marketplace/sync.rs app/src-tauri/src/marketplace/mod.rs
git add app/src-tauri/src/marketplace/sync.rs app/src-tauri/src/marketplace/mod.rs app/src-tauri/src/commands/project_commands.rs
git commit -m "Marketplace: sync projects into their containers on start
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 10: GitHub sign-in through gh inside a container
Files:
- Create:
app/src-tauri/src/marketplace/gh_login.rs - Modify:
app/src-tauri/src/marketplace/mod.rs(addpub mod gh_login;)
Interfaces:
- Consumes:
docker::exec::{create_attached_exec_as, wait_for_exec_exit, AttachedExec}(existing:create_attached_exec_as(container_id, cmd, tty, user, working_dir) -> Result<AttachedExec, String>;AttachedExec { exec_id, output: Stream<Item = Result<LogOutput, _>>, input: AsyncWrite };wait_for_exec_exit(exec_id) -> Option<i64>). - Produces:
gh_login::{run_gh_container_login, parse_device_prompt, extract_token, take_display_lines, strip_ansi, valid_host, CODE_EVENT, OUTPUT_EVENT}. Events:marketplace-gh-login-code{ account_id, code, url };marketplace-gh-login-output{ account_id, chunk }(complete lines only; never contains the token).
Why the flags: --web does the device flow; --git-protocol ssh --skip-ssh-key avoids gh's "Authenticate Git with your GitHub credentials?" prompt (which https triggers and which writes a credential helper into the git config); GH_CONFIG_DIR and GIT_CONFIG_GLOBAL point into a temp dir that is deleted on exit, so Claude in that container is not left logged into the user's GitHub; BROWSER=true makes gh's "open the browser" step a no-op inside the container. The host arrives as $1 (argv, never interpolated) because create_attached_exec_as takes no env. The Enter that dismisses "Press Enter to open github.com in your browser…" is its own write, sent 250 ms after the prompt appears (the PR #64 lesson: text and Enter in one write can be swallowed as a paste).
- Step 1: Write the failing tests
Create app/src-tauri/src/marketplace/gh_login.rs:
//! GitHub sign-in through `gh auth login --web` inside a running container, for
//! hosts that have no `gh` of their own. The token is read back through the
//! exec, returned to the caller for the keychain, and never emitted, logged or
//! left behind in the container.
use std::time::Duration;
use futures_util::StreamExt;
use tauri::{AppHandle, Emitter};
use tokio::io::AsyncWriteExt;
use tokio::sync::oneshot;
use crate::docker::exec::{create_attached_exec_as, wait_for_exec_exit, AttachedExec};
#[cfg(test)]
mod tests {
use super::*;
const GH_PROMPT: &str = "! First copy your one-time code: 4F2A-9C1B\nPress Enter to open github.com in your browser... ";
#[test]
fn the_device_code_is_read_and_the_url_defaults_to_the_host() {
assert_eq!(
parse_device_prompt(GH_PROMPT, "github.com"),
Some(("4F2A-9C1B".to_string(), "https://github.com/login/device".to_string()))
);
}
#[test]
fn an_explicit_device_url_wins() {
let out = "! First copy your one-time code: AB12-CD34\nOpen this URL to continue in your web browser: https://ghe.example.com/login/device\n";
assert_eq!(
parse_device_prompt(out, "ghe.example.com"),
Some(("AB12-CD34".to_string(), "https://ghe.example.com/login/device".to_string()))
);
}
#[test]
fn no_code_yet_means_no_prompt() {
assert_eq!(parse_device_prompt("! First copy your one-time", "github.com"), None);
assert_eq!(parse_device_prompt("", "github.com"), None);
}
#[test]
fn the_token_is_taken_from_between_the_markers() {
let out = "✓ Logged in\n__TRIPLEC_TOKEN_BEGIN__test-token-not-real__TRIPLEC_TOKEN_END__\n";
assert_eq!(extract_token(out), Some("test-token-not-real".to_string()));
assert_eq!(extract_token("__TRIPLEC_TOKEN_BEGIN__test-token-not-real"), None, "unterminated");
assert_eq!(extract_token("__TRIPLEC_TOKEN_BEGIN____TRIPLEC_TOKEN_END__"), None, "empty");
assert_eq!(extract_token("__TRIPLEC_TOKEN_BEGIN__a b__TRIPLEC_TOKEN_END__"), None, "whitespace");
}
#[test]
fn only_complete_lines_are_shown_and_the_token_line_never_is() {
let mut pending = String::new();
assert_eq!(take_display_lines(&mut pending, "! First copy your one-"), "");
assert_eq!(take_display_lines(&mut pending, "time code: 4F2A-9C1B\nPress"), "! First copy your one-time code: 4F2A-9C1B\n");
assert_eq!(pending, "Press");
let shown = take_display_lines(
&mut pending,
" Enter\n__TRIPLEC_TOKEN_BEGIN__test-token-not-real__TRIPLEC_TOKEN_END__\ndone\n",
);
assert_eq!(shown, "Press Enter\ndone\n");
assert!(!shown.contains("test-token-not-real"));
}
#[test]
fn escape_sequences_and_carriage_returns_are_removed() {
assert_eq!(strip_ansi("\u{1b}[1;32m✓\u{1b}[0m done\r\n"), "✓ done\n");
assert_eq!(strip_ansi("a\u{1b}]8;;https://x\u{7}link\u{1b}]8;;\u{7}b"), "alinkb");
assert_eq!(strip_ansi("cut\u{1b}["), "cut");
}
#[test]
fn hosts_are_plain_names() {
assert!(valid_host("github.com"));
assert!(valid_host("ghe.corp-1.example"));
for bad in ["", "-x", "a b", "a;b", "a/b", "$(id)"] {
assert!(!valid_host(bad), "{bad:?}");
}
}
}
- Step 2: Run tests to verify they fail
Add pub mod gh_login; to app/src-tauri/src/marketplace/mod.rs.
Run: cd app/src-tauri && cargo test --lib marketplace::gh_login
Expected: FAIL to compile — functions not found.
- Step 3: Implement
Insert above #[cfg(test)] in gh_login.rs:
pub const CODE_EVENT: &str = "marketplace-gh-login-code";
pub const OUTPUT_EVENT: &str = "marketplace-gh-login-output";
const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const ENTER_DELAY: Duration = Duration::from_millis(250);
const TOKEN_BEGIN: &str = "__TRIPLEC_TOKEN_BEGIN__";
const TOKEN_END: &str = "__TRIPLEC_TOKEN_END__";
const MAX_TRANSCRIPT: usize = 64 * 1024;
const MAX_PENDING_LINE: usize = 4096;
/// Constant script; the host is `$1`. See the task notes for each flag.
const GH_LOGIN_SCRIPT: &str = r#"set -eu
host="$1"
case "$host" in
'' | -* | *[!A-Za-z0-9.-]*) echo "invalid host" >&2; exit 2 ;;
esac
export HOME=/home/claude
d=$(mktemp -d)
trap 'rm -rf "$d"' EXIT
export GH_CONFIG_DIR="$d" GIT_CONFIG_GLOBAL="$d/gitconfig" BROWSER=true
gh auth login --hostname "$host" --web --git-protocol ssh --skip-ssh-key --scopes repo
t=$(gh auth token --hostname "$host")
printf '\n%s%s%s\n' __TRIPLEC_TOKEN_BEGIN__ "$t" __TRIPLEC_TOKEN_END__
"#;
pub fn valid_host(host: &str) -> bool {
!host.is_empty()
&& host.len() <= 253
&& !host.starts_with('-')
&& host.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
}
/// Remove CSI and OSC sequences, other two-byte escapes, and `\r`. An
/// unterminated sequence at the end is dropped.
pub fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\u{1b}' => match chars.next() {
Some('[') => {
for c in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&c) {
break;
}
}
}
Some(']') => {
while let Some(c) = chars.next() {
if c == '\u{7}' {
break;
}
if c == '\u{1b}' && chars.peek() == Some(&'\\') {
chars.next();
break;
}
}
}
_ => {}
},
'\r' => {}
c => out.push(c),
}
}
out
}
/// gh prints `! First copy your one-time code: XXXX-XXXX`, then either a URL
/// or "Press Enter to open <host> in your browser". Returns (code, url).
pub fn parse_device_prompt(output: &str, host: &str) -> Option<(String, String)> {
const LABEL: &str = "one-time code:";
let at = output.find(LABEL)? + LABEL.len();
let code: String = output[at..]
.trim_start()
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect();
if code.len() < 6 || !code.contains('-') {
return None;
}
let url = output
.split_whitespace()
.find(|w| w.starts_with("https://") && w.contains("/login/device"))
.map(|w| w.trim_end_matches(|c: char| !c.is_ascii_alphanumeric() && c != '/').to_string())
.unwrap_or_else(|| format!("https://{host}/login/device"));
Some((code, url))
}
pub fn extract_token(text: &str) -> Option<String> {
let start = text.find(TOKEN_BEGIN)? + TOKEN_BEGIN.len();
let end = start + text[start..].find(TOKEN_END)?;
let token = text[start..end].trim();
if token.is_empty() || token.chars().any(|c| c.is_whitespace() || c.is_control()) {
return None;
}
Some(token.to_string())
}
/// Append `chunk` and hand back the complete lines, minus any line carrying the
/// token markers. A partial line waits in `pending` (so a marker split across
/// chunks is never shown), and is dropped if it grows past a bound.
pub fn take_display_lines(pending: &mut String, chunk: &str) -> String {
pending.push_str(chunk);
let Some(last_nl) = pending.rfind('\n') else {
if pending.len() > MAX_PENDING_LINE {
pending.clear();
}
return String::new();
};
let complete: String = pending.drain(..=last_nl).collect();
complete
.lines()
.filter(|l| !l.contains("__TRIPLEC_TOKEN"))
.map(|l| format!("{l}\n"))
.collect()
}
fn push_capped(buf: &mut String, text: &str) {
buf.push_str(text);
if buf.len() > MAX_TRANSCRIPT {
let mut cut = buf.len() - MAX_TRANSCRIPT;
while !buf.is_char_boundary(cut) {
cut += 1;
}
buf.drain(..cut);
}
}
/// What to show when the login ends without a token: the last few lines, with
/// any marker line removed.
fn failure_tail(transcript: &str) -> String {
let lines: Vec<&str> = transcript
.lines()
.filter(|l| !l.contains("__TRIPLEC_TOKEN") && !l.trim().is_empty())
.collect();
lines[lines.len().saturating_sub(5)..].join("\n")
}
/// Run `gh auth login --web` in the container and return the token it minted.
pub async fn run_gh_container_login(
app: &AppHandle,
account_id: &str,
container_id: &str,
host: &str,
mut cancel: oneshot::Receiver<()>,
) -> Result<String, String> {
if !valid_host(host) {
return Err(format!("\"{host}\" is not a valid host name."));
}
let AttachedExec { exec_id, mut output, mut input } = create_attached_exec_as(
container_id,
vec![
"sh".to_string(),
"-c".to_string(),
GH_LOGIN_SCRIPT.to_string(),
"triple-c-gh-login".to_string(),
host.to_string(),
],
true,
"claude",
"/home/claude",
)
.await?;
let deadline = tokio::time::Instant::now() + LOGIN_TIMEOUT;
let mut transcript = String::new();
let mut pending = String::new();
let mut code_sent = false;
let mut enter_sent = false;
loop {
let next = tokio::select! {
_ = &mut cancel => {
return Err("GitHub sign-in cancelled. Nothing was stored.".to_string());
}
next = tokio::time::timeout_at(deadline, output.next()) => match next {
Ok(next) => next,
Err(_) => {
return Err(format!(
"Timed out after {} minutes waiting for the GitHub sign-in. Nothing was stored.",
LOGIN_TIMEOUT.as_secs() / 60
));
}
},
};
let frame = match next {
Some(Ok(frame)) => frame,
Some(Err(e)) => return Err(format!("Lost the connection to gh: {e}. Nothing was stored.")),
None => break,
};
let text = strip_ansi(&String::from_utf8_lossy(&frame.into_bytes()));
push_capped(&mut transcript, &text);
let shown = take_display_lines(&mut pending, &text);
if !shown.is_empty() {
let _ = app.emit(OUTPUT_EVENT, serde_json::json!({ "account_id": account_id, "chunk": shown }));
}
if !code_sent {
if let Some((code, url)) = parse_device_prompt(&transcript, host) {
let _ = app.emit(
CODE_EVENT,
serde_json::json!({ "account_id": account_id, "code": code, "url": url }),
);
code_sent = true;
}
}
if code_sent && !enter_sent && transcript.contains("Press Enter") {
tokio::time::sleep(ENTER_DELAY).await;
input
.write_all(b"\r")
.await
.map_err(|e| format!("Could not answer gh's prompt: {e}. Nothing was stored."))?;
let _ = input.flush().await;
enter_sent = true;
}
}
let status = wait_for_exec_exit(&exec_id).await;
if let Some(token) = extract_token(&transcript) {
return Ok(token);
}
Err(format!(
"gh did not complete the sign-in (exit status {}). Nothing was stored.\n{}",
status.map(|c| c.to_string()).unwrap_or_else(|| "unknown".to_string()),
failure_tail(&transcript)
))
}
- Step 4: Run tests to verify they pass
Run: cd app/src-tauri && cargo test --lib marketplace::gh_login
Expected: 7 passed.
- Step 5: Commit
cd /workspace/triple-c
rustfmt --edition 2021 app/src-tauri/src/marketplace/gh_login.rs
git add app/src-tauri/src/marketplace/gh_login.rs app/src-tauri/src/marketplace/mod.rs
git commit -m "Marketplace: GitHub sign-in through gh inside a container
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 11: Tauri commands, store-owned fields, wiring and startup refresh
Files:
- Create:
app/src-tauri/src/commands/marketplace_commands.rs - Modify:
app/src-tauri/src/commands/mod.rs(addpub mod marketplace_commands;) - Modify:
app/src-tauri/src/lib.rs(generate_handler!entries; startup refresh; drop Task 6'slet _ = &marketplace_setup;) - Modify:
app/src-tauri/capabilities/default.json(21 grants) - Modify:
app/src-tauri/src/commands/settings_commands.rs(restore_marketplace_fields) - Modify:
app/src-tauri/src/commands/project_commands.rs(restore_store_owned_fields) - Modify:
app/src-tauri/src/commands/settings_export_commands.rs(apply_settings_import)
Interfaces:
-
Consumes: everything in
crate::marketplacefrom Tasks 3–10;models::marketplace::{InstallScope, ProjectSyncResult, …}(Task 2);storage::secure::{store_marketplace_token, delete_marketplace_token}andauth::{host_of, validate_token, resolve_credential, gh_host_available, gh_host_login}(Task 5);crate::docker::container::is_container_running(&str) -> Result<bool, String>. -
Produces: the 21 commands in the contract table, with exactly those names, argument names and return types;
opspure helpers (upsert_install,remove_install,set_disabled,repin,validate_label,validate_branch,validate_host). -
Note for the frontend (Task 15):
start_marketplace_gh_container_logingenerates the account id itself, so itsmarketplace-gh-login-*events carry an id the dialog has not seen yet. Only one login can run at a time, so the dialog should accept events while it is open without filtering onaccount_id. -
Step 1: Write the failing tests for the pure helpers and store-owned fields
Create app/src-tauri/src/commands/marketplace_commands.rs with the helper module and its tests first:
//! Marketplace commands: configure marketplaces and accounts, browse, install,
//! update, and push installs into running containers. Spec:
//! `docs/superpowers/specs/2026-09-27-marketplace-design.md`.
use tauri::{AppHandle, State};
use tokio::sync::oneshot;
use crate::docker::container::is_container_running;
use crate::marketplace::{self as mk, auth, catalog, diff, gh_login, git, tree::GitTree, MarketplaceManager};
use crate::models::marketplace::{
is_valid_commit, is_valid_item_key, AccountMethod, FileDiff, InstallScope, ItemUpdate, Marketplace,
MarketplaceAccount, MarketplaceInstall, MarketplaceItemRef, MarketplaceSnapshot, ProjectSyncResult,
SyncReport,
};
use crate::models::{AppSettings, Project};
use crate::storage::secure;
use crate::AppState;
/// Pure list/field operations behind the commands, kept apart so they are
/// testable without a Tauri runtime.
pub(crate) mod ops {
use crate::models::marketplace::{MarketplaceInstall, MarketplaceItemRef};
/// Insert, or replace the install of the same item (a re-install re-pins).
pub fn upsert_install(list: &mut Vec<MarketplaceInstall>, inst: MarketplaceInstall) {
match list.iter_mut().find(|i| i.item_ref() == inst.item_ref()) {
Some(existing) => *existing = inst,
None => list.push(inst),
}
}
pub fn remove_install(list: &mut Vec<MarketplaceInstall>, item: &MarketplaceItemRef) -> bool {
let before = list.len();
list.retain(|i| &i.item_ref() != item);
list.len() != before
}
pub fn set_disabled(list: &mut Vec<MarketplaceItemRef>, item: &MarketplaceItemRef, disabled: bool) {
list.retain(|r| r != item);
if disabled {
list.push(item.clone());
list.sort();
}
}
pub fn repin(list: &mut [MarketplaceInstall], item: &MarketplaceItemRef, commit: &str) -> bool {
match list.iter_mut().find(|i| &i.item_ref() == item) {
Some(i) => {
i.commit = commit.to_string();
true
}
None => false,
}
}
pub fn validate_label(label: &str) -> Result<String, String> {
let label = label.trim();
if label.is_empty() {
return Err("Enter a name.".to_string());
}
if label.chars().count() > 80 || label.chars().any(char::is_control) {
return Err("Names are at most 80 characters, with no control characters.".to_string());
}
Ok(label.to_string())
}
/// `None` or blank means the repository's default branch.
pub fn validate_branch(branch: Option<String>) -> Result<Option<String>, String> {
let Some(b) = branch.map(|b| b.trim().to_string()).filter(|b| !b.is_empty()) else {
return Ok(None);
};
let ok = b.len() <= 200
&& !b.starts_with('-')
&& !b.starts_with('/')
&& !b.ends_with('/')
&& !b.contains("..")
&& !b.contains("//")
&& b.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/'));
if ok {
Ok(Some(b))
} else {
Err(format!("\"{b}\" is not a valid branch name."))
}
}
/// Lowercased host name with an optional `:port`.
pub fn validate_host(host: &str) -> Result<String, String> {
let host = host.trim().to_ascii_lowercase();
let (name, port) = match host.split_once(':') {
Some((n, p)) => (n, Some(p)),
None => (host.as_str(), None),
};
let name_ok = !name.is_empty()
&& name.len() <= 253
&& !name.starts_with('-')
&& !name.starts_with('.')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-');
let port_ok = port.map_or(true, |p| !p.is_empty() && p.len() <= 5 && p.chars().all(|c| c.is_ascii_digit()));
if name_ok && port_ok {
Ok(host)
} else {
Err(format!("\"{host}\" is not a valid host name."))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::marketplace::ItemKind;
fn r(key: &str) -> MarketplaceItemRef {
MarketplaceItemRef { marketplace_id: "m".into(), kind: ItemKind::Agent, key: key.into() }
}
fn i(key: &str, commit: &str) -> MarketplaceInstall {
MarketplaceInstall { marketplace_id: "m".into(), kind: ItemKind::Agent, key: key.into(), commit: commit.into() }
}
#[test]
fn upsert_replaces_the_same_item_instead_of_duplicating_it() {
let mut list = vec![i("a", "1"), i("b", "1")];
upsert_install(&mut list, i("a", "2"));
upsert_install(&mut list, i("c", "1"));
assert_eq!(list, vec![i("a", "2"), i("b", "1"), i("c", "1")]);
}
#[test]
fn remove_reports_whether_anything_was_removed() {
let mut list = vec![i("a", "1")];
assert!(!remove_install(&mut list, &r("zzz")));
assert!(remove_install(&mut list, &r("a")));
assert!(list.is_empty());
}
#[test]
fn disabling_is_idempotent_and_sorted() {
let mut list = vec![];
set_disabled(&mut list, &r("b"), true);
set_disabled(&mut list, &r("a"), true);
set_disabled(&mut list, &r("a"), true);
assert_eq!(list, vec![r("a"), r("b")]);
set_disabled(&mut list, &r("a"), false);
assert_eq!(list, vec![r("b")]);
}
#[test]
fn repin_moves_only_the_named_item() {
let mut list = vec![i("a", "1"), i("b", "1")];
assert!(repin(&mut list, &r("b"), "2"));
assert!(!repin(&mut list, &r("c"), "2"));
assert_eq!(list, vec![i("a", "1"), i("b", "2")]);
}
#[test]
fn labels_branches_and_hosts_are_validated() {
assert_eq!(validate_label(" Work ").unwrap(), "Work");
assert!(validate_label(" ").is_err());
assert_eq!(validate_branch(None).unwrap(), None);
assert_eq!(validate_branch(Some(" ".into())).unwrap(), None);
assert_eq!(validate_branch(Some("release/1.x".into())).unwrap(), Some("release/1.x".into()));
for bad in ["-x", "a..b", "a b", "a;b", "/a", "a/"] {
assert!(validate_branch(Some(bad.into())).is_err(), "{bad}");
}
assert_eq!(validate_host("GitHub.com").unwrap(), "github.com");
assert_eq!(validate_host("repo.example.net:3000").unwrap(), "repo.example.net:3000");
for bad in ["", "-a", "a b", "a/b", "a:", "a:x", "a;rm"] {
assert!(validate_host(bad).is_err(), "{bad}");
}
}
}
}
Add pub mod marketplace_commands; to app/src-tauri/src/commands/mod.rs.
Append to the tests module in app/src-tauri/src/commands/settings_commands.rs:
#[test]
fn a_stale_settings_save_cannot_overwrite_marketplace_state() {
use crate::models::marketplace::Marketplace;
let mut stored = AppSettings::default();
stored.marketplaces.push(Marketplace {
id: "m1".into(),
name: "Team".into(),
url: "https://example.invalid/r.git".into(),
branch: None,
account_id: None,
});
// The frontend's copy predates the marketplace being added.
let mut incoming = AppSettings::default();
incoming.auto_check_updates = false;
restore_marketplace_fields(&mut incoming, &stored);
assert_eq!(incoming.marketplaces, stored.marketplaces);
assert!(!incoming.auto_check_updates, "the edit the save was for still applies");
}
Append to the tests module in app/src-tauri/src/commands/project_commands.rs:
/// The marketplace commands own a project's installs and opt-outs; the
/// Config tab's next unrelated save carries a stale copy of both.
#[test]
fn a_stale_save_cannot_undo_a_marketplace_install() {
use crate::models::marketplace::{ItemKind, MarketplaceInstall, MarketplaceItemRef};
let (mut stored, mut payload) = stored_and_stale_payload();
stored.marketplace_installs = vec![MarketplaceInstall {
marketplace_id: "m1".into(),
kind: ItemKind::Agent,
key: "code-reviewer".into(),
commit: "a".repeat(40),
}];
stored.marketplace_disabled =
vec![MarketplaceItemRef { marketplace_id: "m1".into(), kind: ItemKind::Hook, key: "h".into() }];
restore_store_owned_fields(&mut payload, &stored);
assert_eq!(payload.marketplace_installs, stored.marketplace_installs);
assert_eq!(payload.marketplace_disabled, stored.marketplace_disabled);
}
- Step 2: Run tests to verify they fail
Run: cd app/src-tauri && cargo test --lib marketplace_commands settings_commands project_commands
Expected: FAIL — restore_marketplace_fields not found; the project test fails its assertions (the fields are not restored yet). (Cargo accepts one filter; run the three names one at a time if needed: cargo test --lib marketplace_commands, then settings_commands, then project_commands.)
- Step 3: Make marketplace fields store-owned
In app/src-tauri/src/commands/settings_commands.rs, add above update_settings:
/// Marketplace state is written only by the marketplace commands
/// (`commands/marketplace_commands.rs`), each of which returns fresh settings.
/// Every other settings save posts the frontend's copy back whole, and that
/// copy can predate an install made a moment ago, so what is stored wins.
fn restore_marketplace_fields(incoming: &mut AppSettings, stored: &AppSettings) {
incoming.marketplace_accounts = stored.marketplace_accounts.clone();
incoming.marketplaces = stored.marketplaces.clone();
incoming.global_marketplace_installs = stored.global_marketplace_installs.clone();
}
and in update_settings change the parameter to mut settings: AppSettings and add, directly after validate_settings_update(&before, &settings)?;:
restore_marketplace_fields(&mut settings, &before);
In app/src-tauri/src/commands/project_commands.rs, add to the end of restore_store_owned_fields:
// Owned by the marketplace commands; a Config-tab save carries a stale copy.
project.marketplace_installs = stored.marketplace_installs.clone();
project.marketplace_disabled = stored.marketplace_disabled.clone();
In app/src-tauri/src/commands/settings_export_commands.rs, apply_settings_import, replace
let saved =
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
with
let imported_marketplace = (
settings.marketplace_accounts.clone(),
settings.marketplaces.clone(),
settings.global_marketplace_installs.clone(),
);
let saved =
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
// `update_settings` keeps marketplace state store-owned. An import is the
// one caller entitled to replace it wholesale.
let saved = {
let mut s = saved;
(s.marketplace_accounts, s.marketplaces, s.global_marketplace_installs) = imported_marketplace;
state.settings_store.update(s)?
};
Run: cd app/src-tauri && cargo test --lib settings_commands && cargo test --lib project_commands && cargo test --lib marketplace_commands
Expected: all pass.
- Step 4: Implement the commands
Append to app/src-tauri/src/commands/marketplace_commands.rs (below the ops module):
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
fn find_marketplace(settings: &AppSettings, id: &str) -> Result<Marketplace, String> {
settings
.marketplaces
.iter()
.find(|m| m.id == id)
.cloned()
.ok_or_else(|| "That marketplace is no longer configured.".to_string())
}
fn find_account(settings: &AppSettings, id: &str) -> Result<MarketplaceAccount, String> {
settings
.marketplace_accounts
.iter()
.find(|a| a.id == id)
.cloned()
.ok_or_else(|| "That account no longer exists.".to_string())
}
fn find_project(state: &AppState, id: &str) -> Result<Project, String> {
state.projects_store.get(id).ok_or_else(|| format!("Project {id} not found"))
}
fn validate_marketplace(settings: &AppSettings, m: &mut Marketplace) -> Result<(), String> {
m.name = ops::validate_label(&m.name)?;
m.url = m.url.trim().to_string();
let host = auth::host_of(&m.url)?;
m.branch = ops::validate_branch(m.branch.take())?;
if let Some(account_id) = &m.account_id {
let a = find_account(settings, account_id)?;
if !a.host.eq_ignore_ascii_case(&host) {
return Err(format!(
"The account \"{}\" is for {}, but this marketplace is on {}.",
a.label, a.host, host
));
}
}
Ok(())
}
fn validate_item(item: &MarketplaceItemRef) -> Result<(), String> {
if is_valid_item_key(&item.key) {
Ok(())
} else {
Err(format!("\"{}\" is not a valid item name.", item.key))
}
}
/// The in-memory snapshot, else the cached one (which is then remembered).
fn snapshot_or_cached(mgr: &MarketplaceManager, m: &Marketplace) -> MarketplaceSnapshot {
if let Some(s) = mgr.snapshot(&m.id) {
return s;
}
let s = mk::load_cached_snapshot(mgr, m);
mgr.put_snapshot(s.clone());
s
}
async fn snapshot_blocking(state: &AppState, m: &Marketplace) -> Result<MarketplaceSnapshot, String> {
let mgr = state.marketplace.clone();
let m = m.clone();
tokio::task::spawn_blocking(move || snapshot_or_cached(&mgr, &m))
.await
.map_err(|e| format!("Reading the marketplace cache failed: {e}"))
}
/// Make each cache's pin refs exactly the commits installs reference, so a
/// pinned version can never be garbage-collected away.
async fn refresh_pins(state: &AppState) {
let settings = state.settings_store.get();
let pins = mk::pins_by_marketplace(&settings, &state.projects_store.list());
let root = state.marketplace.data_root().to_path_buf();
let ids: Vec<String> = settings.marketplaces.iter().map(|m| m.id.clone()).collect();
let _ = tokio::task::spawn_blocking(move || {
for id in ids {
let repo = git::cache_path(&root, &id);
if !repo.exists() {
continue;
}
let commits = pins.get(&id).cloned().unwrap_or_default();
if let Err(e) = git::set_pins(&repo, &commits) {
log::warn!("Could not update the pinned commits of marketplace {}: {}", id, e);
}
}
})
.await;
}
fn remove_cache(state: &AppState, marketplace_id: &str) {
state.marketplace.remove_snapshot(marketplace_id);
let path = git::cache_path(state.marketplace.data_root(), marketplace_id);
if path.exists() {
if let Err(e) = std::fs::remove_dir_all(&path) {
log::warn!("Could not delete the marketplace cache {}: {}", path.display(), e);
}
}
}
fn save_new_account(state: &AppState, account: MarketplaceAccount, stored_token: bool) -> Result<MarketplaceAccount, String> {
let mut settings = state.settings_store.get();
settings.marketplace_accounts.push(account.clone());
if let Err(e) = state.settings_store.update(settings) {
if stored_token {
let _ = secure::delete_marketplace_token(&account.id);
}
return Err(e);
}
Ok(account)
}
// ─────────────────────────────────────────────────────────────────────────────
// Marketplaces
// ─────────────────────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn list_marketplace_snapshots(state: State<'_, AppState>) -> Result<Vec<MarketplaceSnapshot>, String> {
let settings = state.settings_store.get();
let mgr = state.marketplace.clone();
tokio::task::spawn_blocking(move || settings.marketplaces.iter().map(|m| snapshot_or_cached(&mgr, m)).collect())
.await
.map_err(|e| format!("Reading the marketplace caches failed: {e}"))
}
#[tauri::command]
pub async fn refresh_marketplaces(
marketplace_id: Option<String>,
state: State<'_, AppState>,
) -> Result<Vec<MarketplaceSnapshot>, String> {
let settings = state.settings_store.get();
if let Some(id) = &marketplace_id {
find_marketplace(&settings, id)?;
}
for m in settings
.marketplaces
.iter()
.filter(|m| marketplace_id.as_deref().map_or(true, |id| id == m.id))
{
mk::refresh_marketplace(&state.marketplace, &settings, &m.id).await;
}
refresh_pins(&state).await;
list_marketplace_snapshots(state).await
}
/// Test-fetches before saving: a wrong URL or credential fails here, and
/// nothing is stored.
#[tauri::command]
pub async fn add_marketplace(
name: String,
url: String,
branch: Option<String>,
account_id: Option<String>,
state: State<'_, AppState>,
) -> Result<MarketplaceSnapshot, String> {
let settings = state.settings_store.get();
let mut m = Marketplace { id: uuid::Uuid::new_v4().to_string(), name, url, branch, account_id };
validate_marketplace(&settings, &mut m)?;
if settings
.marketplaces
.iter()
.any(|x| x.url.eq_ignore_ascii_case(&m.url) && x.branch == m.branch)
{
return Err("This repository (and branch) has already been added.".to_string());
}
let mut trial = settings.clone();
trial.marketplaces.push(m.clone());
let snap = mk::refresh_marketplace(&state.marketplace, &trial, &m.id).await;
let failure = snap
.fetch_error
.clone()
.or_else(|| snap.head_commit.is_none().then(|| "The repository has no commits yet.".to_string()));
if let Some(e) = failure {
remove_cache(&state, &m.id);
return Err(e);
}
let mut current = state.settings_store.get();
current.marketplaces.push(m);
state.settings_store.update(current)?;
Ok(snap)
}
#[tauri::command]
pub async fn update_marketplace(marketplace: Marketplace, state: State<'_, AppState>) -> Result<AppSettings, String> {
let mut settings = state.settings_store.get();
let mut m = marketplace;
validate_marketplace(&settings, &mut m)?;
let slot = settings
.marketplaces
.iter_mut()
.find(|x| x.id == m.id)
.ok_or_else(|| "That marketplace is no longer configured.".to_string())?;
*slot = m;
state.settings_store.update(settings)
}
/// Installs from it stay listed as "source removed" until forgotten.
#[tauri::command]
pub async fn remove_marketplace(marketplace_id: String, state: State<'_, AppState>) -> Result<AppSettings, String> {
let mut settings = state.settings_store.get();
find_marketplace(&settings, &marketplace_id)?;
settings.marketplaces.retain(|m| m.id != marketplace_id);
let saved = state.settings_store.update(settings)?;
remove_cache(&state, &marketplace_id);
Ok(saved)
}
#[tauri::command]
pub async fn forget_marketplace_installs(marketplace_id: String, state: State<'_, AppState>) -> Result<(), String> {
let mut settings = state.settings_store.get();
settings.global_marketplace_installs.retain(|i| i.marketplace_id != marketplace_id);
state.settings_store.update(settings)?;
for mut p in state.projects_store.list() {
let before = (p.marketplace_installs.len(), p.marketplace_disabled.len());
p.marketplace_installs.retain(|i| i.marketplace_id != marketplace_id);
p.marketplace_disabled.retain(|r| r.marketplace_id != marketplace_id);
if (p.marketplace_installs.len(), p.marketplace_disabled.len()) != before {
state.projects_store.update(p)?;
}
}
refresh_pins(&state).await;
Ok(())
}
// ─────────────────────────────────────────────────────────────────────────────
// Installs
// ─────────────────────────────────────────────────────────────────────────────
/// Pins the item at the marketplace's current head. Returns fresh settings;
/// for a project scope the caller reloads projects.
#[tauri::command]
pub async fn install_marketplace_item(
item: MarketplaceItemRef,
scope: InstallScope,
state: State<'_, AppState>,
) -> Result<AppSettings, String> {
validate_item(&item)?;
let settings = state.settings_store.get();
let m = find_marketplace(&settings, &item.marketplace_id)?;
let snap = snapshot_blocking(&state, &m).await?;
let head = snap
.head_commit
.clone()
.ok_or_else(|| format!("\"{}\" has not been fetched yet — refresh it first.", m.name))?;
let entry = snap
.items
.iter()
.find(|i| i.kind == item.kind && i.key == item.key)
.ok_or_else(|| format!("\"{}\" is no longer in \"{}\" — refresh the marketplace.", item.key, m.name))?;
if let Some(reason) = &entry.invalid {
return Err(format!("\"{}\" cannot be installed: {}", entry.name, reason));
}
let inst = MarketplaceInstall {
marketplace_id: item.marketplace_id.clone(),
kind: item.kind,
key: item.key.clone(),
commit: head,
};
match scope {
InstallScope::Global => {
let mut s = state.settings_store.get();
ops::upsert_install(&mut s.global_marketplace_installs, inst);
state.settings_store.update(s)?;
}
InstallScope::Project { project_id } => {
let mut p = find_project(&state, &project_id)?;
ops::upsert_install(&mut p.marketplace_installs, inst);
state.projects_store.update(p)?;
}
}
refresh_pins(&state).await;
Ok(state.settings_store.get())
}
#[tauri::command]
pub async fn uninstall_marketplace_item(
item: MarketplaceItemRef,
scope: InstallScope,
state: State<'_, AppState>,
) -> Result<(), String> {
match scope {
InstallScope::Global => {
let mut s = state.settings_store.get();
if !ops::remove_install(&mut s.global_marketplace_installs, &item) {
return Err("That item is not installed for all projects.".to_string());
}
state.settings_store.update(s)?;
// An opt-out of an item that is no longer global means nothing.
for mut p in state.projects_store.list() {
if p.marketplace_disabled.contains(&item) {
ops::set_disabled(&mut p.marketplace_disabled, &item, false);
state.projects_store.update(p)?;
}
}
}
InstallScope::Project { project_id } => {
let mut p = find_project(&state, &project_id)?;
if !ops::remove_install(&mut p.marketplace_installs, &item) {
return Err(format!("That item is not installed in \"{}\".", p.name));
}
state.projects_store.update(p)?;
}
}
refresh_pins(&state).await;
Ok(())
}
#[tauri::command]
pub async fn set_global_item_disabled(
project_id: String,
item: MarketplaceItemRef,
disabled: bool,
state: State<'_, AppState>,
) -> Result<Project, String> {
validate_item(&item)?;
let mut p = find_project(&state, &project_id)?;
ops::set_disabled(&mut p.marketplace_disabled, &item, disabled);
state.projects_store.update(p)
}
// ─────────────────────────────────────────────────────────────────────────────
// Updates
// ─────────────────────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn list_marketplace_updates(state: State<'_, AppState>) -> Result<Vec<ItemUpdate>, String> {
let settings = state.settings_store.get();
let projects = state.projects_store.list();
let mgr = state.marketplace.clone();
tokio::task::spawn_blocking(move || mk::compute_updates(&mgr, &settings, &projects))
.await
.map_err(|e| format!("Checking for updates failed: {e}"))
}
#[tauri::command]
pub async fn marketplace_item_diff(
item: MarketplaceItemRef,
from_commit: String,
to_commit: String,
state: State<'_, AppState>,
) -> Result<Vec<FileDiff>, String> {
validate_item(&item)?;
if !is_valid_commit(&from_commit) || !is_valid_commit(&to_commit) {
return Err("Invalid commit id.".to_string());
}
let settings = state.settings_store.get();
let m = find_marketplace(&settings, &item.marketplace_id)?;
let repo = git::cache_path(state.marketplace.data_root(), &m.id);
tokio::task::spawn_blocking(move || diff::item_diff(&repo, item.kind, &item.key, &from_commit, &to_commit))
.await
.map_err(|e| format!("Computing the diff failed: {e}"))?
}
/// Moves one install's pin to the marketplace's head, if the item is still
/// installable there.
#[tauri::command]
pub async fn update_marketplace_item(
item: MarketplaceItemRef,
scope: InstallScope,
state: State<'_, AppState>,
) -> Result<(), String> {
validate_item(&item)?;
let settings = state.settings_store.get();
let m = find_marketplace(&settings, &item.marketplace_id)?;
let head = snapshot_blocking(&state, &m)
.await?
.head_commit
.ok_or_else(|| format!("\"{}\" has not been fetched yet — refresh it first.", m.name))?;
let repo = git::cache_path(state.marketplace.data_root(), &m.id);
let (kind, key, at) = (item.kind, item.key.clone(), head.clone());
tokio::task::spawn_blocking(move || -> Result<(), String> {
let tree = GitTree::open(&repo, &at)?;
catalog::item_files(&tree, kind, &key).map(|_| ())
})
.await
.map_err(|e| format!("Checking the new version failed: {e}"))?
.map_err(|e| format!("\"{}\" cannot be updated: {e}", item.key))?;
match scope {
InstallScope::Global => {
let mut s = state.settings_store.get();
if !ops::repin(&mut s.global_marketplace_installs, &item, &head) {
return Err("That item is not installed for all projects.".to_string());
}
state.settings_store.update(s)?;
}
InstallScope::Project { project_id } => {
let mut p = find_project(&state, &project_id)?;
if !ops::repin(&mut p.marketplace_installs, &item, &head) {
return Err(format!("That item is not installed in \"{}\".", p.name));
}
state.projects_store.update(p)?;
}
}
refresh_pins(&state).await;
Ok(())
}
// ─────────────────────────────────────────────────────────────────────────────
// Sync
// ─────────────────────────────────────────────────────────────────────────────
/// Sync one running project, or every running project when `project_id` is None.
#[tauri::command]
pub async fn apply_marketplace_now(
project_id: Option<String>,
state: State<'_, AppState>,
) -> Result<Vec<ProjectSyncResult>, String> {
let settings = state.settings_store.get();
let projects = match &project_id {
Some(id) => vec![find_project(&state, id)?],
None => state.projects_store.list(),
};
let mut results = Vec::new();
for p in projects {
let running = match &p.container_id {
Some(cid) => is_container_running(cid).await.unwrap_or(false),
None => false,
};
if !running {
if project_id.is_some() {
return Err(format!("\"{}\" is not running. Its marketplace items are applied when it starts.", p.name));
}
continue;
}
let cid = p.container_id.clone().unwrap_or_default();
let report = mk::sync_project(&state.marketplace, &settings, &p, &cid).await;
results.push(ProjectSyncResult { project_id: p.id.clone(), report });
}
Ok(results)
}
#[tauri::command]
pub async fn get_marketplace_sync_report(project_id: String, state: State<'_, AppState>) -> Result<Option<SyncReport>, String> {
Ok(state.marketplace.report(&project_id))
}
// ─────────────────────────────────────────────────────────────────────────────
// Accounts
// ─────────────────────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn add_marketplace_token_account(
label: String,
host: String,
token: String,
state: State<'_, AppState>,
) -> Result<MarketplaceAccount, String> {
let label = ops::validate_label(&label)?;
let host = ops::validate_host(&host)?;
let token = token.trim().to_string();
if token.is_empty() || token.chars().any(|c| c.is_whitespace() || c.is_control()) {
return Err("Paste the whole token — it cannot be empty or contain spaces.".to_string());
}
// None = a host with no known "who am I" API; the marketplace's test fetch proves the token.
let username = auth::validate_token(&host, &token).await?;
let account = MarketplaceAccount {
id: uuid::Uuid::new_v4().to_string(),
label,
host,
method: AccountMethod::Token,
username,
};
secure::store_marketplace_token(&account.id, &token)?;
save_new_account(&state, account, true)
}
#[tauri::command]
pub async fn add_marketplace_gh_host_account(
label: String,
host: String,
state: State<'_, AppState>,
) -> Result<MarketplaceAccount, String> {
let label = ops::validate_label(&label)?;
let host = ops::validate_host(&host)?;
if !auth::gh_host_available().await {
return Err(
"The GitHub CLI (gh) is not installed on this computer. Sign in through a running \
container instead, or add a token."
.to_string(),
);
}
let username = auth::gh_host_login(&host).await?;
let account = MarketplaceAccount {
id: uuid::Uuid::new_v4().to_string(),
label,
host,
method: AccountMethod::GhHost,
username: Some(username),
};
save_new_account(&state, account, false)
}
/// Long-running: drives `gh auth login --web` in the project's container and
/// emits `marketplace-gh-login-code` / `-output` while it waits.
#[tauri::command]
pub async fn start_marketplace_gh_container_login(
label: String,
host: String,
project_id: String,
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<MarketplaceAccount, String> {
let label = ops::validate_label(&label)?;
let host = ops::validate_host(&host)?;
let project = find_project(&state, &project_id)?;
let container_id = project
.container_id
.clone()
.ok_or_else(|| format!("\"{}\" has no container yet. Start it, then try again.", project.name))?;
if !is_container_running(&container_id).await.unwrap_or(false) {
return Err(format!("\"{}\" is not running. Start it, then try again.", project.name));
}
let (tx, rx) = oneshot::channel();
if !state.marketplace.set_gh_login_cancel(Some(tx)).await {
return Err("A GitHub sign-in is already running. Finish or cancel it first.".to_string());
}
let account_id = uuid::Uuid::new_v4().to_string();
let result = gh_login::run_gh_container_login(&app_handle, &account_id, &container_id, &host, rx).await;
state.marketplace.set_gh_login_cancel(None).await;
let token = result?;
let username = auth::validate_token(&host, &token).await?;
secure::store_marketplace_token(&account_id, &token)?;
let account = MarketplaceAccount {
id: account_id,
label,
host,
method: AccountMethod::GhContainer,
username,
};
save_new_account(&state, account, true)
}
#[tauri::command]
pub async fn cancel_marketplace_gh_login(state: State<'_, AppState>) -> Result<(), String> {
state.marketplace.cancel_gh_login().await;
Ok(())
}
#[tauri::command]
pub async fn test_marketplace_account(account_id: String, state: State<'_, AppState>) -> Result<String, String> {
let settings = state.settings_store.get();
let account = find_account(&settings, &account_id)?;
let cred = auth::resolve_credential(&account).await?;
Ok(auth::validate_token(&account.host, &cred.password)
.await?
.unwrap_or_else(|| "token present (this host has no sign-in check)".to_string()))
}
#[tauri::command]
pub async fn remove_marketplace_account(account_id: String, state: State<'_, AppState>) -> Result<AppSettings, String> {
let mut settings = state.settings_store.get();
let account = find_account(&settings, &account_id)?;
if let Some(m) = settings.marketplaces.iter().find(|m| m.account_id.as_deref() == Some(account_id.as_str())) {
return Err(format!("\"{}\" uses this account. Change or remove that marketplace first.", m.name));
}
// Keychain first: if it refuses, nothing has changed yet.
if account.method != AccountMethod::GhHost {
secure::delete_marketplace_token(&account.id)?;
}
settings.marketplace_accounts.retain(|a| a.id != account_id);
state.settings_store.update(settings)
}
#[tauri::command]
pub async fn marketplace_gh_host_available() -> Result<bool, String> {
Ok(auth::gh_host_available().await)
}
Run: cd app/src-tauri && cargo check --lib
Expected: compiles, with "function is never used" warnings for the commands (they are registered next).
- Step 5: Register the commands and grant them
In app/src-tauri/src/lib.rs, inside tauri::generate_handler![ … ], after commands::auth_token_commands::sweep_claude_token_snapshots, add:
// Marketplace
commands::marketplace_commands::list_marketplace_snapshots,
commands::marketplace_commands::refresh_marketplaces,
commands::marketplace_commands::add_marketplace,
commands::marketplace_commands::update_marketplace,
commands::marketplace_commands::remove_marketplace,
commands::marketplace_commands::install_marketplace_item,
commands::marketplace_commands::uninstall_marketplace_item,
commands::marketplace_commands::set_global_item_disabled,
commands::marketplace_commands::forget_marketplace_installs,
commands::marketplace_commands::list_marketplace_updates,
commands::marketplace_commands::marketplace_item_diff,
commands::marketplace_commands::update_marketplace_item,
commands::marketplace_commands::apply_marketplace_now,
commands::marketplace_commands::get_marketplace_sync_report,
commands::marketplace_commands::add_marketplace_token_account,
commands::marketplace_commands::add_marketplace_gh_host_account,
commands::marketplace_commands::start_marketplace_gh_container_login,
commands::marketplace_commands::cancel_marketplace_gh_login,
commands::marketplace_commands::test_marketplace_account,
commands::marketplace_commands::remove_marketplace_account,
commands::marketplace_commands::marketplace_gh_host_available,
In app/src-tauri/capabilities/default.json, change the last entry "allow-clear-scheduler-notifications" to "allow-clear-scheduler-notifications", and append:
"allow-list-marketplace-snapshots",
"allow-refresh-marketplaces",
"allow-add-marketplace",
"allow-update-marketplace",
"allow-remove-marketplace",
"allow-install-marketplace-item",
"allow-uninstall-marketplace-item",
"allow-set-global-item-disabled",
"allow-forget-marketplace-installs",
"allow-list-marketplace-updates",
"allow-marketplace-item-diff",
"allow-update-marketplace-item",
"allow-apply-marketplace-now",
"allow-get-marketplace-sync-report",
"allow-add-marketplace-token-account",
"allow-add-marketplace-gh-host-account",
"allow-start-marketplace-gh-container-login",
"allow-cancel-marketplace-gh-login",
"allow-test-marketplace-account",
"allow-remove-marketplace-account",
"allow-marketplace-gh-host-available"
(The description string in that file is the reviewed threat-model census; add one sentence to it: "The *marketplace* commands fetch user-configured https git repos on the host and push pinned files into containers; account tokens stay in the OS keychain and never cross IPC outward — the only inbound one is the token pasted into add_marketplace_token_account.")
- Step 6: Refresh marketplaces at startup
In app/src-tauri/src/lib.rs, delete the let _ = &marketplace_setup; line added in Task 6, and inside .setup(move |app| { … }), next to the other background spawns (after the housekeeping reap_probe_containers spawn), add:
// Marketplaces: refresh each once at startup, in the background.
// Failures are logged, not toasted — the Marketplace tab shows them.
{
let settings = settings_store_setup.get();
let marketplace = marketplace_setup.clone();
tauri::async_runtime::spawn(async move {
for m in &settings.marketplaces {
let snap = crate::marketplace::refresh_marketplace(&marketplace, &settings, &m.id).await;
if let Some(e) = snap.fetch_error {
log::warn!("Marketplace \"{}\" could not be refreshed at startup: {}", m.name, e);
}
}
});
}
- Step 7: Build and run the whole Rust suite
Run: cd app/src-tauri && cargo build && cargo test --lib
Expected: builds (build.rs accepts the 21 new grants — a missing or misspelled one fails here with the command name); all tests pass. gen/schemas/*.json is regenerated by the build.
- Step 8: Commit
cd /workspace/triple-c
rustfmt --edition 2021 app/src-tauri/src/commands/marketplace_commands.rs
git add app/src-tauri/src/commands/marketplace_commands.rs app/src-tauri/src/commands/mod.rs \
app/src-tauri/src/commands/settings_commands.rs app/src-tauri/src/commands/project_commands.rs \
app/src-tauri/src/commands/settings_export_commands.rs app/src-tauri/src/lib.rs \
app/src-tauri/capabilities/default.json app/src-tauri/gen/schemas
git commit -m "Marketplace: Tauri commands, store-owned fields and startup refresh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 12: Frontend plumbing — wrappers, store, tab, settings section, view shell
Files:
- Modify:
app/src/lib/tauri-commands.ts(append a// ---- Marketplace ----section) - Create:
app/src/lib/marketplace.ts,app/src/lib/marketplace.test.ts - Modify:
app/src/store/appState.ts(helpers near the tab-key helpers, lines ~82-95; interface ~131-170; implementation nearcloseHomeTab~377) - Modify:
app/src/store/appState.test.ts(append a describe block) - Modify:
app/src/App.tsx(import line 24; pane list ~lines 121-164; calluseMarketplaceSyncToasts()) - Modify:
app/src/components/layout/MainTabs.tsx(imports lines 5-10; store selector ~line 44;tabLabel~194; ghost icon ~275;renderTab~314) - Modify:
app/src/components/layout/MainTabs.test.tsx(append a test) - Modify:
app/src/hooks/useKeyboardShortcuts.ts(import line 2; Ctrl+Shift+W branch ~line 65) - Modify:
app/src/hooks/useKeyboardShortcuts.test.tsx(append a test) - Modify:
app/src/components/layout/NotesDock.tsx(comment only, see Step 11) - Create:
app/src/hooks/useMarketplace.ts,app/src/hooks/useMarketplace.test.ts - Create:
app/src/components/settings/MarketplaceSettings.tsx,app/src/components/settings/MarketplaceSettings.test.tsx - Modify:
app/src/components/settings/SettingsPanel.tsx(after theclaude-authsection, lines 170-172) - Create:
app/src/components/marketplace/MarketplaceView.tsx,app/src/components/marketplace/MarketplaceView.test.tsx
Interfaces:
-
Consumes: TS types from Task 2 (
lib/types.ts:ItemKind,Marketplace,MarketplaceAccount,MarketplaceItemRef,MarketplaceInstall,MarketplaceSnapshot,ItemUpdate,FileDiff,SyncReport,InstallScope,ProjectSyncResult, and the newAppSettings/Projectfields); backend commands anddefault.jsongrants from Task 11. -
Produces:
- wrappers exactly as in the contract;
lib/marketplace.ts:ProjectItemState,itemRefKey,formatItemRef,projectItemState,effectiveInstalls,KIND_LABELS,KIND_ORDER,isStale,STALE_AFTER_MS;- store:
MARKETPLACE_TAB_KEY,isMarketplaceTab,marketplaceFilterProjectId,openMarketplace,closeMarketplaceTab,setMarketplaceFilterProjectId; hooks/useMarketplace.ts:useMarketplace(): MarketplaceApianduseMarketplaceSyncToasts(): void(see code);MarketplaceViewwith sub-tab ids"browse" | "installed" | "accounts"and panes that Tasks 13/14/15 replace.
-
Step 1: Write failing tests for
lib/marketplace.ts
app/src/lib/marketplace.test.ts:
import { describe, it, expect } from "vitest";
import {
effectiveInstalls,
formatItemRef,
isStale,
itemRefKey,
projectItemState,
STALE_AFTER_MS,
} from "./marketplace";
import type { MarketplaceInstall, MarketplaceSnapshot, Project } from "./types";
const A = "a".repeat(40);
const B = "b".repeat(40);
const inst = (key: string, commit = A, kind: MarketplaceInstall["kind"] = "agent"): MarketplaceInstall => ({
marketplace_id: "m1",
kind,
key,
commit,
});
const project = (patch: Partial<Project> = {}): Project =>
({
id: "p1",
name: "api",
marketplace_installs: [],
marketplace_disabled: [],
...patch,
}) as unknown as Project;
describe("itemRefKey / formatItemRef", () => {
it("keys and formats a ref", () => {
const r = { marketplace_id: "m1", kind: "hook" as const, key: "notify" };
expect(itemRefKey(r)).toBe("m1/hook/notify");
expect(formatItemRef(r)).toBe("hook:notify");
});
});
describe("projectItemState", () => {
const ref = { marketplace_id: "m1", kind: "agent" as const, key: "rev" };
it("is none when nothing installs it", () => {
expect(projectItemState(ref, [], project())).toBe("none");
});
it("is inherited from a global install", () => {
expect(projectItemState(ref, [inst("rev")], project())).toBe("inherited");
});
it("is opted_out when the project disabled the global install", () => {
const p = project({ marketplace_disabled: [ref] });
expect(projectItemState(ref, [inst("rev")], p)).toBe("opted_out");
});
it("is project for a project-only install", () => {
const p = project({ marketplace_installs: [inst("rev")] });
expect(projectItemState(ref, [], p)).toBe("project");
});
it("is project when project and global share the pin", () => {
const p = project({ marketplace_installs: [inst("rev", A)] });
expect(projectItemState(ref, [inst("rev", A)], p)).toBe("project");
});
it("flags a project pin that differs from the global pin", () => {
const p = project({ marketplace_installs: [inst("rev", B)] });
expect(projectItemState(ref, [inst("rev", A)], p)).toBe("project_pinned_differently");
});
it("does not confuse kinds with the same key", () => {
expect(projectItemState(ref, [inst("rev", A, "skill")], project())).toBe("none");
});
});
describe("effectiveInstalls", () => {
it("merges global minus disabled plus project, project winning", () => {
const disabledRef = { marketplace_id: "m1", kind: "agent" as const, key: "off" };
const p = project({
marketplace_disabled: [disabledRef],
marketplace_installs: [inst("both", B), inst("mine")],
});
const out = effectiveInstalls([inst("glob"), inst("off"), inst("both", A)], p);
expect(out.map((i) => [i.key, i.commit, i.source])).toEqual([
["both", B, "project"],
["glob", A, "global"],
["mine", A, "project"],
]);
});
});
describe("isStale", () => {
const snap = (fetched_at: string | null): MarketplaceSnapshot => ({
marketplace_id: "m1",
head_commit: null,
fetched_at,
fetch_error: null,
items: [],
});
const now = Date.parse("2026-09-27T12:00:00Z");
it("treats a never-fetched snapshot as stale", () => {
expect(isStale(snap(null), now)).toBe(true);
});
it("is fresh within 15 minutes and stale after", () => {
expect(isStale(snap(new Date(now - STALE_AFTER_MS + 1000).toISOString()), now)).toBe(false);
expect(isStale(snap(new Date(now - STALE_AFTER_MS - 1000).toISOString()), now)).toBe(true);
});
it("treats an unparsable timestamp as stale", () => {
expect(isStale(snap("not a date"), now)).toBe(true);
});
});
- Step 2: Run to verify it fails
Run: cd app && npx vitest run src/lib/marketplace.test.ts
Expected: FAIL — Failed to resolve import "./marketplace".
- Step 3: Implement
lib/marketplace.ts
import type {
ItemKind,
MarketplaceInstall,
MarketplaceItemRef,
MarketplaceSnapshot,
Project,
} from "./types";
/** How a project relates to one marketplace item. */
export type ProjectItemState =
| "none"
| "inherited"
| "opted_out"
| "project"
| "project_pinned_differently";
export const KIND_ORDER: ItemKind[] = ["agent", "skill", "command", "hook", "plugin"];
export const KIND_LABELS: Record<ItemKind, string> = {
agent: "Agents",
skill: "Skills",
command: "Commands",
hook: "Hooks",
plugin: "Plugins",
};
/** A marketplace is refreshed when its tab opens if the last fetch is older than this. */
export const STALE_AFTER_MS = 15 * 60 * 1000;
export const itemRefKey = (r: MarketplaceItemRef) => `${r.marketplace_id}/${r.kind}/${r.key}`;
/** Same shape as the item strings in a `SyncReport`. */
export const formatItemRef = (r: MarketplaceItemRef) => `${r.kind}:${r.key}`;
const sameItem = (a: MarketplaceItemRef, b: MarketplaceItemRef) =>
a.marketplace_id === b.marketplace_id && a.kind === b.kind && a.key === b.key;
export function projectItemState(
item: MarketplaceItemRef,
globalInstalls: MarketplaceInstall[],
project: Project,
): ProjectItemState {
const own = project.marketplace_installs.find((i) => sameItem(i, item));
const global = globalInstalls.find((i) => sameItem(i, item));
if (own) {
return global && global.commit !== own.commit ? "project_pinned_differently" : "project";
}
if (!global) return "none";
return project.marketplace_disabled.some((d) => sameItem(d, item)) ? "opted_out" : "inherited";
}
/** Mirror of the backend's `effective_installs`, tagged with where each install comes from. */
export function effectiveInstalls(
globalInstalls: MarketplaceInstall[],
project: Project,
): (MarketplaceInstall & { source: "global" | "project" })[] {
const byKey = new Map<string, MarketplaceInstall & { source: "global" | "project" }>();
for (const g of globalInstalls) {
if (project.marketplace_disabled.some((d) => sameItem(d, g))) continue;
byKey.set(itemRefKey(g), { ...g, source: "global" });
}
for (const p of project.marketplace_installs) {
byKey.set(itemRefKey(p), { ...p, source: "project" });
}
return [...byKey.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([, v]) => v);
}
export function isStale(snapshot: MarketplaceSnapshot, now: number): boolean {
if (!snapshot.fetched_at) return true;
const at = Date.parse(snapshot.fetched_at);
if (Number.isNaN(at)) return true;
return now - at > STALE_AFTER_MS;
}
Note on sort order: the backend sorts by (marketplace_id, kind, key) with Rust enum order; the frontend sorts by the itemRefKey string. Both are only used for display, so they do not need to match.
- Step 4: Run to verify it passes
Run: cd app && npx vitest run src/lib/marketplace.test.ts
Expected: PASS (11 tests).
- Step 5: Add the command wrappers
Append to app/src/lib/tauri-commands.ts, and add the new names to the file's single import type {…} from "./types" line (FileDiff, InstallScope, ItemUpdate, Marketplace, MarketplaceAccount, MarketplaceItemRef, MarketplaceSnapshot, ProjectSyncResult, SyncReport):
// ---- Marketplace ----
export const listMarketplaceSnapshots = () =>
invoke<MarketplaceSnapshot[]>("list_marketplace_snapshots");
export const refreshMarketplaces = (marketplaceId?: string) =>
invoke<MarketplaceSnapshot[]>("refresh_marketplaces", { marketplaceId: marketplaceId ?? null });
export const addMarketplace = (
name: string,
url: string,
branch: string | null,
accountId: string | null,
) => invoke<MarketplaceSnapshot>("add_marketplace", { name, url, branch, accountId });
export const updateMarketplace = (marketplace: Marketplace) =>
invoke<AppSettings>("update_marketplace", { marketplace });
export const removeMarketplace = (marketplaceId: string) =>
invoke<AppSettings>("remove_marketplace", { marketplaceId });
export const installMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
invoke<AppSettings>("install_marketplace_item", { item, scope });
export const uninstallMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
invoke<void>("uninstall_marketplace_item", { item, scope });
export const setGlobalItemDisabled = (
projectId: string,
item: MarketplaceItemRef,
disabled: boolean,
) => invoke<Project>("set_global_item_disabled", { projectId, item, disabled });
export const forgetMarketplaceInstalls = (marketplaceId: string) =>
invoke<void>("forget_marketplace_installs", { marketplaceId });
export const listMarketplaceUpdates = () => invoke<ItemUpdate[]>("list_marketplace_updates");
export const marketplaceItemDiff = (
item: MarketplaceItemRef,
fromCommit: string,
toCommit: string,
) => invoke<FileDiff[]>("marketplace_item_diff", { item, fromCommit, toCommit });
export const updateMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
invoke<void>("update_marketplace_item", { item, scope });
export const applyMarketplaceNow = (projectId?: string) =>
invoke<ProjectSyncResult[]>("apply_marketplace_now", { projectId: projectId ?? null });
export const getMarketplaceSyncReport = (projectId: string) =>
invoke<SyncReport | null>("get_marketplace_sync_report", { projectId });
export const addMarketplaceTokenAccount = (label: string, host: string, token: string) =>
invoke<MarketplaceAccount>("add_marketplace_token_account", { label, host, token });
export const addMarketplaceGhHostAccount = (label: string, host: string) =>
invoke<MarketplaceAccount>("add_marketplace_gh_host_account", { label, host });
export const startMarketplaceGhContainerLogin = (label: string, host: string, projectId: string) =>
invoke<MarketplaceAccount>("start_marketplace_gh_container_login", { label, host, projectId });
export const cancelMarketplaceGhLogin = () => invoke<void>("cancel_marketplace_gh_login");
export const testMarketplaceAccount = (accountId: string) =>
invoke<string>("test_marketplace_account", { accountId });
export const removeMarketplaceAccount = (accountId: string) =>
invoke<AppSettings>("remove_marketplace_account", { accountId });
export const marketplaceGhHostAvailable = () => invoke<boolean>("marketplace_gh_host_available");
AppSettings and Project are already imported in that file (used by getSettings and updateProject).
- Step 6: Write failing store tests
Append to app/src/store/appState.test.ts (and extend its import to import { useAppState, homeTabKey, terminalTabKey, MARKETPLACE_TAB_KEY } from "./appState";):
describe("marketplace tab", () => {
beforeEach(() => {
seed([A, B], A);
useAppState.setState({ marketplaceFilterProjectId: null });
});
it("opens once, activates, and records the project filter", () => {
useAppState.getState().openMarketplace("p9");
useAppState.getState().openMarketplace("p9");
const s = useAppState.getState();
expect(s.tabOrder).toEqual([A, B, MARKETPLACE_TAB_KEY]);
expect(s.activeTabKey).toBe(MARKETPLACE_TAB_KEY);
expect(s.activeSessionId).toBeNull();
expect(s.marketplaceFilterProjectId).toBe("p9");
});
it("clears the filter when opened without a project", () => {
useAppState.getState().openMarketplace("p9");
useAppState.getState().openMarketplace();
expect(useAppState.getState().marketplaceFilterProjectId).toBeNull();
});
it("does not select a project when activated", () => {
useAppState.getState().openMarketplace();
useAppState.getState().setActiveTabKey(MARKETPLACE_TAB_KEY);
expect(useAppState.getState().selectedProjectId).toBeNull();
});
it("closes and activates the neighbour", () => {
useAppState.getState().openMarketplace();
useAppState.getState().closeMarketplaceTab();
const s = useAppState.getState();
expect(s.tabOrder).toEqual([A, B]);
expect(s.activeTabKey).toBe(B);
});
it("closing when not open is a no-op", () => {
useAppState.getState().closeMarketplaceTab();
expect(useAppState.getState().tabOrder).toEqual([A, B]);
});
});
- Step 7: Run to verify it fails
Run: cd app && npx vitest run src/store/appState.test.ts
Expected: FAIL — MARKETPLACE_TAB_KEY is undefined / openMarketplace is not a function.
- Step 8: Implement the store additions
In app/src/store/appState.ts, directly after export const tabKeyId = … (line ~86), add:
/** The Marketplace view is a singleton main-area tab; its key has no id part. */
export const MARKETPLACE_TAB_KEY = "marketplace";
export const isMarketplaceTab = (key: string) => key === MARKETPLACE_TAB_KEY;
In the AppState interface, next to closeHomeTab: (projectId: string) => void;, add:
/** Project the Marketplace view is filtered to, or null for all projects. */
marketplaceFilterProjectId: string | null;
setMarketplaceFilterProjectId: (projectId: string | null) => void;
/** Open (or focus) the singleton Marketplace tab, optionally filtered to one project. */
openMarketplace: (filterProjectId?: string | null) => void;
closeMarketplaceTab: () => void;
In the create<AppState>(…) body, directly after the closeHomeTab implementation, add:
marketplaceFilterProjectId: null,
setMarketplaceFilterProjectId: (projectId) => set({ marketplaceFilterProjectId: projectId }),
openMarketplace: (filterProjectId = null) =>
set((state) => ({
marketplaceFilterProjectId: filterProjectId,
tabOrder: state.tabOrder.includes(MARKETPLACE_TAB_KEY)
? state.tabOrder
: [...state.tabOrder, MARKETPLACE_TAB_KEY],
...activation(MARKETPLACE_TAB_KEY),
})),
closeMarketplaceTab: () =>
set((state) => {
const index = state.tabOrder.indexOf(MARKETPLACE_TAB_KEY);
if (index === -1) return {};
const tabOrder = state.tabOrder.filter((k) => k !== MARKETPLACE_TAB_KEY);
const activeTabKey =
state.activeTabKey === MARKETPLACE_TAB_KEY
? (tabOrder[Math.min(index, tabOrder.length - 1)] ?? null)
: state.activeTabKey;
return { tabOrder, ...activation(activeTabKey) };
}),
setActiveTabKey, cycleTab and focusTabIndex need no change: they only set selectedProjectId for isHomeTab keys, and "marketplace" is not one.
- Step 9: Run to verify it passes
Run: cd app && npx vitest run src/store/appState.test.ts
Expected: PASS.
- Step 10: Failing tests for the tab strip and Ctrl+Shift+W
Append to app/src/components/layout/MainTabs.test.tsx (extend the store import with MARKETPLACE_TAB_KEY):
describe("marketplace tab", () => {
beforeEach(() => {
useAppState.setState({
tabOrder: [HOME, MARKETPLACE_TAB_KEY],
activeTabKey: MARKETPLACE_TAB_KEY,
activeSessionId: null,
});
});
it("renders a Marketplace tab that closes", () => {
render(<MainTabs />);
expect(screen.getByRole("tab", { name: /marketplace/i })).toHaveAttribute("aria-selected", "true");
fireEvent.click(screen.getByRole("button", { name: "Close Marketplace tab" }));
expect(useAppState.getState().tabOrder).toEqual([HOME]);
});
});
Append to app/src/hooks/useKeyboardShortcuts.test.tsx (extend the store import with MARKETPLACE_TAB_KEY):
describe("Ctrl+Shift+W on the Marketplace tab", () => {
it("closes the Marketplace tab", () => {
useAppState.setState({
tabOrder: [HOME, MARKETPLACE_TAB_KEY],
activeTabKey: MARKETPLACE_TAB_KEY,
activeSessionId: null,
});
renderHook(() => useKeyboardShortcuts());
press("W", { shift: true });
expect(useAppState.getState().tabOrder).toEqual([HOME]);
});
});
Run: cd app && npx vitest run src/components/layout/MainTabs.test.tsx src/hooks/useKeyboardShortcuts.test.tsx
Expected: FAIL — no Marketplace tab is rendered (the key falls through to the session branch and returns null), and Ctrl+Shift+W calls closeHomeTab("marketplace"), which does nothing.
- Step 11: Implement the tab strip, shortcut and dock changes
app/src/components/layout/MainTabs.tsx:
- Extend the store import:
import {
useAppState,
isHomeTab,
isMarketplaceTab,
tabKeyId,
terminalTabKey,
} from "../../store/appState";
- Add
closeMarketplaceTabto theuseAppState(useShallow(...))selector next tocloseHomeTab:
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab, closeMarketplaceTab, moveTab } = useAppState(
useShallow((s) => ({
tabOrder: s.tabOrder,
activeTabKey: s.activeTabKey,
setActiveTabKey: s.setActiveTabKey,
closeHomeTab: s.closeHomeTab,
closeMarketplaceTab: s.closeMarketplaceTab,
moveTab: s.moveTab,
})),
);
(Keep any other fields the existing selector already returns; only add closeMarketplaceTab.)
3. First line inside tabLabel:
if (isMarketplaceTab(key)) return "Marketplace";
- Ghost icon:
icon: isMarketplaceTab(drag.key) ? "◈" : isHomeTab(drag.key) ? "⌂" : "▣", - First branch inside
renderTab, beforeif (isHomeTab(key)):
if (isMarketplaceTab(key)) {
return (
<div
role="tab"
aria-selected={active}
tabIndex={0}
data-tab-index={index}
onClick={() => activateTab(key)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveTabKey(key);
}
}}
{...pointerProps(key, false)}
className={tabClass(active, dragKey === key)}
>
<span aria-hidden="true" className="text-[var(--text-secondary)]">◈</span>
<span className="truncate max-w-[160px]">Marketplace</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
closeMarketplaceTab();
}}
aria-label="Close Marketplace tab"
title="Close tab"
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
}
app/src/hooks/useKeyboardShortcuts.ts: change the import to import { useAppState, isMarketplaceTab, isTerminalTab, tabKeyId } from "../store/appState";, and replace the else branch of the Ctrl+Shift+W handler with:
} else if (isMarketplaceTab(key)) {
state.closeMarketplaceTab();
} else {
state.closeHomeTab(tabKeyId(key));
}
app/src/components/layout/NotesDock.tsx: behaviour is already right (for the Marketplace tab projectId stays null, so the dock shows its no-project state). Only replace the comment above let projectId with:
// Follow whatever is in front: a home tab is its own project, a terminal tab
// is the project it belongs to. The Marketplace tab belongs to no project.
Run: cd app && npx vitest run src/components/layout/MainTabs.test.tsx src/hooks/useKeyboardShortcuts.test.tsx
Expected: PASS.
- Step 12: Failing test for
useMarketplace
app/src/hooks/useMarketplace.test.ts:
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useAppState } from "../store/appState";
import type { AppSettings, MarketplaceSnapshot } from "../lib/types";
const listMarketplaceSnapshots = vi.fn();
const refreshMarketplaces = vi.fn();
const listMarketplaceUpdates = vi.fn();
const getSettings = vi.fn();
const listProjects = vi.fn();
const installMarketplaceItem = vi.fn();
vi.mock("../lib/tauri-commands", () => ({
listMarketplaceSnapshots: () => listMarketplaceSnapshots(),
refreshMarketplaces: (id?: string) => refreshMarketplaces(id),
listMarketplaceUpdates: () => listMarketplaceUpdates(),
getSettings: () => getSettings(),
listProjects: () => listProjects(),
installMarketplaceItem: (...a: unknown[]) => installMarketplaceItem(...a),
}));
let syncHandler: ((e: { payload: unknown }) => void) | null = null;
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (_name: string, cb: (e: { payload: unknown }) => void) => {
syncHandler = cb;
return vi.fn();
}),
}));
import { useMarketplace, useMarketplaceSyncToasts } from "./useMarketplace";
const snap = (id: string, fetched_at: string | null): MarketplaceSnapshot => ({
marketplace_id: id,
head_commit: null,
fetched_at,
fetch_error: null,
items: [],
});
describe("useMarketplace", () => {
beforeEach(() => {
vi.clearAllMocks();
useAppState.setState({ toasts: [], appSettings: { marketplaces: [] } as unknown as AppSettings });
listMarketplaceUpdates.mockResolvedValue([]);
getSettings.mockResolvedValue({ marketplaces: [] });
listProjects.mockResolvedValue([]);
});
it("loads snapshots and refreshes only stale ones", async () => {
const fresh = snap("m1", new Date().toISOString());
const stale = snap("m2", null);
listMarketplaceSnapshots.mockResolvedValue([fresh, stale]);
refreshMarketplaces.mockResolvedValue([{ ...stale, fetched_at: new Date().toISOString() }]);
const { result } = renderHook(() => useMarketplace());
await act(() => result.current.load({ refreshStale: true }));
expect(refreshMarketplaces).toHaveBeenCalledTimes(1);
expect(refreshMarketplaces).toHaveBeenCalledWith("m2");
expect(result.current.snapshots.map((s) => s.marketplace_id)).toEqual(["m1", "m2"]);
expect(result.current.snapshots[1].fetched_at).not.toBeNull();
});
it("toasts and reloads after a failed mutation", async () => {
listMarketplaceSnapshots.mockResolvedValue([]);
installMarketplaceItem.mockRejectedValue("boom");
const { result } = renderHook(() => useMarketplace());
const ok = await act(() =>
result.current.install({ marketplace_id: "m1", kind: "agent", key: "a" }, { type: "global" }),
);
expect(ok).toBe(false);
expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "error", detail: "boom" });
});
});
describe("useMarketplaceSyncToasts", () => {
beforeEach(() => {
syncHandler = null;
useAppState.setState({
toasts: [],
projects: [{ id: "p1", name: "api" }] as never,
});
});
it("toasts a sync with errors and stays quiet on a clean one", async () => {
renderHook(() => useMarketplaceSyncToasts());
await waitFor(() => expect(syncHandler).not.toBeNull());
act(() =>
syncHandler!({
payload: {
project_id: "p1",
report: { installed: ["agent:a"], updated: [], removed: [], skipped: [], errors: [], finished_at: "" },
},
}),
);
expect(useAppState.getState().toasts).toHaveLength(0);
act(() =>
syncHandler!({
payload: {
project_id: "p1",
report: {
installed: [],
updated: [],
removed: [],
skipped: [{ item: "agent:a", reason: "a file you created has the same name" }],
errors: ["claude plugin install failed"],
finished_at: "",
},
},
}),
);
const toast = useAppState.getState().toasts[0];
expect(toast.kind).toBe("error");
expect(toast.message).toContain("api");
expect(toast.detail).toContain("claude plugin install failed");
expect(toast.detail).toContain("agent:a");
});
});
Run: cd app && npx vitest run src/hooks/useMarketplace.test.ts
Expected: FAIL — Failed to resolve import "./useMarketplace".
- Step 13: Implement
hooks/useMarketplace.ts
import { useCallback, useEffect, useState } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import * as commands from "../lib/tauri-commands";
import { useAppState } from "../store/appState";
import { isStale } from "../lib/marketplace";
import type {
InstallScope,
ItemUpdate,
MarketplaceItemRef,
MarketplaceSnapshot,
SyncReport,
} from "../lib/types";
export interface MarketplaceApi {
snapshots: MarketplaceSnapshot[];
updates: ItemUpdate[];
loading: boolean;
/** Ids of marketplaces currently being fetched. */
refreshing: string[];
load: (opts?: { refreshStale?: boolean }) => Promise<void>;
refresh: (marketplaceId?: string) => Promise<void>;
/** Reload settings, projects and the update list after a mutation. */
reloadState: () => Promise<void>;
install: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
uninstall: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
setDisabled: (projectId: string, item: MarketplaceItemRef, disabled: boolean) => Promise<boolean>;
update: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
forget: (marketplaceId: string) => Promise<boolean>;
remove: (marketplaceId: string) => Promise<boolean>;
}
function errorText(e: unknown): string {
return typeof e === "string" ? e : e instanceof Error ? e.message : String(e);
}
export function useMarketplace(): MarketplaceApi {
const setAppSettings = useAppState((s) => s.setAppSettings);
const setProjects = useAppState((s) => s.setProjects);
const pushToast = useAppState((s) => s.pushToast);
const [snapshots, setSnapshots] = useState<MarketplaceSnapshot[]>([]);
const [updates, setUpdates] = useState<ItemUpdate[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState<string[]>([]);
const merge = useCallback((fresh: MarketplaceSnapshot[]) => {
setSnapshots((prev) => {
const byId = new Map(prev.map((s) => [s.marketplace_id, s]));
for (const s of fresh) byId.set(s.marketplace_id, s);
return [...byId.values()];
});
}, []);
const loadUpdates = useCallback(async () => {
try {
setUpdates(await commands.listMarketplaceUpdates());
} catch (e) {
console.error("Failed to list marketplace updates:", e);
}
}, []);
const refresh = useCallback(
async (marketplaceId?: string) => {
const ids = marketplaceId ? [marketplaceId] : snapshots.map((s) => s.marketplace_id);
setRefreshing((r) => [...new Set([...r, ...ids])]);
try {
merge(await commands.refreshMarketplaces(marketplaceId));
await loadUpdates();
} catch (e) {
pushToast({ kind: "error", message: "Could not refresh the marketplace", detail: errorText(e) });
} finally {
setRefreshing((r) => r.filter((id) => !ids.includes(id)));
}
},
[snapshots, merge, loadUpdates, pushToast],
);
const load = useCallback(
async (opts: { refreshStale?: boolean } = {}) => {
setLoading(true);
try {
const list = await commands.listMarketplaceSnapshots();
setSnapshots(list);
await loadUpdates();
if (opts.refreshStale) {
const now = Date.now();
const stale = list.filter((s) => isStale(s, now)).map((s) => s.marketplace_id);
if (stale.length > 0) {
setRefreshing(stale);
try {
// One call per marketplace so one slow or failing repo does not hold up the rest.
await Promise.all(
stale.map(async (id) => {
try {
merge(await commands.refreshMarketplaces(id));
} finally {
setRefreshing((r) => r.filter((x) => x !== id));
}
}),
);
} finally {
await loadUpdates();
}
}
}
} catch (e) {
pushToast({ kind: "error", message: "Could not load marketplaces", detail: errorText(e) });
} finally {
setLoading(false);
}
},
[merge, loadUpdates, pushToast],
);
const reloadState = useCallback(async () => {
const [settings, projects] = await Promise.all([commands.getSettings(), commands.listProjects()]);
setAppSettings(settings);
setProjects(projects);
await loadUpdates();
}, [setAppSettings, setProjects, loadUpdates]);
/** Run a mutation; on failure toast it. Always resync local state afterwards. */
const mutate = useCallback(
async (label: string, run: () => Promise<unknown>): Promise<boolean> => {
let ok = true;
try {
await run();
} catch (e) {
ok = false;
pushToast({ kind: "error", message: label, detail: errorText(e) });
}
try {
await reloadState();
} catch (e) {
console.error("Failed to reload after marketplace change:", e);
}
return ok;
},
[reloadState, pushToast],
);
return {
snapshots,
updates,
loading,
refreshing,
load,
refresh,
reloadState,
install: (item, scope) =>
mutate(`Could not install ${item.key}`, () => commands.installMarketplaceItem(item, scope)),
uninstall: (item, scope) =>
mutate(`Could not remove ${item.key}`, () => commands.uninstallMarketplaceItem(item, scope)),
setDisabled: (projectId, item, disabled) =>
mutate(`Could not change ${item.key} for this project`, () =>
commands.setGlobalItemDisabled(projectId, item, disabled),
),
update: (item, scope) =>
mutate(`Could not update ${item.key}`, () => commands.updateMarketplaceItem(item, scope)),
forget: (marketplaceId) =>
mutate("Could not forget those installs", () => commands.forgetMarketplaceInstalls(marketplaceId)),
remove: async (marketplaceId) => {
const ok = await mutate("Could not remove the marketplace", () =>
commands.removeMarketplace(marketplaceId),
);
if (ok) setSnapshots((prev) => prev.filter((s) => s.marketplace_id !== marketplaceId));
return ok;
},
};
}
interface SyncFinishedEvent {
project_id: string;
report: SyncReport;
}
/**
* App-wide: toast when a marketplace sync (container start or "Apply now")
* reports errors or skipped items. A clean sync is silent.
*/
export function useMarketplaceSyncToasts() {
useEffect(() => {
let cancelled = false;
let unlisten: UnlistenFn | null = null;
void listen<SyncFinishedEvent>("marketplace-sync-finished", (event) => {
const { project_id, report } = event.payload;
if (report.errors.length === 0 && report.skipped.length === 0) return;
const state = useAppState.getState();
const name = state.projects.find((p) => p.id === project_id)?.name ?? project_id;
const lines = [
...report.errors,
...report.skipped.map((s) => `${s.item}: ${s.reason}`),
];
state.pushToast({
kind: report.errors.length > 0 ? "error" : "info",
message: `Marketplace sync for “${name}” ${report.errors.length > 0 ? "had errors" : "skipped items"}`,
detail: lines.join("\n"),
dedupeKey: `marketplace-sync-${project_id}`,
});
}).then((fn) => {
if (cancelled) fn();
else unlisten = fn;
});
return () => {
cancelled = true;
unlisten?.();
};
}, []);
}
The store exposes setProjects: (projects: Project[]) => void (store/appState.ts:119) and projects.
Run: cd app && npx vitest run src/hooks/useMarketplace.test.ts
Expected: PASS.
- Step 14: Failing tests for the settings section and the view shell
app/src/components/settings/MarketplaceSettings.test.tsx:
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import MarketplaceSettings from "./MarketplaceSettings";
import { useAppState, MARKETPLACE_TAB_KEY } from "../../store/appState";
import type { AppSettings } from "../../lib/types";
const listMarketplaceUpdates = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
listMarketplaceUpdates: () => listMarketplaceUpdates(),
}));
describe("MarketplaceSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
useAppState.setState({
tabOrder: [],
activeTabKey: null,
appSettings: {
marketplaces: [{ id: "m1", name: "Starter", url: "https://x/y.git", branch: null, account_id: null }],
global_marketplace_installs: [
{ marketplace_id: "m1", kind: "agent", key: "a", commit: "a".repeat(40) },
{ marketplace_id: "m1", kind: "hook", key: "h", commit: "a".repeat(40) },
],
marketplace_accounts: [],
} as unknown as AppSettings,
});
listMarketplaceUpdates.mockResolvedValue([
{ item: { marketplace_id: "m1", kind: "agent", key: "a" }, pinned: "a".repeat(40), head: "b".repeat(40) },
]);
});
it("summarises and opens the Marketplace tab", async () => {
render(<MarketplaceSettings />);
expect(screen.getByTestId("marketplace-summary")).toHaveTextContent("1 marketplace");
expect(screen.getByTestId("marketplace-summary")).toHaveTextContent("2 installed for all projects");
await waitFor(() =>
expect(screen.getByTestId("marketplace-summary")).toHaveTextContent("1 update available"),
);
fireEvent.click(screen.getByRole("button", { name: "Open Marketplace" }));
expect(useAppState.getState().activeTabKey).toBe(MARKETPLACE_TAB_KEY);
});
});
app/src/components/marketplace/MarketplaceView.test.tsx:
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
const load = vi.fn(async () => {});
vi.mock("../../hooks/useMarketplace", () => ({
useMarketplace: () => ({
snapshots: [],
updates: [],
loading: false,
refreshing: [],
load,
refresh: vi.fn(),
reloadState: vi.fn(),
install: vi.fn(),
uninstall: vi.fn(),
setDisabled: vi.fn(),
update: vi.fn(),
forget: vi.fn(),
remove: vi.fn(),
}),
}));
vi.mock("./BrowsePane", () => ({ default: () => <div>browse pane</div> }));
vi.mock("./InstalledPane", () => ({ default: () => <div>installed pane</div> }));
vi.mock("./AccountsPane", () => ({ default: () => <div>accounts pane</div> }));
import MarketplaceView from "./MarketplaceView";
describe("MarketplaceView", () => {
beforeEach(() => vi.clearAllMocks());
it("loads with stale refresh when first shown and switches sub-tabs", async () => {
render(<MarketplaceView active />);
await waitFor(() => expect(load).toHaveBeenCalledWith({ refreshStale: true }));
expect(screen.getByText("browse pane")).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
expect(screen.getByText("installed pane")).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "Accounts" }));
expect(screen.getByText("accounts pane")).toBeInTheDocument();
});
it("does not load while hidden", () => {
render(<MarketplaceView active={false} />);
expect(load).not.toHaveBeenCalled();
});
});
Run: cd app && npx vitest run src/components/settings/MarketplaceSettings.test.tsx src/components/marketplace/MarketplaceView.test.tsx
Expected: FAIL — modules not found.
- Step 15: Implement the settings section
app/src/components/settings/MarketplaceSettings.tsx:
import { useEffect, useState } from "react";
import { useAppState } from "../../store/appState";
import { listMarketplaceUpdates } from "../../lib/tauri-commands";
import Button from "../ui/Button";
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
export default function MarketplaceSettings() {
const appSettings = useAppState((s) => s.appSettings);
const openMarketplace = useAppState((s) => s.openMarketplace);
const [updateCount, setUpdateCount] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
listMarketplaceUpdates()
.then((u) => {
if (!cancelled) setUpdateCount(u.length);
})
.catch(() => {
if (!cancelled) setUpdateCount(null);
});
return () => {
cancelled = true;
};
}, [appSettings?.marketplaces.length]);
const marketplaces = appSettings?.marketplaces.length ?? 0;
const globalInstalls = appSettings?.global_marketplace_installs.length ?? 0;
return (
<div className="space-y-2">
<p data-testid="marketplace-summary" className="text-xs text-[var(--text-secondary)] leading-snug">
{plural(marketplaces, "marketplace", "marketplaces")} ·{" "}
{globalInstalls} installed for all projects
{updateCount !== null && updateCount > 0 && (
<> · {plural(updateCount, "update available", "updates available")}</>
)}
</p>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
Agents, skills, commands, hooks and plugins from git repositories, installed for all
projects or per project. Changes apply to new Claude sessions.
</p>
<Button size="md" variant="secondary" onClick={() => openMarketplace()}>
Open Marketplace
</Button>
</div>
);
}
In app/src/components/settings/SettingsPanel.tsx add import MarketplaceSettings from "./MarketplaceSettings"; next to the SharedAuthSettings import, and directly after the claude-auth AccordionSection (lines 170-172) add:
<AccordionSection id="marketplace" title="Marketplace" defaultOpen={false}>
<MarketplaceSettings />
</AccordionSection>
- Step 16: Implement the view shell and wire it into
App.tsx
app/src/components/marketplace/MarketplaceView.tsx:
import { useEffect, useRef, useState } from "react";
import { useMarketplace } from "../../hooks/useMarketplace";
import BrowsePane from "./BrowsePane";
import InstalledPane from "./InstalledPane";
import AccountsPane from "./AccountsPane";
const SUB_TABS = [
{ id: "browse", label: "Browse" },
{ id: "installed", label: "Installed" },
{ id: "accounts", label: "Accounts" },
] as const;
export type MarketplaceSubTab = (typeof SUB_TABS)[number]["id"];
interface Props {
active: boolean;
}
export default function MarketplaceView({ active }: Props) {
const mp = useMarketplace();
const [tab, setTab] = useState<MarketplaceSubTab>("browse");
const { load } = mp;
const wasActive = useRef(false);
// Load (and refresh stale marketplaces) each time the tab comes to the front.
useEffect(() => {
if (active && !wasActive.current) void load({ refreshStale: true });
wasActive.current = active;
}, [active, load]);
return (
<div className={`w-full h-full flex flex-col min-h-0 ${active ? "" : "hidden"}`}>
<div
role="tablist"
aria-label="Marketplace sections"
className="flex gap-1 px-3 pt-3 border-b border-[var(--border-color)]"
>
{SUB_TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={tab === t.id}
onClick={() => setTab(t.id)}
className={`px-3 py-1.5 text-xs rounded-t-[var(--radius-control)] ${
tab === t.id
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{t.label}
{t.id === "installed" && mp.updates.length > 0 && (
<span className="ml-1.5 px-1 rounded-[4px] text-[10px] bg-[var(--accent-muted)] text-[var(--accent)]">
{mp.updates.length}
</span>
)}
</button>
))}
</div>
<div className="flex-1 min-h-0 overflow-auto">
{tab === "browse" && <BrowsePane mp={mp} />}
{tab === "installed" && <InstalledPane mp={mp} />}
{tab === "accounts" && <AccountsPane mp={mp} />}
</div>
</div>
);
}
Create the three panes with minimal real content now; Tasks 13, 14 and 15 replace each file completely.
app/src/components/marketplace/BrowsePane.tsx:
import type { MarketplaceApi } from "../../hooks/useMarketplace";
export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
return (
<p className="p-4 text-xs text-[var(--text-secondary)]">
{mp.snapshots.length} marketplace{mp.snapshots.length === 1 ? "" : "s"} configured.
</p>
);
}
app/src/components/marketplace/InstalledPane.tsx:
import type { MarketplaceApi } from "../../hooks/useMarketplace";
export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
return (
<p className="p-4 text-xs text-[var(--text-secondary)]">
{mp.updates.length} update{mp.updates.length === 1 ? "" : "s"} available.
</p>
);
}
app/src/components/marketplace/AccountsPane.tsx:
import type { MarketplaceApi } from "../../hooks/useMarketplace";
import { useAppState } from "../../store/appState";
export default function AccountsPane(_props: { mp: MarketplaceApi }) {
const count = useAppState((s) => s.appSettings?.marketplace_accounts.length ?? 0);
return (
<p className="p-4 text-xs text-[var(--text-secondary)]">
{count} account{count === 1 ? "" : "s"}.
</p>
);
}
app/src/App.tsx:
- Change the store import (line 24) to
import { useAppState, isHomeTab, tabKeyId, homeTabKey, MARKETPLACE_TAB_KEY } from "./store/appState";. - Add imports:
import MarketplaceView from "./components/marketplace/MarketplaceView";andimport { useMarketplaceSyncToasts } from "./hooks/useMarketplace";. - In the
Appcomponent body next to the other hook calls (e.g. afteruseKeyboardShortcuts()), adduseMarketplaceSyncToasts();. - Inside
<div className="w-full h-full">, after thesessions.map(...)block, add:
{tabOrder.includes(MARKETPLACE_TAB_KEY) && (
<PaneVisibilityProvider visible={activeTabKey === MARKETPLACE_TAB_KEY}>
<MarketplaceView active={activeTabKey === MARKETPLACE_TAB_KEY} />
</PaneVisibilityProvider>
)}
- Step 17: Run the new and neighbouring tests
Run: cd app && npx vitest run src/components/settings src/components/marketplace src/components/layout src/hooks src/store src/lib src/test/capabilities.test.ts
Expected: PASS. capabilities.test.ts passes only once Task 11 has added the allow-* grants for every new wrapper; if Task 11 is not merged yet, this test fails listing the new wrappers, which is expected at this point and must pass before committing Task 17.
- Step 18: Type-check and commit
Run: cd app && npx tsc --noEmit -p .
Expected: no errors.
cd /workspace/triple-c && git add app/src && git commit -qm "Marketplace UI plumbing: wrappers, singleton tab, settings section, view shell
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 13: Browse — marketplace list, item detail, install controls, hook confirm, add marketplace
Files:
- Replace:
app/src/components/marketplace/BrowsePane.tsx - Create:
app/src/components/marketplace/ItemDetail.tsx - Create:
app/src/components/marketplace/InstallControls.tsx - Create:
app/src/components/marketplace/HookConfirmModal.tsx - Create:
app/src/components/marketplace/AddMarketplaceModal.tsx - Test:
app/src/components/marketplace/BrowsePane.test.tsx,InstallControls.test.tsx,AddMarketplaceModal.test.tsx
Interfaces:
-
Consumes:
MarketplaceApi(Task 12),projectItemState,KIND_LABELS,KIND_ORDER,itemRefKey(Task 12),addMarketplacewrapper, storeappSettings,projects,marketplaceFilterProjectId,setMarketplaceFilterProjectId. -
Produces:
BrowsePane({ mp })default export (same props as the Task 12 stub);InstallControls({ mp, item, marketplaceId });HookConfirmModal({ item, onConfirm, onCancel });AddMarketplaceModal({ onClose, onAdded }). -
Step 1: Failing test for InstallControls
app/src/components/marketplace/InstallControls.test.tsx:
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import InstallControls from "./InstallControls";
import { useAppState } from "../../store/appState";
import type { AppSettings, CatalogItem, Project } from "../../lib/types";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
const C = "c".repeat(40);
function api(): MarketplaceApi {
return {
snapshots: [],
updates: [],
loading: false,
refreshing: [],
load: vi.fn(),
refresh: vi.fn(),
reloadState: vi.fn(),
install: vi.fn(async () => true),
uninstall: vi.fn(async () => true),
setDisabled: vi.fn(async () => true),
update: vi.fn(),
forget: vi.fn(),
remove: vi.fn(),
};
}
const item = (kind: CatalogItem["kind"], patch: Partial<CatalogItem> = {}): CatalogItem => ({
kind,
key: "rev",
name: "rev",
description: "",
path: `agents/rev.md`,
invalid: null,
hook_commands: kind === "hook" ? ["/home/claude/.claude/triple-c/hooks/rev/run.sh"] : [],
preview: "",
...patch,
});
const project = (id: string, patch: Partial<Project> = {}) =>
({ id, name: `proj-${id}`, marketplace_installs: [], marketplace_disabled: [], ...patch }) as unknown as Project;
function seed(globalInstalls: AppSettings["global_marketplace_installs"], projects: Project[]) {
useAppState.setState({
appSettings: { global_marketplace_installs: globalInstalls, marketplaces: [], marketplace_accounts: [] } as unknown as AppSettings,
projects,
marketplaceFilterProjectId: null,
});
}
const ref = { marketplace_id: "m1", kind: "agent" as const, key: "rev" };
describe("InstallControls", () => {
beforeEach(() => seed([], [project("p1"), project("p2")]));
it("installs for all projects", () => {
const mp = api();
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
expect(mp.install).toHaveBeenCalledWith(ref, { type: "global" });
});
it("installs for one project", () => {
const mp = api();
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p2/ }));
expect(mp.install).toHaveBeenCalledWith(ref, { type: "project", project_id: "p2" });
});
it("opts a project out of a global install and back in", () => {
const mp = api();
seed([{ ...ref, commit: C }], [project("p1"), project("p2", { marketplace_disabled: [ref] })]);
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
const row1 = screen.getByTestId("install-row-p1");
expect(within(row1).getByText("Inherited")).toBeInTheDocument();
fireEvent.click(within(row1).getByRole("checkbox"));
expect(mp.setDisabled).toHaveBeenCalledWith("p1", ref, true);
const row2 = screen.getByTestId("install-row-p2");
expect(within(row2).getByText("Opted out")).toBeInTheDocument();
fireEvent.click(within(row2).getByRole("checkbox"));
expect(mp.setDisabled).toHaveBeenCalledWith("p2", ref, false);
});
it("removes a project-only install", () => {
const mp = api();
seed([], [project("p1", { marketplace_installs: [{ ...ref, commit: C }] })]);
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p1/ }));
expect(mp.uninstall).toHaveBeenCalledWith(ref, { type: "project", project_id: "p1" });
});
it("requires confirmation before installing a hook", () => {
const mp = api();
render(<InstallControls mp={mp} item={item("hook")} marketplaceId="m1" />);
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
expect(mp.install).not.toHaveBeenCalled();
expect(screen.getByText("/home/claude/.claude/triple-c/hooks/rev/run.sh")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Install hook" }));
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" });
});
it("disables everything for an invalid item", () => {
render(<InstallControls mp={api()} item={item("agent", { invalid: "bad front matter" })} marketplaceId="m1" />);
expect(screen.getByRole("switch", { name: "All projects" })).toBeDisabled();
expect(screen.getByRole("checkbox", { name: /proj-p1/ })).toBeDisabled();
});
it("shows only the filtered project when a filter is set", () => {
useAppState.setState({ marketplaceFilterProjectId: "p2" });
render(<InstallControls mp={api()} item={item("agent")} marketplaceId="m1" />);
expect(screen.queryByTestId("install-row-p1")).not.toBeInTheDocument();
expect(screen.getByTestId("install-row-p2")).toBeInTheDocument();
});
});
Toggle renders role="switch" with aria-label={label} and aria-checked (components/ui/Toggle.tsx:31-33), so it is queried as a switch.
Run: cd app && npx vitest run src/components/marketplace/InstallControls.test.tsx
Expected: FAIL — module not found.
- Step 2: Implement HookConfirmModal and InstallControls
app/src/components/marketplace/HookConfirmModal.tsx:
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import type { CatalogItem } from "../../lib/types";
interface Props {
item: CatalogItem;
onConfirm: () => void;
onCancel: () => void;
}
/** Hooks run shell commands in every Claude session, so installing one is always confirmed. */
export default function HookConfirmModal({ item, onConfirm, onCancel }: Props) {
return (
<Modal
title={`Install hook “${item.name}”?`}
description="This hook runs the commands below inside the container whenever its event fires."
widthClassName="w-[40rem]"
onClose={onCancel}
footer={
<>
<Button size="md" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button size="md" variant="primary" onClick={onConfirm}>
Install hook
</Button>
</>
}
>
{item.hook_commands.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">This hook declares no commands.</p>
) : (
<ul className="space-y-1">
{item.hook_commands.map((c) => (
<li key={c}>
<code className="block font-mono text-xs break-all px-2 py-1 rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
{c}
</code>
</li>
))}
</ul>
)}
</Modal>
);
}
app/src/components/marketplace/InstallControls.tsx:
import { useState } from "react";
import { useAppState } from "../../store/appState";
import { projectItemState, type ProjectItemState } from "../../lib/marketplace";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
import type { CatalogItem, InstallScope, MarketplaceItemRef } from "../../lib/types";
import Toggle from "../ui/Toggle";
import HookConfirmModal from "./HookConfirmModal";
const STATE_LABEL: Record<ProjectItemState, string> = {
none: "",
inherited: "Inherited",
opted_out: "Opted out",
project: "This project",
project_pinned_differently: "Pinned to a different commit",
};
interface Props {
mp: MarketplaceApi;
item: CatalogItem;
marketplaceId: string;
}
export default function InstallControls({ mp, item, marketplaceId }: Props) {
const appSettings = useAppState((s) => s.appSettings);
const projects = useAppState((s) => s.projects);
const filterId = useAppState((s) => s.marketplaceFilterProjectId);
const [pendingHook, setPendingHook] = useState<InstallScope | null>(null);
const [busy, setBusy] = useState(false);
const ref: MarketplaceItemRef = { marketplace_id: marketplaceId, kind: item.kind, key: item.key };
const globalInstalls = appSettings?.global_marketplace_installs ?? [];
const isGlobal = globalInstalls.some(
(g) => g.marketplace_id === marketplaceId && g.kind === item.kind && g.key === item.key,
);
const disabled = item.invalid !== null || busy;
const shown = filterId ? projects.filter((p) => p.id === filterId) : projects;
const run = async (fn: () => Promise<boolean>) => {
setBusy(true);
try {
await fn();
} finally {
setBusy(false);
}
};
/** Every install goes through here so a hook is always confirmed first. */
const install = (scope: InstallScope) => {
if (item.kind === "hook") {
setPendingHook(scope);
return;
}
void run(() => mp.install(ref, scope));
};
const toggleProject = (projectId: string, state: ProjectItemState) => {
const scope: InstallScope = { type: "project", project_id: projectId };
switch (state) {
case "none":
install(scope);
break;
case "inherited":
void run(() => mp.setDisabled(projectId, ref, true));
break;
case "opted_out":
void run(() => mp.setDisabled(projectId, ref, false));
break;
case "project":
case "project_pinned_differently":
void run(() => mp.uninstall(ref, scope));
break;
}
};
return (
<div className="space-y-2">
<Toggle
label="All projects"
checked={isGlobal}
disabled={disabled}
onChange={(v) => (v ? install({ type: "global" }) : void run(() => mp.uninstall(ref, { type: "global" })))}
/>
<ul className="space-y-1">
{shown.map((p) => {
const state = projectItemState(ref, globalInstalls, p);
const checked = state === "inherited" || state === "project" || state === "project_pinned_differently";
return (
<li
key={p.id}
data-testid={`install-row-${p.id}`}
className="flex items-center justify-between gap-2 text-xs"
>
<label className="flex items-center gap-2 min-w-0">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={() => toggleProject(p.id, state)}
/>
<span className="truncate">{p.name}</span>
</label>
{STATE_LABEL[state] && (
<span className="text-[var(--text-secondary)] whitespace-nowrap">{STATE_LABEL[state]}</span>
)}
</li>
);
})}
</ul>
{projects.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No projects yet — “All projects” also covers projects added later.</p>
)}
{pendingHook && (
<HookConfirmModal
item={item}
onCancel={() => setPendingHook(null)}
onConfirm={() => {
const scope = pendingHook;
setPendingHook(null);
void run(() => mp.install(ref, scope));
}}
/>
)}
</div>
);
}
Run: cd app && npx vitest run src/components/marketplace/InstallControls.test.tsx
Expected: PASS.
- Step 3: Failing tests for AddMarketplaceModal and BrowsePane
app/src/components/marketplace/AddMarketplaceModal.test.tsx:
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useAppState } from "../../store/appState";
import type { AppSettings } from "../../lib/types";
const addMarketplace = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
addMarketplace: (...a: unknown[]) => addMarketplace(...a),
}));
import AddMarketplaceModal from "./AddMarketplaceModal";
describe("AddMarketplaceModal", () => {
beforeEach(() => {
vi.clearAllMocks();
useAppState.setState({
appSettings: {
marketplace_accounts: [{ id: "acc1", label: "Work", host: "github.com", method: "token", username: "me" }],
marketplaces: [],
global_marketplace_installs: [],
} as unknown as AppSettings,
});
});
it("submits name, url, branch and account", async () => {
const onAdded = vi.fn();
addMarketplace.mockResolvedValue({ marketplace_id: "m1", head_commit: null, fetched_at: null, fetch_error: null, items: [] });
render(<AddMarketplaceModal onClose={vi.fn()} onAdded={onAdded} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Starter" } });
fireEvent.change(screen.getByLabelText("Repository URL"), { target: { value: "https://github.com/shadowdao/triple-c-marketplace.git" } });
fireEvent.change(screen.getByLabelText("Branch"), { target: { value: "" } });
fireEvent.change(screen.getByLabelText("Account"), { target: { value: "acc1" } });
fireEvent.click(screen.getByRole("button", { name: "Add marketplace" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
expect(addMarketplace).toHaveBeenCalledWith("Starter", "https://github.com/shadowdao/triple-c-marketplace.git", null, "acc1");
});
it("rejects non-https URLs before calling the backend", () => {
render(<AddMarketplaceModal onClose={vi.fn()} onAdded={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "x" } });
fireEvent.change(screen.getByLabelText("Repository URL"), { target: { value: "git@github.com:a/b.git" } });
expect(screen.getByRole("button", { name: "Add marketplace" })).toBeDisabled();
expect(screen.getByText(/must start with https:\/\//)).toBeInTheDocument();
});
it("shows the backend error and stays open", async () => {
addMarketplace.mockRejectedValue("Work cannot read this repository (HTTP 404)");
render(<AddMarketplaceModal onClose={vi.fn()} onAdded={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "x" } });
fireEvent.change(screen.getByLabelText("Repository URL"), { target: { value: "https://github.com/a/b.git" } });
fireEvent.click(screen.getByRole("button", { name: "Add marketplace" }));
expect(await screen.findByText(/HTTP 404/)).toBeInTheDocument();
});
});
app/src/components/marketplace/BrowsePane.test.tsx:
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { useAppState } from "../../store/appState";
import type { AppSettings, CatalogItem, MarketplaceSnapshot } from "../../lib/types";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
vi.mock("./InstallControls", () => ({ default: () => <div>install controls</div> }));
vi.mock("./AddMarketplaceModal", () => ({ default: () => <div>add modal</div> }));
import BrowsePane from "./BrowsePane";
const it_ = (kind: CatalogItem["kind"], key: string, patch: Partial<CatalogItem> = {}): CatalogItem => ({
kind,
key,
name: key,
description: `${key} description`,
path: key,
invalid: null,
hook_commands: [],
preview: `${key} preview body`,
...patch,
});
const snapshot: MarketplaceSnapshot = {
marketplace_id: "m1",
head_commit: "a".repeat(40),
fetched_at: "2026-09-27T12:00:00Z",
fetch_error: "network unreachable",
items: [it_("agent", "code-reviewer"), it_("hook", "notify-on-stop"), it_("skill", "broken", { invalid: "SKILL.md missing" })],
};
function api(patch: Partial<MarketplaceApi> = {}): MarketplaceApi {
return {
snapshots: [snapshot],
updates: [],
loading: false,
refreshing: [],
load: vi.fn(),
refresh: vi.fn(),
reloadState: vi.fn(),
install: vi.fn(),
uninstall: vi.fn(),
setDisabled: vi.fn(),
update: vi.fn(),
forget: vi.fn(),
remove: vi.fn(),
...patch,
};
}
describe("BrowsePane", () => {
beforeEach(() => {
useAppState.setState({
appSettings: {
marketplaces: [{ id: "m1", name: "Starter", url: "https://github.com/s/m.git", branch: null, account_id: null }],
marketplace_accounts: [],
global_marketplace_installs: [],
} as unknown as AppSettings,
projects: [],
marketplaceFilterProjectId: null,
});
});
it("lists items, filters by kind and search, and shows detail", () => {
render(<BrowsePane mp={api()} />);
expect(screen.getByText("network unreachable")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /code-reviewer/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /notify-on-stop/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole("radio", { name: "Hooks" }));
expect(screen.queryByRole("button", { name: /code-reviewer/ })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("radio", { name: "All" }));
fireEvent.change(screen.getByLabelText("Search items"), { target: { value: "review" } });
expect(screen.queryByRole("button", { name: /notify-on-stop/ })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /code-reviewer/ }));
expect(screen.getByText("code-reviewer preview body")).toBeInTheDocument();
expect(screen.getByText("install controls")).toBeInTheDocument();
});
it("shows why an item is invalid", () => {
render(<BrowsePane mp={api()} />);
fireEvent.click(screen.getByRole("button", { name: /broken/ }));
expect(screen.getByText("SKILL.md missing")).toBeInTheDocument();
});
it("refreshes one marketplace", () => {
const mp = api();
render(<BrowsePane mp={mp} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh Starter" }));
expect(mp.refresh).toHaveBeenCalledWith("m1");
});
it("offers Add when there are no marketplaces", () => {
useAppState.setState({
appSettings: { marketplaces: [], marketplace_accounts: [], global_marketplace_installs: [] } as unknown as AppSettings,
});
render(<BrowsePane mp={api({ snapshots: [] })} />);
fireEvent.click(screen.getByRole("button", { name: "Add marketplace" }));
expect(screen.getByText("add modal")).toBeInTheDocument();
});
});
SegmentedControl renders a role="radiogroup" with one role="radio" per segment (components/ui/SegmentedControl.tsx:51,78).
Run: cd app && npx vitest run src/components/marketplace/AddMarketplaceModal.test.tsx src/components/marketplace/BrowsePane.test.tsx
Expected: FAIL — modules not found / stub BrowsePane lacks the list.
- Step 4: Implement AddMarketplaceModal
app/src/components/marketplace/AddMarketplaceModal.tsx:
import { useState } from "react";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import Field, { inputClass, selectClass } from "../ui/Field";
import { addMarketplace } from "../../lib/tauri-commands";
import { useAppState } from "../../store/appState";
import type { MarketplaceSnapshot } from "../../lib/types";
interface Props {
onClose: () => void;
onAdded: (snapshot: MarketplaceSnapshot) => void;
}
export default function AddMarketplaceModal({ onClose, onAdded }: Props) {
const accounts = useAppState((s) => s.appSettings?.marketplace_accounts ?? []);
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [branch, setBranch] = useState("");
const [accountId, setAccountId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const trimmedUrl = url.trim();
const urlProblem =
trimmedUrl !== "" && !trimmedUrl.startsWith("https://")
? "The repository URL must start with https:// (SSH URLs are not supported)."
: null;
const canSubmit = name.trim() !== "" && trimmedUrl !== "" && !urlProblem && !busy;
const submit = async () => {
setBusy(true);
setError(null);
try {
const snap = await addMarketplace(
name.trim(),
trimmedUrl,
branch.trim() === "" ? null : branch.trim(),
accountId === "" ? null : accountId,
);
onAdded(snap);
onClose();
} catch (e) {
setError(typeof e === "string" ? e : String(e));
} finally {
setBusy(false);
}
};
return (
<Modal
title="Add marketplace"
description="Triple-C fetches the repository now to check it can be read. Nothing is saved if that fails."
widthClassName="w-[36rem]"
dismissible={!busy}
onClose={onClose}
footer={
<>
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button size="md" variant="primary" onClick={() => void submit()} disabled={!canSubmit}>
{busy ? "Checking…" : "Add marketplace"}
</Button>
</>
}
>
<div className="space-y-3">
<Field label="Name">
{(id) => (
<input id={id} value={name} onChange={(e) => setName(e.target.value)} className={inputClass} placeholder="Team marketplace" />
)}
</Field>
<Field label="Repository URL" hint={urlProblem ?? "HTTPS clone URL, e.g. https://github.com/owner/repo.git"}>
{(id) => (
<input id={id} value={url} onChange={(e) => setUrl(e.target.value)} className={inputClass} placeholder="https://github.com/owner/repo.git" />
)}
</Field>
<Field label="Branch" hint="Leave empty to use the repository's default branch.">
{(id) => (
<input id={id} value={branch} onChange={(e) => setBranch(e.target.value)} className={inputClass} placeholder="main" />
)}
</Field>
<Field label="Account" hint="Needed for private repositories. Add accounts on the Accounts tab.">
{(id) => (
<select id={id} value={accountId} onChange={(e) => setAccountId(e.target.value)} className={selectClass}>
<option value="">None (public repository)</option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>
{a.label} — {a.host}
{a.username ? ` (${a.username})` : ""}
</option>
))}
</select>
)}
</Field>
{error && (
<p role="alert" className="text-xs text-[var(--error)] whitespace-pre-wrap leading-snug">
{error}
</p>
)}
</div>
</Modal>
);
}
Field renders its hint below the control; the URL problem is shown there so the test can find it by text.
- Step 5: Implement ItemDetail and BrowsePane
app/src/components/marketplace/ItemDetail.tsx:
import type { CatalogItem } from "../../lib/types";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
import { KIND_LABELS } from "../../lib/marketplace";
import StatusIndicator from "../ui/StatusIndicator";
import InstallControls from "./InstallControls";
interface Props {
mp: MarketplaceApi;
item: CatalogItem;
marketplaceId: string;
}
export default function ItemDetail({ mp, item, marketplaceId }: Props) {
return (
<div className="space-y-3">
<div>
<p className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)]">
{KIND_LABELS[item.kind].replace(/s$/, "")} · <code className="font-mono">{item.path}</code>
</p>
<h3 className="text-sm font-medium text-[var(--text-primary)]">{item.name}</h3>
{item.description && <p className="text-xs text-[var(--text-secondary)] leading-snug">{item.description}</p>}
</div>
{item.invalid && (
<div className="rounded-[var(--radius-control)] border border-[var(--error)]/40 bg-[var(--error-muted)] p-2">
<StatusIndicator tone="error" label="Cannot be installed" className="text-xs" />
<p className="mt-1 text-xs text-[var(--text-secondary)]">{item.invalid}</p>
</div>
)}
{item.kind === "hook" && item.hook_commands.length > 0 && (
<div>
<p className="text-xs font-medium mb-1">Commands this hook runs</p>
<ul className="space-y-1">
{item.hook_commands.map((c) => (
<li key={c}>
<code className="block font-mono text-xs break-all">{c}</code>
</li>
))}
</ul>
</div>
)}
{item.preview && (
<pre className="max-h-80 overflow-auto p-2 text-xs font-mono whitespace-pre-wrap rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
{item.preview}
</pre>
)}
<div>
<p className="text-xs font-medium mb-1">Install</p>
<InstallControls mp={mp} item={item} marketplaceId={marketplaceId} />
<p className="mt-2 text-[11px] text-[var(--text-secondary)]">
Running containers pick changes up on their next start or with “Apply now” on the Installed tab. Changes
apply to new Claude sessions.
</p>
</div>
</div>
);
}
app/src/components/marketplace/BrowsePane.tsx (replaces the Task 12 stub):
import { useMemo, useState } from "react";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
import { useAppState } from "../../store/appState";
import { KIND_LABELS, KIND_ORDER, itemRefKey } from "../../lib/marketplace";
import type { CatalogItem, ItemKind } from "../../lib/types";
import Button from "../ui/Button";
import SegmentedControl from "../ui/SegmentedControl";
import { inputClass, selectClass } from "../ui/Field";
import AddMarketplaceModal from "./AddMarketplaceModal";
import ItemDetail from "./ItemDetail";
type KindFilter = ItemKind | "all";
const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : "never");
export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
const marketplaces = useAppState((s) => s.appSettings?.marketplaces ?? []);
const projects = useAppState((s) => s.projects);
const filterId = useAppState((s) => s.marketplaceFilterProjectId);
const setFilterId = useAppState((s) => s.setMarketplaceFilterProjectId);
const [kind, setKind] = useState<KindFilter>("all");
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<{ marketplaceId: string; item: CatalogItem } | null>(null);
const [adding, setAdding] = useState(false);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return mp.snapshots.flatMap((snap) =>
snap.items
.filter((i) => kind === "all" || i.kind === kind)
.filter((i) => q === "" || `${i.name} ${i.key} ${i.description}`.toLowerCase().includes(q))
.sort((a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) || a.name.localeCompare(b.name))
.map((item) => ({ marketplaceId: snap.marketplace_id, item })),
);
}, [mp.snapshots, kind, query]);
const nameOf = (id: string) => marketplaces.find((m) => m.id === id)?.name ?? id;
return (
<div className="flex h-full min-h-0">
<aside className="w-64 flex-shrink-0 border-r border-[var(--border-color)] p-3 space-y-3 overflow-auto">
<div className="flex items-center justify-between">
<h2 className="text-xs font-medium">Marketplaces</h2>
<Button size="sm" variant="secondary" onClick={() => setAdding(true)}>
Add marketplace
</Button>
</div>
{marketplaces.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No marketplaces yet. Add a git repository to browse its items.</p>
)}
{marketplaces.map((m) => {
const snap = mp.snapshots.find((s) => s.marketplace_id === m.id);
const refreshing = mp.refreshing.includes(m.id);
return (
<div key={m.id} className="space-y-1 text-xs">
<div className="flex items-center justify-between gap-2">
<span className="font-medium truncate" title={m.url}>
{m.name}
</span>
<Button
size="sm"
variant="ghost"
aria-label={`Refresh ${m.name}`}
disabled={refreshing}
onClick={() => void mp.refresh(m.id)}
>
{refreshing ? "…" : "↻"}
</Button>
</div>
<p className="text-[var(--text-secondary)]">Last fetched {when(snap?.fetched_at ?? null)}</p>
{snap?.fetch_error && (
<p className="text-[var(--error)] whitespace-pre-wrap leading-snug">{snap.fetch_error}</p>
)}
</div>
);
})}
{projects.length > 0 && (
<label className="block text-xs space-y-1">
<span className="text-[var(--text-secondary)]">Show install state for</span>
<select
value={filterId ?? ""}
onChange={(e) => setFilterId(e.target.value === "" ? null : e.target.value)}
className={selectClass}
>
<option value="">All projects</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
)}
</aside>
<section className="w-80 flex-shrink-0 border-r border-[var(--border-color)] p-3 space-y-2 overflow-auto">
<SegmentedControl<KindFilter>
label="Item kind"
value={kind}
onChange={setKind}
segments={[
{ value: "all", label: "All" },
...KIND_ORDER.map((k) => ({ value: k as KindFilter, label: KIND_LABELS[k] })),
]}
/>
<input
aria-label="Search items"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
className={inputClass}
/>
<ul className="space-y-1">
{rows.map(({ marketplaceId, item }) => {
const key = itemRefKey({ marketplace_id: marketplaceId, kind: item.kind, key: item.key });
const isSel =
selected?.marketplaceId === marketplaceId &&
selected.item.kind === item.kind &&
selected.item.key === item.key;
return (
<li key={key}>
<button
type="button"
onClick={() => setSelected({ marketplaceId, item })}
className={`w-full text-left px-2 py-1.5 rounded-[var(--radius-control)] text-xs ${
isSel ? "bg-[var(--bg-tertiary)]" : "hover:bg-[var(--bg-tertiary)]"
}`}
>
<span className="font-medium">{item.name}</span>
<span className="ml-1 text-[var(--text-secondary)]">{KIND_LABELS[item.kind].replace(/s$/, "").toLowerCase()}</span>
{item.invalid && <span className="ml-1 text-[var(--error)]">invalid</span>}
{mp.snapshots.length > 1 && (
<span className="block text-[var(--text-secondary)]">{nameOf(marketplaceId)}</span>
)}
{item.description && (
<span className="block text-[var(--text-secondary)] truncate">{item.description}</span>
)}
</button>
</li>
);
})}
{rows.length === 0 && mp.snapshots.length > 0 && (
<li className="text-xs text-[var(--text-secondary)]">No items match.</li>
)}
</ul>
</section>
<section className="flex-1 min-w-0 p-4 overflow-auto">
{selected ? (
<ItemDetail mp={mp} item={selected.item} marketplaceId={selected.marketplaceId} />
) : (
<p className="text-xs text-[var(--text-secondary)]">Select an item to see what it contains and install it.</p>
)}
</section>
{adding && (
<AddMarketplaceModal
onClose={() => setAdding(false)}
onAdded={() => {
void mp.reloadState();
void mp.load();
}}
/>
)}
</div>
);
}
The item buttons include the description in their accessible name; the test's /code-reviewer/ regexes match it. The selected item's CatalogItem is a copy from the snapshot at selection time; after a refresh, re-selecting shows the new data (acceptable, the install controls read install state live from the store).
- Step 6: Run to verify they pass
Run: cd app && npx vitest run src/components/marketplace
Expected: PASS (InstallControls, AddMarketplaceModal, BrowsePane, MarketplaceView).
- Step 7: Type-check and commit
Run: cd app && npx tsc --noEmit -p .
Expected: no errors.
cd /workspace/triple-c && git add app/src/components/marketplace && git commit -qm "Marketplace UI: browse, item detail, install controls, hook confirmation, add marketplace
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 14: Installed — install list, updates with diff, source removed, Apply now
Files:
- Replace:
app/src/components/marketplace/InstalledPane.tsx - Create:
app/src/components/marketplace/UpdateDiffModal.tsx - Test:
app/src/components/marketplace/InstalledPane.test.tsx,UpdateDiffModal.test.tsx
Interfaces:
- Consumes:
MarketplaceApi(updates,snapshots,update,uninstall,forget), wrappersmarketplaceItemDiff,applyMarketplaceNow,effectiveInstalls/itemRefKey/formatItemRef/KIND_LABELS(Task 12), storeappSettings,projects,pushToast. - Produces:
InstalledPane({ mp }),UpdateDiffModal({ update, scope, onClose, onAccept }).
Update semantics: an ItemUpdate is per item (MarketplaceItemRef + pinned + head). An item can be installed in several scopes with different pins, so the Installed tab lists one row per install (scope) and shows the badge on each row whose own commit differs from the update's head for that item. Accepting updates that one install via updateMarketplaceItem(item, scope).
- Step 1: Failing test for UpdateDiffModal
app/src/components/marketplace/UpdateDiffModal.test.tsx:
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
const marketplaceItemDiff = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
marketplaceItemDiff: (...a: unknown[]) => marketplaceItemDiff(...a),
}));
import UpdateDiffModal from "./UpdateDiffModal";
const A = "a".repeat(40);
const B = "b".repeat(40);
const item = { marketplace_id: "m1", kind: "hook" as const, key: "notify" };
describe("UpdateDiffModal", () => {
beforeEach(() => vi.clearAllMocks());
it("loads the diff from the install's pin to head and accepts", async () => {
marketplaceItemDiff.mockResolvedValue([
{ path: "notify.sh", change: "modified", unified: "-echo old\n+echo new\n" },
{ path: "icon.png", change: "added", unified: null },
]);
const onAccept = vi.fn(async () => true);
render(<UpdateDiffModal item={item} fromCommit={A} toCommit={B} scopeLabel="All projects" onClose={vi.fn()} onAccept={onAccept} />);
await waitFor(() => expect(marketplaceItemDiff).toHaveBeenCalledWith(item, A, B));
expect(screen.getByText(/\+echo new/)).toBeInTheDocument();
expect(screen.getByText("Binary file — no text diff")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Update" }));
await waitFor(() => expect(onAccept).toHaveBeenCalled());
});
it("shows a load error and keeps Update disabled", async () => {
marketplaceItemDiff.mockRejectedValue("commit not in cache");
render(<UpdateDiffModal item={item} fromCommit={A} toCommit={B} scopeLabel="p" onClose={vi.fn()} onAccept={vi.fn()} />);
expect(await screen.findByText(/commit not in cache/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Update" })).toBeDisabled();
});
});
Run: cd app && npx vitest run src/components/marketplace/UpdateDiffModal.test.tsx
Expected: FAIL — module not found.
- Step 2: Implement UpdateDiffModal
app/src/components/marketplace/UpdateDiffModal.tsx:
import { useEffect, useState } from "react";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import { marketplaceItemDiff } from "../../lib/tauri-commands";
import { formatItemRef } from "../../lib/marketplace";
import type { FileDiff, MarketplaceItemRef } from "../../lib/types";
interface Props {
item: MarketplaceItemRef;
fromCommit: string;
toCommit: string;
scopeLabel: string;
onClose: () => void;
/** Resolves true when the update was applied. */
onAccept: () => Promise<boolean>;
}
const CHANGE_LABEL: Record<FileDiff["change"], string> = {
added: "added",
removed: "removed",
modified: "modified",
};
export default function UpdateDiffModal({ item, fromCommit, toCommit, scopeLabel, onClose, onAccept }: Props) {
const [diffs, setDiffs] = useState<FileDiff[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
marketplaceItemDiff(item, fromCommit, toCommit)
.then((d) => {
if (!cancelled) setDiffs(d);
})
.catch((e) => {
if (!cancelled) setError(typeof e === "string" ? e : String(e));
});
return () => {
cancelled = true;
};
}, [item, fromCommit, toCommit]);
const accept = async () => {
setBusy(true);
try {
if (await onAccept()) onClose();
} finally {
setBusy(false);
}
};
return (
<Modal
title={`Update ${formatItemRef(item)}`}
description={`${scopeLabel}: ${fromCommit.slice(0, 8)} → ${toCommit.slice(0, 8)}. Review the changes before accepting.`}
widthClassName="w-[52rem]"
dismissible={!busy}
onClose={onClose}
footer={
<>
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button size="md" variant="primary" onClick={() => void accept()} disabled={busy || diffs === null}>
Update
</Button>
</>
}
>
{error && <p role="alert" className="text-xs text-[var(--error)]">{error}</p>}
{!error && diffs === null && <p className="text-xs text-[var(--text-secondary)]">Loading changes…</p>}
{diffs && diffs.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No file changes (only the catalog entry changed).</p>
)}
{diffs && diffs.length > 0 && (
<div className="space-y-3 max-h-[60vh] overflow-auto">
{diffs.map((d) => (
<div key={d.path}>
<p className="text-xs font-mono mb-1">
{d.path} <span className="text-[var(--text-secondary)]">({CHANGE_LABEL[d.change]})</span>
</p>
{d.unified === null ? (
<p className="text-xs text-[var(--text-secondary)]">Binary file — no text diff</p>
) : (
<pre className="p-2 text-xs font-mono whitespace-pre overflow-auto rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
{d.unified}
</pre>
)}
</div>
))}
</div>
)}
</Modal>
);
}
Run: cd app && npx vitest run src/components/marketplace/UpdateDiffModal.test.tsx
Expected: PASS.
- Step 3: Failing test for InstalledPane
app/src/components/marketplace/InstalledPane.test.tsx:
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { useAppState } from "../../store/appState";
import type { AppSettings, Project } from "../../lib/types";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
const applyMarketplaceNow = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
applyMarketplaceNow: (id?: string) => applyMarketplaceNow(id),
}));
vi.mock("./UpdateDiffModal", () => ({
default: ({ onAccept }: { onAccept: () => Promise<boolean> }) => (
<button onClick={() => void onAccept()}>accept diff</button>
),
}));
import InstalledPane from "./InstalledPane";
const A = "a".repeat(40);
const B = "b".repeat(40);
function api(patch: Partial<MarketplaceApi> = {}): MarketplaceApi {
return {
snapshots: [],
updates: [],
loading: false,
refreshing: [],
load: vi.fn(),
refresh: vi.fn(),
reloadState: vi.fn(),
install: vi.fn(),
uninstall: vi.fn(async () => true),
setDisabled: vi.fn(),
update: vi.fn(async () => true),
forget: vi.fn(async () => true),
remove: vi.fn(),
...patch,
};
}
describe("InstalledPane", () => {
beforeEach(() => {
vi.clearAllMocks();
useAppState.setState({
toasts: [],
appSettings: {
marketplaces: [{ id: "m1", name: "Starter", url: "https://x/y.git", branch: null, account_id: null }],
marketplace_accounts: [],
global_marketplace_installs: [
{ marketplace_id: "m1", kind: "agent", key: "rev", commit: A },
{ marketplace_id: "gone", kind: "skill", key: "old", commit: A },
],
} as unknown as AppSettings,
projects: [
{
id: "p1",
name: "api",
status: "running",
marketplace_installs: [{ marketplace_id: "m1", kind: "command", key: "cmd", commit: B }],
marketplace_disabled: [],
},
] as unknown as Project[],
});
});
it("lists global and project installs", () => {
render(<InstalledPane mp={api()} />);
const global = screen.getByTestId("installed-global");
expect(within(global).getByText("rev")).toBeInTheDocument();
const proj = screen.getByTestId("installed-project-p1");
expect(within(proj).getByText("cmd")).toBeInTheDocument();
});
it("badges and accepts an update for the matching install", async () => {
const mp = api({
updates: [{ item: { marketplace_id: "m1", kind: "agent", key: "rev" }, pinned: A, head: B }],
});
render(<InstalledPane mp={mp} />);
fireEvent.click(screen.getByRole("button", { name: "Review update for rev" }));
fireEvent.click(screen.getByRole("button", { name: "accept diff" }));
await waitFor(() =>
expect(mp.update).toHaveBeenCalledWith({ marketplace_id: "m1", kind: "agent", key: "rev" }, { type: "global" }),
);
});
it("marks installs whose marketplace was removed and forgets them", () => {
const mp = api();
render(<InstalledPane mp={mp} />);
expect(screen.getByText("Source removed")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Forget installs from removed marketplaces" }));
expect(mp.forget).toHaveBeenCalledWith("gone");
});
it("removes a project install", () => {
const mp = api();
render(<InstalledPane mp={mp} />);
fireEvent.click(screen.getByRole("button", { name: "Remove cmd from api" }));
expect(mp.uninstall).toHaveBeenCalledWith(
{ marketplace_id: "m1", kind: "command", key: "cmd" },
{ type: "project", project_id: "p1" },
);
});
it("applies now and summarises the result", async () => {
applyMarketplaceNow.mockResolvedValue([
{ project_id: "p1", report: { installed: ["agent:rev"], updated: [], removed: [], skipped: [], errors: [], finished_at: "" } },
]);
render(<InstalledPane mp={api()} />);
fireEvent.click(screen.getByRole("button", { name: "Apply now" }));
await waitFor(() => expect(applyMarketplaceNow).toHaveBeenCalledWith(undefined));
await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "success" }));
expect(useAppState.getState().toasts[0].message).toContain("1 running project");
});
});
Run: cd app && npx vitest run src/components/marketplace/InstalledPane.test.tsx
Expected: FAIL — stub pane has no lists.
- Step 4: Implement InstalledPane
app/src/components/marketplace/InstalledPane.tsx (replaces the Task 12 stub):
import { useState } from "react";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
import { useAppState } from "../../store/appState";
import { KIND_LABELS } from "../../lib/marketplace";
import { applyMarketplaceNow } from "../../lib/tauri-commands";
import type { InstallScope, ItemUpdate, MarketplaceInstall } from "../../lib/types";
import Button from "../ui/Button";
import UpdateDiffModal from "./UpdateDiffModal";
interface Pending {
install: MarketplaceInstall;
update: ItemUpdate;
scope: InstallScope;
scopeLabel: string;
}
export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
const appSettings = useAppState((s) => s.appSettings);
const projects = useAppState((s) => s.projects);
const pushToast = useAppState((s) => s.pushToast);
const [pending, setPending] = useState<Pending | null>(null);
const [applying, setApplying] = useState(false);
const marketplaces = appSettings?.marketplaces ?? [];
const known = new Set(marketplaces.map((m) => m.id));
const nameOf = (id: string) => marketplaces.find((m) => m.id === id)?.name ?? id;
const globalInstalls = appSettings?.global_marketplace_installs ?? [];
const updateFor = (i: MarketplaceInstall) =>
mp.updates.find(
(u) =>
u.item.marketplace_id === i.marketplace_id &&
u.item.kind === i.kind &&
u.item.key === i.key &&
u.head !== i.commit,
);
const removedSources = [
...new Set(
[...globalInstalls, ...projects.flatMap((p) => p.marketplace_installs)]
.map((i) => i.marketplace_id)
.filter((id) => !known.has(id)),
),
];
const applyNow = async () => {
setApplying(true);
try {
const results = await applyMarketplaceNow(undefined);
const failed = results.filter((r) => r.report.errors.length > 0);
if (results.length === 0) {
pushToast({ kind: "info", message: "No running projects — changes apply when a project starts." });
} else if (failed.length === 0) {
pushToast({
kind: "success",
message: `Marketplace applied to ${results.length} running project${results.length === 1 ? "" : "s"}. New Claude sessions will use it.`,
});
} else {
pushToast({
kind: "error",
message: `Marketplace sync failed for ${failed.length} of ${results.length} running projects`,
detail: failed.flatMap((r) => r.report.errors).join("\n"),
});
}
} catch (e) {
pushToast({ kind: "error", message: "Could not apply marketplace changes", detail: String(e) });
} finally {
setApplying(false);
}
};
const row = (i: MarketplaceInstall, scope: InstallScope, scopeLabel: string, removeLabel: string) => {
const upd = updateFor(i);
const gone = !known.has(i.marketplace_id);
return (
<li key={`${i.marketplace_id}/${i.kind}/${i.key}`} className="flex items-center justify-between gap-2 text-xs py-1">
<div className="min-w-0">
<span className="font-medium">{i.key}</span>
<span className="ml-1 text-[var(--text-secondary)]">
{KIND_LABELS[i.kind].replace(/s$/, "").toLowerCase()} · {nameOf(i.marketplace_id)} · {i.commit.slice(0, 8)}
</span>
{gone && <span className="ml-2 text-[var(--warning)]">Source removed</span>}
</div>
<div className="flex gap-1 flex-shrink-0">
{upd && !gone && (
<Button
size="sm"
variant="secondary"
aria-label={`Review update for ${i.key}`}
onClick={() => setPending({ install: i, update: upd, scope, scopeLabel })}
>
Update available
</Button>
)}
<Button size="sm" variant="ghost" aria-label={removeLabel} onClick={() => void mp.uninstall(i, scope)}>
Remove
</Button>
</div>
</li>
);
};
return (
<div className="p-4 space-y-4 max-w-4xl">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-[var(--text-secondary)]">
Installs are pinned to a commit. Containers pick up changes on their next start, or now for running ones.
Changes apply to new Claude sessions.
</p>
<Button size="md" variant="primary" disabled={applying} onClick={() => void applyNow()}>
{applying ? "Applying…" : "Apply now"}
</Button>
</div>
{removedSources.length > 0 && (
<div className="rounded-[var(--radius-control)] border border-[var(--warning)]/40 bg-[var(--warning-muted)] p-2 text-xs space-y-1">
<p>
Some installs come from marketplaces that were removed. They are removed from containers at their next
sync.
</p>
<Button
size="sm"
variant="secondary"
aria-label="Forget installs from removed marketplaces"
onClick={() => removedSources.forEach((id) => void mp.forget(id))}
>
Forget
</Button>
</div>
)}
<section data-testid="installed-global">
<h3 className="text-xs font-medium mb-1">All projects</h3>
{globalInstalls.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">Nothing installed for all projects.</p>
) : (
<ul>{globalInstalls.map((i) => row(i, { type: "global" }, "All projects", `Remove ${i.key} from all projects`))}</ul>
)}
</section>
{projects.map((p) => (
<section key={p.id} data-testid={`installed-project-${p.id}`}>
<h3 className="text-xs font-medium mb-1">{p.name}</h3>
{p.marketplace_installs.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">
No project-only installs
{p.marketplace_disabled.length > 0 ? ` · opted out of ${p.marketplace_disabled.length} global item(s)` : ""}.
</p>
) : (
<ul>
{p.marketplace_installs.map((i) =>
row(i, { type: "project", project_id: p.id }, p.name, `Remove ${i.key} from ${p.name}`),
)}
</ul>
)}
</section>
))}
{pending && (
<UpdateDiffModal
item={pending.update.item}
fromCommit={pending.install.commit}
toCommit={pending.update.head}
scopeLabel={pending.scopeLabel}
onClose={() => setPending(null)}
onAccept={() => mp.update(pending.update.item, pending.scope)}
/>
)}
</div>
);
}
Run: cd app && npx vitest run src/components/marketplace/InstalledPane.test.tsx
Expected: PASS.
- Step 5: Type-check and commit
Run: cd app && npx tsc --noEmit -p . && npx vitest run src/components/marketplace
Expected: no type errors; all marketplace tests pass.
cd /workspace/triple-c && git add app/src/components/marketplace && git commit -qm "Marketplace UI: installed list, update diff review, apply now
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 15: Accounts — list, test, remove, add (gh on host, gh in container, token)
Files:
- Replace:
app/src/components/marketplace/AccountsPane.tsx - Create:
app/src/components/marketplace/AddAccountModal.tsx - Create:
app/src/components/marketplace/GhContainerLoginModal.tsx - Test:
app/src/components/marketplace/AccountsPane.test.tsx,AddAccountModal.test.tsx,GhContainerLoginModal.test.tsx
Interfaces:
- Consumes: wrappers
testMarketplaceAccount,removeMarketplaceAccount,addMarketplaceTokenAccount,addMarketplaceGhHostAccount,marketplaceGhHostAvailable,startMarketplaceGhContainerLogin,cancelMarketplaceGhLogin,openUrlExternal; eventsmarketplace-gh-login-code{ account_id, code, url },marketplace-gh-login-output{ account_id, chunk }; storeappSettings,setAppSettings,projects,pushToast;useSettings().loadSettings. - Produces:
AccountsPane({ mp }),AddAccountModal({ onClose }),GhContainerLoginModal({ label, host, projectId, projectName, onClose, onDone }).
Event filtering: startMarketplaceGhContainerLogin resolves only when the login finishes, so the modal cannot know the new account's id while it runs. It therefore accepts every marketplace-gh-login-* event while it is open. This is safe because the backend allows one gh login at a time (MarketplaceManager::set_gh_login_cancel refuses a second).
The token input is a password field; the value is sent once to addMarketplaceTokenAccount and then cleared from component state. It is never logged or shown again.
- Step 1: Failing test for GhContainerLoginModal
app/src/components/marketplace/GhContainerLoginModal.test.tsx:
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
const startMarketplaceGhContainerLogin = vi.fn();
const cancelMarketplaceGhLogin = vi.fn();
const openUrlExternal = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
startMarketplaceGhContainerLogin: (...a: unknown[]) => startMarketplaceGhContainerLogin(...a),
cancelMarketplaceGhLogin: () => cancelMarketplaceGhLogin(),
openUrlExternal: (u: string) => openUrlExternal(u),
}));
const handlers = new Map<string, (e: { payload: unknown }) => void>();
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (name: string, cb: (e: { payload: unknown }) => void) => {
handlers.set(name, cb);
return vi.fn();
}),
}));
import GhContainerLoginModal from "./GhContainerLoginModal";
describe("GhContainerLoginModal", () => {
beforeEach(() => {
vi.clearAllMocks();
handlers.clear();
});
it("shows the device code, opens the URL, and finishes", async () => {
let resolve!: (v: unknown) => void;
startMarketplaceGhContainerLogin.mockReturnValue(new Promise((r) => (resolve = r)));
const onDone = vi.fn();
render(
<GhContainerLoginModal label="Work" host="github.com" projectId="p1" projectName="api" onClose={vi.fn()} onDone={onDone} />,
);
await waitFor(() => expect(handlers.has("marketplace-gh-login-code")).toBe(true));
await waitFor(() => expect(startMarketplaceGhContainerLogin).toHaveBeenCalledWith("Work", "github.com", "p1"));
act(() =>
handlers.get("marketplace-gh-login-code")!({
payload: { account_id: "unknown-yet", code: "ABCD-1234", url: "https://github.com/login/device" },
}),
);
expect(screen.getByText("ABCD-1234")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open GitHub" }));
expect(openUrlExternal).toHaveBeenCalledWith("https://github.com/login/device");
await act(async () => resolve({ id: "acc9", label: "Work", host: "github.com", method: "gh_container", username: "me" }));
await waitFor(() => expect(onDone).toHaveBeenCalled());
});
it("refuses to open a non-GitHub URL from the container", async () => {
startMarketplaceGhContainerLogin.mockReturnValue(new Promise(() => {}));
render(<GhContainerLoginModal label="W" host="github.com" projectId="p1" projectName="api" onClose={vi.fn()} onDone={vi.fn()} />);
await waitFor(() => expect(handlers.has("marketplace-gh-login-code")).toBe(true));
act(() =>
handlers.get("marketplace-gh-login-code")!({
payload: { account_id: "x", code: "ABCD-1234", url: "https://evil.example/login" },
}),
);
expect(screen.queryByRole("button", { name: "Open GitHub" })).not.toBeInTheDocument();
});
it("cancels", async () => {
startMarketplaceGhContainerLogin.mockReturnValue(new Promise(() => {}));
const onClose = vi.fn();
render(<GhContainerLoginModal label="W" host="github.com" projectId="p1" projectName="api" onClose={onClose} onDone={vi.fn()} />);
fireEvent.click(await screen.findByRole("button", { name: "Cancel sign-in" }));
expect(cancelMarketplaceGhLogin).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
});
Run: cd app && npx vitest run src/components/marketplace/GhContainerLoginModal.test.tsx
Expected: FAIL — module not found.
- Step 2: Implement GhContainerLoginModal
app/src/components/marketplace/GhContainerLoginModal.tsx:
import { useEffect, useRef, useState } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import StatusIndicator from "../ui/StatusIndicator";
import {
cancelMarketplaceGhLogin,
openUrlExternal,
startMarketplaceGhContainerLogin,
} from "../../lib/tauri-commands";
import type { MarketplaceAccount } from "../../lib/types";
interface Props {
label: string;
host: string;
projectId: string;
projectName: string;
onClose: () => void;
onDone: (account: MarketplaceAccount) => void;
}
interface CodeEvent {
account_id: string;
code: string;
url: string;
}
interface OutputEvent {
account_id: string;
chunk: string;
}
const MAX_OUTPUT = 8000;
/** Only open device-login pages on the host being signed in to. */
function safeDeviceUrl(url: string, host: string): string | null {
try {
const u = new URL(url);
return u.protocol === "https:" && u.hostname === host ? u.toString() : null;
} catch {
return null;
}
}
/**
* Drives `gh auth login --web` inside a running container. The command only
* resolves when the login finishes, so the new account's id is unknown while it
* runs; the modal accepts every gh-login event while open. The backend allows
* one gh login at a time, so there is never another flow's event to confuse.
*/
export default function GhContainerLoginModal({ label, host, projectId, projectName, onClose, onDone }: Props) {
const [code, setCode] = useState<string | null>(null);
const [url, setUrl] = useState<string | null>(null);
const [output, setOutput] = useState("");
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(true);
const started = useRef(false);
useEffect(() => {
let cancelled = false;
const unlisteners: UnlistenFn[] = [];
const register = async <T,>(name: string, handle: (p: T) => void) => {
const un = await listen<T>(name, (e) => handle(e.payload));
if (cancelled) un();
else unlisteners.push(un);
};
void (async () => {
await register<CodeEvent>("marketplace-gh-login-code", (p) => {
setCode(p.code);
setUrl(p.url);
});
await register<OutputEvent>("marketplace-gh-login-output", (p) =>
setOutput((prev) => {
const next = prev + p.chunk;
return next.length > MAX_OUTPUT ? next.slice(next.length - MAX_OUTPUT) : next;
}),
);
if (cancelled || started.current) return;
started.current = true;
try {
const account = await startMarketplaceGhContainerLogin(label, host, projectId);
if (!cancelled) {
setRunning(false);
onDone(account);
}
} catch (e) {
if (!cancelled) {
setRunning(false);
setError(typeof e === "string" ? e : String(e));
}
}
})();
return () => {
cancelled = true;
for (const un of unlisteners) {
try {
un();
} catch {
/* already gone */
}
}
};
// Runs once per modal instance; the props do not change while it is open.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const cancel = () => {
void cancelMarketplaceGhLogin();
onClose();
};
const openable = url ? safeDeviceUrl(url, host) : null;
return (
<Modal
title={`Sign in to ${host} with gh`}
description={`Running gh auth login in “${projectName}”. The sign-in is not kept in that container.`}
widthClassName="w-[40rem]"
dismissible={!running}
onClose={running ? cancel : onClose}
footer={
running ? (
<Button size="md" variant="ghost" onClick={cancel}>
Cancel sign-in
</Button>
) : (
<Button size="md" onClick={onClose}>
Close
</Button>
)
}
>
<div className="space-y-3">
{running && !code && <StatusIndicator tone="busy" label="Starting gh…" className="text-xs" />}
{code && running && (
<div className="space-y-2">
<p className="text-xs">Enter this code on the GitHub device page:</p>
<p className="font-mono text-lg tracking-widest select-all">{code}</p>
{openable ? (
<Button size="md" variant="primary" onClick={() => void openUrlExternal(openable)}>
Open GitHub
</Button>
) : (
url && <p className="text-xs text-[var(--error)]">The sign-in URL did not point at {host}; not opening it.</p>
)}
</div>
)}
{error && <p role="alert" className="text-xs text-[var(--error)] whitespace-pre-wrap">{error}</p>}
{output && (
<pre className="max-h-40 overflow-auto p-2 text-[11px] font-mono whitespace-pre-wrap rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
{output}
</pre>
)}
</div>
</Modal>
);
}
Run: cd app && npx vitest run src/components/marketplace/GhContainerLoginModal.test.tsx
Expected: PASS.
- Step 3: Failing test for AddAccountModal
app/src/components/marketplace/AddAccountModal.test.tsx:
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useAppState } from "../../store/appState";
import type { Project } from "../../lib/types";
const marketplaceGhHostAvailable = vi.fn();
const addMarketplaceGhHostAccount = vi.fn();
const addMarketplaceTokenAccount = vi.fn();
const getSettings = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
marketplaceGhHostAvailable: () => marketplaceGhHostAvailable(),
addMarketplaceGhHostAccount: (...a: unknown[]) => addMarketplaceGhHostAccount(...a),
addMarketplaceTokenAccount: (...a: unknown[]) => addMarketplaceTokenAccount(...a),
getSettings: () => getSettings(),
}));
vi.mock("./GhContainerLoginModal", () => ({
default: ({ projectId }: { projectId: string }) => <div>container login for {projectId}</div>,
}));
import AddAccountModal from "./AddAccountModal";
const running = { id: "p1", name: "api", status: "running", container_id: "c1" } as unknown as Project;
describe("AddAccountModal", () => {
beforeEach(() => {
vi.clearAllMocks();
getSettings.mockResolvedValue({ marketplace_accounts: [] });
useAppState.setState({ projects: [running], toasts: [] });
});
it("uses host gh when available", async () => {
marketplaceGhHostAvailable.mockResolvedValue(true);
addMarketplaceGhHostAccount.mockResolvedValue({ id: "a1" });
const onClose = vi.fn();
render(<AddAccountModal onClose={onClose} />);
expect(await screen.findByText(/gh is installed on this computer/)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "Personal" } });
fireEvent.click(screen.getByRole("button", { name: "Add account" }));
await waitFor(() => expect(addMarketplaceGhHostAccount).toHaveBeenCalledWith("Personal", "github.com"));
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("falls back to gh in a running container", async () => {
marketplaceGhHostAvailable.mockResolvedValue(false);
render(<AddAccountModal onClose={vi.fn()} />);
expect(await screen.findByLabelText("Run gh in")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "Work" } });
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect(screen.getByText("container login for p1")).toBeInTheDocument();
});
it("adds a token account for any host", async () => {
marketplaceGhHostAvailable.mockResolvedValue(false);
addMarketplaceTokenAccount.mockResolvedValue({ id: "a2" });
render(<AddAccountModal onClose={vi.fn()} />);
fireEvent.click(await screen.findByRole("radio", { name: "Access token" }));
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "Gitea" } });
fireEvent.change(screen.getByLabelText("Host"), { target: { value: "repo.anhonesthost.net" } });
fireEvent.change(screen.getByLabelText("Token"), { target: { value: "test-token-not-real" } });
fireEvent.click(screen.getByRole("button", { name: "Add account" }));
await waitFor(() =>
expect(addMarketplaceTokenAccount).toHaveBeenCalledWith("Gitea", "repo.anhonesthost.net", "test-token-not-real"),
);
});
it("shows a validation error from the backend", async () => {
marketplaceGhHostAvailable.mockResolvedValue(false);
addMarketplaceTokenAccount.mockRejectedValue("The token was rejected by repo.anhonesthost.net (HTTP 401)");
render(<AddAccountModal onClose={vi.fn()} />);
fireEvent.click(await screen.findByRole("radio", { name: "Access token" }));
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "G" } });
fireEvent.change(screen.getByLabelText("Host"), { target: { value: "repo.anhonesthost.net" } });
fireEvent.change(screen.getByLabelText("Token"), { target: { value: "test-token-not-real" } });
fireEvent.click(screen.getByRole("button", { name: "Add account" }));
expect(await screen.findByText(/HTTP 401/)).toBeInTheDocument();
});
});
(SegmentedControl segments are role="radio".)
Run: cd app && npx vitest run src/components/marketplace/AddAccountModal.test.tsx
Expected: FAIL — module not found.
- Step 4: Implement AddAccountModal
app/src/components/marketplace/AddAccountModal.tsx:
import { useEffect, useState } from "react";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import SegmentedControl from "../ui/SegmentedControl";
import Field, { inputClass, selectClass } from "../ui/Field";
import {
addMarketplaceGhHostAccount,
addMarketplaceTokenAccount,
getSettings,
marketplaceGhHostAvailable,
} from "../../lib/tauri-commands";
import { useAppState } from "../../store/appState";
import GhContainerLoginModal from "./GhContainerLoginModal";
type Method = "gh" | "token";
interface Props {
onClose: () => void;
}
export default function AddAccountModal({ onClose }: Props) {
const projects = useAppState((s) => s.projects);
const setAppSettings = useAppState((s) => s.setAppSettings);
const runnable = projects.filter((p) => p.status === "running" && p.container_id);
const [method, setMethod] = useState<Method>("gh");
const [hostGh, setHostGh] = useState<boolean | null>(null);
const [label, setLabel] = useState("");
const [host, setHost] = useState("github.com");
const [token, setToken] = useState("");
const [projectId, setProjectId] = useState(runnable[0]?.id ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [containerLogin, setContainerLogin] = useState(false);
useEffect(() => {
let cancelled = false;
marketplaceGhHostAvailable()
.then((v) => {
if (!cancelled) setHostGh(v);
})
.catch(() => {
if (!cancelled) setHostGh(false);
});
return () => {
cancelled = true;
};
}, []);
const reloadSettings = async () => setAppSettings(await getSettings());
const finish = async () => {
await reloadSettings();
onClose();
};
const submit = async () => {
setError(null);
if (method === "gh" && !hostGh) {
setContainerLogin(true);
return;
}
setBusy(true);
try {
if (method === "gh") {
await addMarketplaceGhHostAccount(label.trim(), host.trim());
} else {
const t = token.trim();
setToken("");
await addMarketplaceTokenAccount(label.trim(), host.trim(), t);
}
await finish();
} catch (e) {
setError(typeof e === "string" ? e : String(e));
} finally {
setBusy(false);
}
};
const hostValid = /^[A-Za-z0-9.-]+(:[0-9]+)?$/.test(host.trim());
const needsContainer = method === "gh" && hostGh === false;
const canSubmit =
!busy &&
hostGh !== null &&
label.trim() !== "" &&
hostValid &&
(method === "gh" ? !needsContainer || projectId !== "" : token.trim() !== "");
if (containerLogin) {
const project = runnable.find((p) => p.id === projectId);
return (
<GhContainerLoginModal
label={label.trim()}
host={host.trim()}
projectId={projectId}
projectName={project?.name ?? projectId}
onClose={onClose}
onDone={() => void finish()}
/>
);
}
return (
<Modal
title="Add account"
description="Accounts let Triple-C read private marketplace repositories. Credentials stay on this computer and never enter containers."
widthClassName="w-[36rem]"
dismissible={!busy}
onClose={onClose}
footer={
<>
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button size="md" variant="primary" onClick={() => void submit()} disabled={!canSubmit}>
{needsContainer ? "Sign in" : busy ? "Checking…" : "Add account"}
</Button>
</>
}
>
<div className="space-y-3">
<SegmentedControl<Method>
label="Sign-in method"
value={method}
onChange={(m) => {
setMethod(m);
setError(null);
}}
segments={[
{ value: "gh", label: "GitHub via gh" },
{ value: "token", label: "Access token" },
]}
/>
<Field label="Label">
{(id) => (
<input id={id} value={label} onChange={(e) => setLabel(e.target.value)} className={inputClass} placeholder="Work GitHub" />
)}
</Field>
<Field label="Host" hint={hostValid ? undefined : "Host name only, e.g. github.com or repo.example.com"}>
{(id) => <input id={id} value={host} onChange={(e) => setHost(e.target.value)} className={inputClass} />}
</Field>
{method === "gh" && hostGh === true && (
<p className="text-xs text-[var(--text-secondary)]">
gh is installed on this computer. Triple-C asks it for a token each time it fetches, so signing out of gh
also signs this account out. If gh is not logged in yet, run <code className="font-mono">gh auth login</code> first.
</p>
)}
{needsContainer &&
(runnable.length === 0 ? (
<p className="text-xs text-[var(--warning)]">
gh is not installed on this computer. Start a project so gh can run in its container, or use an access token.
</p>
) : (
<Field
label="Run gh in"
hint="gh is not installed on this computer, so the sign-in runs in this container. The token is kept in your OS keychain, not in the container."
>
{(id) => (
<select id={id} value={projectId} onChange={(e) => setProjectId(e.target.value)} className={selectClass}>
{runnable.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
)}
</Field>
))}
{method === "token" && (
<Field
label="Token"
hint="A personal access token with read access to the repository. For GitHub SSO orgs, authorise the token for the org."
>
{(id) => (
<input
id={id}
type="password"
autoComplete="off"
value={token}
onChange={(e) => setToken(e.target.value)}
className={inputClass}
/>
)}
</Field>
)}
{error && <p role="alert" className="text-xs text-[var(--error)] whitespace-pre-wrap">{error}</p>}
</div>
</Modal>
);
}
Run: cd app && npx vitest run src/components/marketplace/AddAccountModal.test.tsx
Expected: PASS.
- Step 5: Failing test for AccountsPane
app/src/components/marketplace/AccountsPane.test.tsx:
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useAppState } from "../../store/appState";
import type { AppSettings } from "../../lib/types";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
const testMarketplaceAccount = vi.fn();
const removeMarketplaceAccount = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
testMarketplaceAccount: (id: string) => testMarketplaceAccount(id),
removeMarketplaceAccount: (id: string) => removeMarketplaceAccount(id),
}));
vi.mock("./AddAccountModal", () => ({ default: () => <div>add account modal</div> }));
import AccountsPane from "./AccountsPane";
const settings = {
marketplace_accounts: [
{ id: "a1", label: "Personal", host: "github.com", method: "gh_host", username: "me" },
{ id: "a2", label: "Gitea", host: "repo.example.com", method: "token", username: "jk" },
],
marketplaces: [{ id: "m1", name: "Team", url: "https://repo.example.com/t/m.git", branch: null, account_id: "a2" }],
global_marketplace_installs: [],
} as unknown as AppSettings;
describe("AccountsPane", () => {
beforeEach(() => {
vi.clearAllMocks();
useAppState.setState({ appSettings: settings, toasts: [] });
});
it("lists accounts with their method and usage", () => {
render(<AccountsPane mp={{} as MarketplaceApi} />);
expect(screen.getByText("Personal")).toBeInTheDocument();
expect(screen.getByText(/gh on this computer/)).toBeInTheDocument();
expect(screen.getByText(/Used by Team/)).toBeInTheDocument();
});
it("tests an account", async () => {
testMarketplaceAccount.mockResolvedValue("me");
render(<AccountsPane mp={{} as MarketplaceApi} />);
fireEvent.click(screen.getByRole("button", { name: "Test Personal" }));
await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "success" }));
expect(useAppState.getState().toasts[0].message).toContain("me");
});
it("confirms before removing an account in use, then removes", async () => {
removeMarketplaceAccount.mockResolvedValue({ ...settings, marketplace_accounts: [settings.marketplace_accounts[0]] });
render(<AccountsPane mp={{} as MarketplaceApi} />);
fireEvent.click(screen.getByRole("button", { name: "Remove Gitea" }));
expect(screen.getByText(/Team will be fetched without credentials/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Remove account" }));
await waitFor(() => expect(removeMarketplaceAccount).toHaveBeenCalledWith("a2"));
await waitFor(() => expect(useAppState.getState().appSettings!.marketplace_accounts).toHaveLength(1));
});
it("opens the add dialog", () => {
render(<AccountsPane mp={{} as MarketplaceApi} />);
fireEvent.click(screen.getByRole("button", { name: "Add account" }));
expect(screen.getByText("add account modal")).toBeInTheDocument();
});
});
Run: cd app && npx vitest run src/components/marketplace/AccountsPane.test.tsx
Expected: FAIL — stub pane has no list.
- Step 6: Implement AccountsPane
app/src/components/marketplace/AccountsPane.tsx (replaces the Task 12 stub):
import { useState } from "react";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
import { useAppState } from "../../store/appState";
import { removeMarketplaceAccount, testMarketplaceAccount } from "../../lib/tauri-commands";
import type { AccountMethod, MarketplaceAccount } from "../../lib/types";
import Button from "../ui/Button";
import Modal from "../ui/Modal";
import AddAccountModal from "./AddAccountModal";
const METHOD_LABEL: Record<AccountMethod, string> = {
gh_host: "GitHub — gh on this computer",
gh_container: "GitHub — signed in via container",
token: "Access token",
};
export default function AccountsPane(_props: { mp: MarketplaceApi }) {
const appSettings = useAppState((s) => s.appSettings);
const setAppSettings = useAppState((s) => s.setAppSettings);
const pushToast = useAppState((s) => s.pushToast);
const [adding, setAdding] = useState(false);
const [confirmRemove, setConfirmRemove] = useState<MarketplaceAccount | null>(null);
const [testing, setTesting] = useState<string | null>(null);
const accounts = appSettings?.marketplace_accounts ?? [];
const marketplaces = appSettings?.marketplaces ?? [];
const usedBy = (id: string) => marketplaces.filter((m) => m.account_id === id).map((m) => m.name);
const test = async (a: MarketplaceAccount) => {
setTesting(a.id);
try {
const login = await testMarketplaceAccount(a.id);
pushToast({ kind: "success", message: `${a.label} works — signed in as ${login}` });
} catch (e) {
pushToast({ kind: "error", message: `${a.label} could not sign in`, detail: String(e) });
} finally {
setTesting(null);
}
};
const remove = async (a: MarketplaceAccount) => {
setConfirmRemove(null);
try {
setAppSettings(await removeMarketplaceAccount(a.id));
} catch (e) {
pushToast({ kind: "error", message: `Could not remove ${a.label}`, detail: String(e) });
}
};
return (
<div className="p-4 space-y-3 max-w-3xl">
<div className="flex items-center justify-between">
<p className="text-xs text-[var(--text-secondary)]">
Accounts are used to fetch private marketplaces. Tokens are kept in your OS keychain and never enter
containers.
</p>
<Button size="md" variant="secondary" onClick={() => setAdding(true)}>
Add account
</Button>
</div>
{accounts.length === 0 && <p className="text-xs text-[var(--text-secondary)]">No accounts yet. Public repositories need none.</p>}
<ul className="space-y-2">
{accounts.map((a) => {
const users = usedBy(a.id);
return (
<li
key={a.id}
className="flex items-center justify-between gap-2 p-2 rounded-[var(--radius-control)] border border-[var(--border-color)]"
>
<div className="min-w-0 text-xs">
<p className="font-medium">{a.label}</p>
<p className="text-[var(--text-secondary)]">
{METHOD_LABEL[a.method]} · {a.host}
{a.username ? ` · ${a.username}` : ""}
</p>
{users.length > 0 && <p className="text-[var(--text-secondary)]">Used by {users.join(", ")}</p>}
</div>
<div className="flex gap-1 flex-shrink-0">
<Button
size="sm"
variant="ghost"
aria-label={`Test ${a.label}`}
disabled={testing === a.id}
onClick={() => void test(a)}
>
{testing === a.id ? "Testing…" : "Test"}
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`Remove ${a.label}`}
onClick={() => (users.length > 0 ? setConfirmRemove(a) : void remove(a))}
>
Remove
</Button>
</div>
</li>
);
})}
</ul>
{adding && <AddAccountModal onClose={() => setAdding(false)} />}
{confirmRemove && (
<Modal
title={`Remove ${confirmRemove.label}?`}
onClose={() => setConfirmRemove(null)}
footer={
<>
<Button size="md" variant="ghost" onClick={() => setConfirmRemove(null)}>
Cancel
</Button>
<Button size="md" variant="danger" onClick={() => void remove(confirmRemove)}>
Remove account
</Button>
</>
}
>
<p className="text-xs">
{usedBy(confirmRemove.id).join(", ")} will be fetched without credentials, which fails for private
repositories. Installed items keep syncing from the cached copy.
</p>
</Modal>
)}
</div>
);
}
- Step 7: Run and commit
Run: cd app && npx vitest run src/components/marketplace && npx tsc --noEmit -p .
Expected: PASS, no type errors.
cd /workspace/triple-c && git add app/src/components/marketplace && git commit -qm "Marketplace UI: accounts — gh on host, gh in a container, access tokens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 16: Project Home → Config → Marketplace section
Files:
- Create:
app/src/components/projects/home/config/MarketplaceSection.tsx - Create:
app/src/components/projects/home/config/MarketplaceSection.test.tsx - Modify:
app/src/components/projects/home/ConfigTab.tsx(import + render afterRuntimeSection)
Interfaces:
- Consumes:
effectiveInstalls,KIND_LABELS,formatItemRef(Task 12); wrapperssetGlobalItemDisabled,getMarketplaceSyncReport; storeappSettings,openMarketplace,updateProjectInList,pushToast;ConfigGroupfromui/Field,Toggle. - Produces:
MarketplaceSection({ project })default export.
Opting out is allowed while the container runs (it only changes what the next sync installs), so this section is not disabled by STOPPED_ONLY. It saves through setGlobalItemDisabled (which returns the updated Project), not through save(), because update_project is the stopped-only path.
- Step 1: Failing test
app/src/components/projects/home/config/MarketplaceSection.test.tsx:
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { useAppState, MARKETPLACE_TAB_KEY } from "../../../../store/appState";
import type { AppSettings, Project } from "../../../../lib/types";
const setGlobalItemDisabled = vi.fn();
const getMarketplaceSyncReport = vi.fn();
vi.mock("../../../../lib/tauri-commands", () => ({
setGlobalItemDisabled: (...a: unknown[]) => setGlobalItemDisabled(...a),
getMarketplaceSyncReport: (id: string) => getMarketplaceSyncReport(id),
}));
import MarketplaceSection from "./MarketplaceSection";
const A = "a".repeat(40);
const project = {
id: "p1",
name: "api",
status: "running",
marketplace_installs: [{ marketplace_id: "m1", kind: "command", key: "cmd", commit: A }],
marketplace_disabled: [{ marketplace_id: "m1", kind: "hook", key: "noisy" }],
} as unknown as Project;
describe("MarketplaceSection", () => {
beforeEach(() => {
vi.clearAllMocks();
useAppState.setState({
tabOrder: [],
activeTabKey: null,
projects: [project],
toasts: [],
appSettings: {
marketplaces: [{ id: "m1", name: "Starter", url: "https://x/y.git", branch: null, account_id: null }],
marketplace_accounts: [],
global_marketplace_installs: [
{ marketplace_id: "m1", kind: "agent", key: "rev", commit: A },
{ marketplace_id: "m1", kind: "hook", key: "noisy", commit: A },
],
} as unknown as AppSettings,
});
getMarketplaceSyncReport.mockResolvedValue({
installed: ["agent:rev"],
updated: [],
removed: [],
skipped: [{ item: "command:cmd", reason: "a file you created has the same name" }],
errors: [],
finished_at: "2026-09-27T12:00:00Z",
});
});
it("shows effective items with their source and the opted-out global item", async () => {
render(<MarketplaceSection project={project} />);
const rev = screen.getByTestId("mp-global-agent-rev");
expect(within(rev).getByRole("switch")).toBeChecked();
const noisy = screen.getByTestId("mp-global-hook-noisy");
expect(within(noisy).getByRole("switch")).not.toBeChecked();
expect(screen.getByTestId("mp-project-command-cmd")).toHaveTextContent("This project only");
expect(await screen.findByText(/a file you created has the same name/)).toBeInTheDocument();
});
it("opts out of a global item", async () => {
setGlobalItemDisabled.mockResolvedValue({ ...project, marketplace_disabled: [] });
render(<MarketplaceSection project={project} />);
fireEvent.click(within(screen.getByTestId("mp-global-agent-rev")).getByRole("switch"));
await waitFor(() =>
expect(setGlobalItemDisabled).toHaveBeenCalledWith("p1", { marketplace_id: "m1", kind: "agent", key: "rev" }, true),
);
});
it("opens the Marketplace filtered to this project", () => {
render(<MarketplaceSection project={project} />);
fireEvent.click(screen.getByRole("button", { name: "Open in Marketplace" }));
expect(useAppState.getState().activeTabKey).toBe(MARKETPLACE_TAB_KEY);
expect(useAppState.getState().marketplaceFilterProjectId).toBe("p1");
});
});
(Toggle is role="switch" with aria-checked, so toBeChecked() works on it.)
Run: cd app && npx vitest run src/components/projects/home/config/MarketplaceSection.test.tsx
Expected: FAIL — module not found.
- Step 2: Implement MarketplaceSection
app/src/components/projects/home/config/MarketplaceSection.tsx:
import { useEffect, useState } from "react";
import { ConfigGroup } from "../../../ui/Field";
import Toggle from "../../../ui/Toggle";
import Button from "../../../ui/Button";
import { useAppState } from "../../../../store/appState";
import { KIND_LABELS } from "../../../../lib/marketplace";
import { getMarketplaceSyncReport, setGlobalItemDisabled } from "../../../../lib/tauri-commands";
import type { MarketplaceItemRef, Project, SyncReport } from "../../../../lib/types";
interface Props {
project: Project;
}
const kindWord = (k: MarketplaceItemRef["kind"]) => KIND_LABELS[k].replace(/s$/, "").toLowerCase();
const same = (a: MarketplaceItemRef, b: MarketplaceItemRef) =>
a.marketplace_id === b.marketplace_id && a.kind === b.kind && a.key === b.key;
export default function MarketplaceSection({ project }: Props) {
const appSettings = useAppState((s) => s.appSettings);
const openMarketplace = useAppState((s) => s.openMarketplace);
const updateProjectInList = useAppState((s) => s.updateProjectInList);
const pushToast = useAppState((s) => s.pushToast);
const [report, setReport] = useState<SyncReport | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const globalInstalls = appSettings?.global_marketplace_installs ?? [];
const nameOf = (id: string) => appSettings?.marketplaces.find((m) => m.id === id)?.name ?? "removed marketplace";
useEffect(() => {
let cancelled = false;
getMarketplaceSyncReport(project.id)
.then((r) => {
if (!cancelled) setReport(r);
})
.catch(() => {
if (!cancelled) setReport(null);
});
return () => {
cancelled = true;
};
}, [project.id, project.status]);
const toggleGlobal = async (ref: MarketplaceItemRef, enabled: boolean) => {
const id = `${ref.kind}-${ref.key}`;
setBusy(id);
try {
updateProjectInList(await setGlobalItemDisabled(project.id, ref, !enabled));
} catch (e) {
pushToast({ kind: "error", message: `Could not change ${ref.key} for “${project.name}”`, detail: String(e) });
} finally {
setBusy(null);
}
};
return (
<ConfigGroup
title="Marketplace"
description="Items this project gets from marketplaces. Changes apply on the next container start or with Apply now, in new Claude sessions."
>
<div className="space-y-3">
{globalInstalls.length > 0 && (
<div>
<p className="text-xs font-medium mb-1">From “All projects”</p>
<ul className="space-y-1">
{globalInstalls.map((g) => {
const shadowed = project.marketplace_installs.some((p) => same(p, g));
const enabled = !project.marketplace_disabled.some((d) => same(d, g));
return (
<li
key={`${g.marketplace_id}/${g.kind}/${g.key}`}
data-testid={`mp-global-${g.kind}-${g.key}`}
className="flex items-center justify-between gap-2 text-xs"
>
<span className="min-w-0 truncate">
<span className="font-medium">{g.key}</span>{" "}
<span className="text-[var(--text-secondary)]">
{kindWord(g.kind)} · {nameOf(g.marketplace_id)}
{shadowed ? " · overridden by this project's own install" : ""}
</span>
</span>
<Toggle
label={`Use ${g.key} in ${project.name}`}
checked={enabled}
disabled={busy === `${g.kind}-${g.key}`}
onChange={(v) => void toggleGlobal({ marketplace_id: g.marketplace_id, kind: g.kind, key: g.key }, v)}
/>
</li>
);
})}
</ul>
</div>
)}
{project.marketplace_installs.length > 0 && (
<div>
<p className="text-xs font-medium mb-1">This project only</p>
<ul className="space-y-1">
{project.marketplace_installs.map((i) => (
<li
key={`${i.marketplace_id}/${i.kind}/${i.key}`}
data-testid={`mp-project-${i.kind}-${i.key}`}
className="text-xs"
>
<span className="font-medium">{i.key}</span>{" "}
<span className="text-[var(--text-secondary)]">
{kindWord(i.kind)} · {nameOf(i.marketplace_id)} · This project only
</span>
</li>
))}
</ul>
</div>
)}
{globalInstalls.length === 0 && project.marketplace_installs.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">Nothing installed from a marketplace.</p>
)}
{report && (
<div className="text-xs space-y-1">
<p className="font-medium">
Last sync {report.finished_at ? new Date(report.finished_at).toLocaleString() : ""}
</p>
<p className="text-[var(--text-secondary)]">
{report.installed.length} installed · {report.updated.length} updated · {report.removed.length} removed
</p>
{report.skipped.map((s) => (
<p key={s.item} className="text-[var(--warning)]">
Skipped {s.item}: {s.reason}
</p>
))}
{report.errors.map((e) => (
<p key={e} className="text-[var(--error)] whitespace-pre-wrap">
{e}
</p>
))}
</div>
)}
<Button size="sm" variant="secondary" onClick={() => openMarketplace(project.id)}>
Open in Marketplace
</Button>
</div>
</ConfigGroup>
);
}
updateProjectInList: (project: Project) => void (store/appState.ts:121) replaces the project in the store.
In app/src/components/projects/home/ConfigTab.tsx add import MarketplaceSection from "./config/MarketplaceSection"; and, after the <RuntimeSection … /> element:
<MarketplaceSection project={project} />
- Step 3: Run and commit
Run: cd app && npx vitest run src/components/projects/home && npx tsc --noEmit -p .
Expected: PASS, no type errors.
cd /workspace/triple-c && git add app/src/components/projects/home && git commit -qm "Project Config: Marketplace section with per-project opt-out and last sync report
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 17: Docs, full verification, end-to-end check, PR
Files:
- Modify:
CLAUDE.md(new### Marketplacesubsection under Key Conventions, after the new-window capability rule, around line 646) - Modify:
HOW-TO-USE.md(new## Marketplacesection; place it after the section that covers Claude authentication / settings — find withgrep -n '^## ' HOW-TO-USE.md)
Interfaces:
-
Consumes: everything from Tasks 1–16.
-
Produces: an open Gitea PR from
feat/marketplaceintomain. -
Step 1: CLAUDE.md subsection
Add:
### Marketplace
- Code: models in `models/marketplace.rs`; host-side logic in `src/marketplace/` (`git.rs` gix cache + pins, `catalog.rs` repo format, `auth.rs` credentials, `gh_login.rs`, `payload.rs`, `sync.rs`); commands in `commands/marketplace_commands.rs`; UI in `components/marketplace/` and `projects/home/config/MarketplaceSection.tsx`. Spec: `docs/superpowers/specs/2026-09-27-marketplace-design.md`.
- **Tokens never enter containers.** Marketplaces are fetched on the host into `<data_dir>/triple-c/marketplaces/<id>.git`; containers only ever receive a tar of pinned files. Do not add a code path that passes a marketplace credential into an exec, env var, label or file in a container.
- **Sync model:** after every container start (next to `sync_bedrock_credentials`) and on "Apply now", the host builds the project's effective set (`global − disabled ∪ project`), uploads it, and runs the constant script `/usr/local/bin/triple-c-marketplace-sync` (source `container/marketplace-sync.sh`) as `claude`. The script only removes files and hook entries it recorded in `~/.claude/triple-c/marketplace/state.json`; it must never overwrite or delete user-created agents/skills/commands or user hooks. A sync failure must not fail the container start.
- Installs are **pinned** to a commit; nothing updates without the user accepting a diff. Pinned commits are kept alive by `refs/triple-c/pins/*` in the cache.
- Marketplace changes need no container labels or recreation — they are applied by the sync, not at create time.
- Step 2: HOW-TO-USE.md section
## Marketplace
The marketplace installs Claude Code **agents, skills, commands, hooks and plugins** from git repositories into your containers.
1. **Settings → Marketplace → Open Marketplace** opens the Marketplace tab.
2. **Add a marketplace**: on the Browse tab choose *Add marketplace* and enter an HTTPS clone URL, for example `https://github.com/shadowdao/triple-c-marketplace.git`. For a private repository, pick an account (see below). Triple-C checks it can read the repository before saving.
3. **Install**: select an item to see what it contains. Turn on **All projects** to install it everywhere (including projects you add later), or tick individual projects. A project can opt out of an "All projects" item by unticking it, or from **Project → Config → Marketplace**.
4. **Hooks** run shell commands, so Triple-C shows every command before installing one.
5. **When it applies**: on the container's next start, or straight away for running containers with **Installed → Apply now**. New Claude sessions pick it up; sessions already open keep what they loaded.
**Updates.** Every install is pinned to the commit it came from. When an item changes in its repository, the Installed tab shows *Update available*. Review the diff and accept to move the pin.
**Accounts (private repositories).** On the Accounts tab:
- *GitHub via gh* — if the GitHub CLI is installed and logged in on this computer, Triple-C uses it. If not, it runs `gh auth login` inside a running project's container and keeps only the resulting token in your OS keychain.
- *Access token* — any host (GitHub, Gitea, GitLab). The token is stored in your OS keychain.
Credentials never enter containers. If a private repository in a GitHub organisation cannot be read, the error explains the usual causes: the org has not approved the GitHub CLI, the token is not authorised for the org's SSO, or a fine-grained token belongs to a different owner.
**If an item is skipped**: Triple-C never overwrites an agent, skill or command file you created yourself. If one has the same name as a marketplace item, the sync skips it and the project's Config → Marketplace section says so.
- Step 3: Full automated verification
Run each and record the result:
cd /workspace/triple-c/app && npx vitest run
Expected: all test files pass (previous count 75 files / 978 tests plus the new marketplace tests), including src/test/capabilities.test.ts.
cd /workspace/triple-c/app && npm run build
Expected: TypeScript and Vite build succeed.
cd /workspace/triple-c/app/src-tauri && cargo test --lib
Expected: all tests pass (previously 677 passed; now more). The sync-script tests skip only where jq is missing.
cd /workspace/triple-c/app/src-tauri && cargo clippy --lib 2>&1 | grep -E "src/(marketplace|models/marketplace|commands/marketplace_commands)" -A6
Expected: no output (no clippy warnings in new files). Fix any that appear.
cd /workspace/triple-c && for f in $(git diff --name-only main... -- 'app/src-tauri/src/**/*.rs'); do rustfmt --edition 2021 --check "$f" >/dev/null 2>&1 || echo "needs fmt: $f"; done
Expected: no needs fmt lines for files created by this branch (pre-existing files may already be unformatted on main; only fix what this branch added).
- Step 4: Commit docs
cd /workspace/triple-c && git add CLAUDE.md HOW-TO-USE.md && git commit -qm "Docs: marketplace
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
- Step 5: Push and open the PR
cd /workspace/triple-c && git push -q -u origin feat/marketplace
Build the body with python3 and post it with $TEA_TOKEN (never echo the token):
cd /workspace/triple-c && S=/tmp/claude-1000/-workspace/f70b4bb0-c929-434b-af28-62cc3705211a/scratchpad && python3 - <<'EOF' > $S/pr-marketplace.json
import json
body = """## Summary
Adds a Triple-C **marketplace**: git repositories of agents, skills, commands, hooks and plugins that can be installed for all projects or per project.
- Settings → Marketplace section and a full-width Marketplace tab (Browse / Installed / Accounts).
- Hybrid repo format: `agents/`, `skills/`, `commands/`, `hooks/` managed by Triple-C; `plugins/` is a standard Claude Code marketplace installed with `claude plugin`.
- Host-side fetch with `gix` into a bare cache; installs pinned to commits, per-item update detection with a diff review.
- Named accounts: GitHub via host `gh`, GitHub via `gh` in a container (token kept in the keychain, not the container), or an access token for any host. Tokens never enter containers.
- Sync after every container start and on Apply now: constant script in the image, idempotent, never touches user-created files or hooks; failures never block a start.
- Project Home → Config → Marketplace: effective items, per-project opt-out, last sync report.
- Also: the shared-auth button row in Settings now wraps (Check snapshot images no longer overflows).
Spec: `docs/superpowers/specs/2026-09-27-marketplace-design.md` · Plan: `docs/superpowers/plans/2026-09-27-marketplace.md`
Starter marketplace: https://github.com/shadowdao/triple-c-marketplace
## Testing
- `npx vitest run`, `npm run build`, `cargo test --lib` all pass (see CI).
- Manual end-to-end on the preview build: see checklist below.
## Manual end-to-end checklist
- [ ] Add `https://github.com/shadowdao/triple-c-marketplace.git` (no account); all five items listed.
- [ ] Install each kind for All projects; start a project; each appears in a new Claude session (`/agents`, skills, `/example-command`, Stop hook rings, `/plugin` lists example-plugin).
- [ ] Install an item for one project only; a second project does not get it.
- [ ] Opt a project out of a global item from Config → Marketplace; Apply now; it is removed there only.
- [ ] Push a change to the starter repo; Refresh; only that item shows Update available; diff shows the change; accept; Apply now.
- [ ] Create `~/.claude/agents/code-reviewer.md` by hand in a container; sync skips it with a conflict; the file is unchanged.
- [ ] Add a user hook to `~/.claude/settings.json`; install/uninstall the marketplace hook; the user hook is untouched.
- [ ] Hook install shows the confirm dialog with the rendered command.
- [ ] Private repo via an access token; private repo via gh (host, and via container on a machine without gh).
- [ ] Offline refresh keeps the cached items and shows the error; container start still succeeds.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
"""
print(json.dumps({"title": "Marketplace for agents, skills, commands, hooks and plugins", "head": "feat/marketplace", "base": "main", "body": body}))
EOF
curl -s -X POST -H "Authorization: token $TEA_TOKEN" -H "Content-Type: application/json" \
--data @$S/pr-marketplace.json \
https://repo.anhonesthost.net/api/v1/repos/CyberCoveLLC/Triple-C/pulls \
| python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('number'), d.get('html_url'), d.get('message'))"
Expected: a PR number and URL, message None.
- Step 6: Wait for CI and run the manual checklist
Check CI on the PR head (the preview build must be green before the manual run):
cd /workspace/triple-c && SHA=$(git rev-parse HEAD) && curl -s -H "Authorization: token $TEA_TOKEN" \
https://repo.anhonesthost.net/api/v1/repos/CyberCoveLLC/Triple-C/commits/$SHA/status \
| python3 -c "import json,sys;d=json.load(sys.stdin);print(d['state']);[print(' ',s['context'],s['status']) for s in d['statuses']]"
Expected: success once all jobs finish (pending while running). Then hand the manual checklist in the PR body to the user for the preview build; tick items as they are confirmed. Do not merge until the user has run it and asked for the merge.