Spec: Triple-C marketplace design

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 07:50:22 -07:00
co-authored by Claude Opus 5.5
parent ece0d74afb
commit a6b00e0873
@@ -0,0 +1,293 @@
# Triple-C marketplace — design
Date: 2026-09-27
Status: approved in conversation (user), section by section; awaiting review of this written spec
## Goal
Users add one or more **marketplace** git repos in Settings, browse the agents, skills, commands,
hooks and plugins they contain, and install any item either **for all projects** or **for
individual projects**. Private repos are supported through named sign-in accounts. Installed items
are pinned to a commit and only change when the user accepts an update.
A public starter marketplace, `shadowdao/triple-c-marketplace` (local clone at
`/workspace/projects/triple-c-marketplace`, its own git repo), is created with one example of each
item type. It is also where marketplace items built later will be published.
Non-goals (this version): per-marketplace auto-update, a registered Triple-C GitHub/OAuth app,
publishing to a marketplace from inside Triple-C, a separate OS window for the marketplace.
## Decisions (user-approved)
| Topic | Decision |
|---|---|
| Repo format | **Hybrid**: Triple-C-managed `agents/`, `skills/`, `commands/`, `hooks/`; `plugins/` is a standard Claude Code marketplace installed with `claude plugin` |
| Auth | **Named accounts**, one chosen per marketplace. GitHub sign-in reuses `gh` (host first, else inside a running container); any host accepts a pasted token. No Triple-C OAuth app |
| Scope | **Global list + per-project additions + per-project opt-out** of global items; global items reach projects created later |
| Updates | **Pinned on install**; "update available" per item; the user reviews a diff and accepts |
| Fetching | **On the host**, into an app cache, with `gix`; files copied into containers. Tokens never enter containers |
| Where the UI lives | Settings sidebar section + a full-width **Marketplace** main-area tab (like Project Home), not an OS window |
## Current state (verified against `292fc90`)
- Settings is `components/settings/SettingsPanel.tsx`, rendered in the sidebar, sections as
`AccordionSection`s. Main-area tabs are Zustand-driven (`store/appState.ts` `tabOrder` /
`activeTabKey`; keys `home:<projectId>` and session ids), rendered by `App.tsx`, strip in
`layout/MainTabs.tsx`. No router.
- Global settings: `models/app_settings.rs` `AppSettings` → `<data_dir>/triple-c/settings.json`.
Per project: `models/project.rs` `Project` → `projects.json`. TS mirror in `lib/types.ts`,
wrappers in `lib/tauri-commands.ts`. Settings export/import in `models/settings_export.rs`.
- Keychain: `storage/secure.rs`; global single-value entries use `read_entry`/`delete_entry`
(shared Claude token, gateway keys). Project secrets are restricted to `PROJECT_SECRET_KEYS`.
- Container start: `commands/project_commands.rs` `start_project_container` runs
`docker::sync_bedrock_credentials` after start (≈:1448) — the pattern the marketplace sync follows.
- Exec/upload: `docker/exec.rs` `upload_bytes_to_container(container_id, dest_dir, file_name, data)`,
`exec_oneshot_streams_as(container_id, user, cmd, env)`, `create_attached_exec_as(…, tty, user)`.
Container user is addressed as `"claude"`. Constant-script + env-data rule: header of
`commands/inspect_commands.rs`.
- `container/entrypoint.sh` merges `CLAUDE_CODE_SETTINGS_JSON` into `~/.claude/settings.json`
(≈:408-447), then runs `claude update` under `flock /tmp/.triple-c-claude-update.lock`
(≈:649) and prints `Triple-C container ready.` (≈:654). Nothing marks readiness on disk today.
- `commands/inspect_commands.rs` `list_container_capabilities` already inventories agents, skills,
commands, hooks and plugins in a container (read-only); `CapabilityTiles.tsx` shows it.
- The container image has `gh`, `git`, `jq`. Claude Code 2.1.283 supports
`claude plugin marketplace add <path>` / `update` / `remove` and
`claude plugin install|uninstall <plugin>@<marketplace>`.
- No host-side git or GitHub auth exists today (no `git2`/`gix`; `reqwest` with rustls is present).
`gix` 0.88 is current on crates.io.
## 1. Marketplace repo format
```
<repo>/
├── README.md
├── agents/<name>.md # Claude Code agent file (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 # standard Claude Code marketplace catalog
└── <plugin>/… # standard Claude Code plugins
```
Every folder is optional; a repo with only `plugins/` is valid.
- **Agent** — any `agents/*.md`. Name/description from YAML front matter (`name` falls back to the
file stem). Installs to `~/.claude/agents/<file>`.
- **Skill** — any `skills/<dir>/` containing `SKILL.md`; name/description from its front matter.
Installs to `~/.claude/skills/<dir>/`.
- **Command** — any `commands/*.md`; description from front matter `description` if present,
otherwise the first non-empty line. Installs to `~/.claude/commands/<file>`.
- **Hook** — `hooks/<dir>/hook.json`:
```json
{ "name": "notify-on-stop",
"description": "Desktop ping when Claude finishes",
"hooks": { "Stop": [{ "hooks": [{ "type": "command",
"command": "${HOOK_DIR}/notify.sh" }] }] } }
```
`hooks` is Claude Code's `settings.json` hooks object, verbatim. `${HOOK_DIR}` is substituted
with the install folder `~/.claude/triple-c/hooks/<dir>` (absolute path). The whole folder is
copied; files keep their executable bit from the git tree mode.
- **Plugin** — entries of `plugins/.claude-plugin/marketplace.json`; each `source` must be a
relative path inside `plugins/` (remote sources are listed as invalid: they would fetch from
inside the container, bypassing pinning and host-side auth).
Item identity: `(marketplace_id, kind, key)` where `key` is the file stem / folder name / plugin
name. Keys must match `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`.
Invalid items (bad front matter, unparsable `hook.json`, unknown hook event, key failing the
pattern, plugin source missing or outside `plugins/`, symlinks anywhere in an item) are listed with
the reason and cannot be installed; they never stop the rest of the marketplace from loading.
Limits (defensive, per item): 2 MiB total, 200 files.
## 2. Data model and storage
`AppSettings` (all `#[serde(default)]`):
```rust
pub marketplace_accounts: Vec<MarketplaceAccount>,
pub marketplaces: Vec<Marketplace>,
pub global_marketplace_installs: Vec<MarketplaceInstall>,
pub struct MarketplaceAccount {
pub id: String, // uuid
pub label: String, // user-facing, e.g. "Work GitHub"
pub host: String, // e.g. "github.com", "repo.anhonesthost.net"
pub method: AccountMethod, // GhHost | GhContainer | Token
pub username: Option<String>, // resolved at sign-in, display only
}
pub struct Marketplace {
pub id: String, // uuid
pub name: String, // display; slug used in container paths
pub url: String, // https URL only
pub branch: Option<String>,// None = remote default branch
pub account_id: Option<String>, // None = anonymous
}
pub struct MarketplaceInstall {
pub marketplace_id: String,
pub kind: ItemKind, // Agent | Skill | Command | Hook | Plugin
pub key: String,
pub commit: String, // full hex object id
}
```
`Project` (all `#[serde(default)]`):
```rust
pub marketplace_installs: Vec<MarketplaceInstall>, // project-only additions
pub marketplace_disabled: Vec<MarketplaceItemRef>, // (marketplace_id, kind, key) opted out
```
**Secrets.** Token and GhContainer accounts keep their token in the keychain as
`marketplace-account:<id>` (new global helpers in `secure.rs` alongside the gateway ones).
GhHost accounts store nothing: every fetch runs `gh auth token --hostname <host>` so a later
`gh auth refresh`/logout on the host is honoured. Deleting an account deletes its entry.
**Effective set for a project** (pure function, unit-tested):
`(global − project.marketplace_disabled) ∪ project.marketplace_installs`, keyed by
`(marketplace_id, kind, key)`; on a key clash the project's entry (and pin) wins.
**Cache.** `<data_dir>/triple-c/marketplaces/<id>.git` — a bare `gix` clone. Content is read
from git objects at each install's pinned commit, never from a worktree, so different pins of the
same repo coexist. Pinned commits are protected from pruning by writing a ref per pin
(`refs/triple-c/pins/<commit>`), refreshed after every install/update/remove.
Removing a marketplace deletes its cache and its account link; installs that still reference it
stay in the lists and are shown as **source removed**. With no cache there is nothing to copy, so
the next sync removes those items from containers; the UI says so before the marketplace is
removed and offers "Forget" to drop the stale entries.
**Export/import.** Accounts (without secrets), marketplaces and install lists go into the existing
export; account tokens follow the existing encrypted-secrets policy of `settings_export.rs`.
## 3. Fetching and signing in
**Fetch** (`marketplace/git.rs`): `gix` over HTTPS only (reject other URL schemes at add time).
Credentials are supplied through gix's credential callback, never written to disk: username
`x-access-token` for github.com, otherwise the account's `username` (falling back to `oauth2`),
password = token. Shallow fetch is not used (pins need history for diff/ancestry).
- **Add marketplace** = test fetch of the chosen branch; failures surface immediately.
- **Refresh**: when the Marketplace tab opens and the last fetch is > 15 min old, on the Refresh
button, and once at app start (background, errors logged not toasted).
- **Offline / fetch error**: the last cache stays usable; UI shows "last fetched <time>" and the error.
**Update detection** compares each installed item's own tree (item folder / file blob id) at
its pin vs. the branch head; only a changed item shows "update available".
**Update** shows a text diff of the item's files (pinned → head) and, on accept, moves the pin.
Hooks' diffs always show the rendered commands.
**Accounts** (`marketplace/auth.rs`):
- **GitHub via `gh` on host**: detect `gh` on PATH; `gh auth status --hostname <host>`; if not
logged in, tell the user to run `gh auth login` (we do not drive the host's gh interactively).
`gh api user --jq .login` for the display name.
- **GitHub via `gh` in a container** (no host `gh`): user picks a running project; Triple-C runs
`gh auth login --hostname <host> --web --git-protocol https --scopes repo` in an attached pty
exec with `GH_CONFIG_DIR=$(mktemp -d)`, surfaces the one-time code and URL in a dialog (same
shape as `ClaudeAuthModal`), then runs `gh auth token` with the same config dir, stores the
token in the keychain and `rm -rf`s the dir. Cancel tears the exec down. Nothing persists in the
container, so Claude in that container is not logged into the user's GitHub.
- **Token**: pasted once, validated via the host's "who am I" API
(GitHub `GET /user`, Gitea `GET /api/v1/user`, GitLab `GET /api/v4/user`; unknown host → test
`ls-remote`-equivalent fetch), stored in the keychain. The token is never returned to the frontend.
**Access errors** (401/403/404 on fetch): message names the account used and lists the likely
org causes — org has not approved the GitHub CLI / third-party app restrictions, token not
SSO-authorised for the org, fine-grained token owned by a different owner — with the GitHub
settings link for each.
## 4. Installing into containers
**When.** (a) After every container start, in `start_project_container` next to
`sync_bedrock_credentials`; (b) on **Apply now** for running containers after the lists change.
The sync is idempotent; no container labels or recreation are involved. A container reset wipes
the volumes and the next start re-syncs.
**Readiness.** `entrypoint.sh` writes `/tmp/.triple-c-ready` just before printing
`Triple-C container ready.`. The sync polls for it (up to 180 s) so it never races the
entrypoint's settings.json merge or `claude update`. Plugin commands additionally run under
`flock /tmp/.triple-c-claude-update.lock`.
**Payload.** The host builds one tar of the effective set, read from the cache at each pin:
```
agents/<file> skills/<dir>/… commands/<file> hooks/<dir>/…
plugins/.claude-plugin/marketplace.json # generated: only selected plugins
plugins/<plugin>/… # each at its own pin
manifest.json # effective set: kind, key, marketplace, commit, hook JSON
```
uploaded to `~/.claude/triple-c/marketplace/incoming/` and then applied by a **constant** script
(`container/marketplace-sync.sh`, baked into the image; data only via env and files) run as
`claude`. Results come back as JSON on stdout.
**Script behaviour.** State lives in `~/.claude/triple-c/marketplace/state.json` (what Triple-C
installed last time, including the exact hook entries it inserted).
- Agents / skills / commands: copy into place; delete the ones in state but no longer in the
manifest. A destination that exists but is **not** in state is the user's own file → skip and
report a conflict, never overwrite.
- Hooks: copy folder to `~/.claude/triple-c/hooks/<dir>/`; with `jq`, remove from
`~/.claude/settings.json` exactly the entries recorded in state, then append the new rendered
entries and record them. User-authored hook entries are never touched.
- Plugins: marketplace name `triple-c-<slug>`; copy the generated tree to
`~/.claude/triple-c/plugins/<slug>/`; `claude plugin marketplace add` it the first time, else
`claude plugin marketplace update triple-c-<slug>`; `install` newly selected, `uninstall`
removed; drop the marketplace registration when it has no plugins left.
- Emits a report: `{installed, updated, removed, skipped: [{item, reason}], errors: [...]}`.
**Failure handling.** A sync failure never fails the container start; it is logged, stored as the
project's last sync report, and toasted. UI copy states that changes apply to **new** Claude
sessions.
## 5. UI
**Settings sidebar → Marketplace section** (new `AccordionSection`): counts (marketplaces, global
installs, updates available) and **Open Marketplace**, which opens/focuses the singleton main-area
tab `marketplace`.
**Marketplace tab** (`components/marketplace/`), sub-tabs:
- **Browse** — marketplace list (Add, account, last fetched, Refresh) on the left; kind filter
(Agents / Skills / Commands / Hooks / Plugins) and search; item detail pane with the content
preview (agent/command/skill markdown, hook commands, plugin component list) and install
controls: an **All projects** switch plus a per-project checkbox list showing each project's
state (inherited, opted out, project-only, pinned to a different commit). Installing a hook
requires a confirm step listing every command it will run.
- **Installed** — every install (global and per project) with update badges; **Update** opens
the diff; **Apply now** syncs running containers.
- **Accounts** — add GitHub (`gh`) or token account, test, remove.
**Project Home → Config → Marketplace section**: the project's effective set with source
(global / project), switches to opt out of global items, last sync report, and a link that opens
the Marketplace tab filtered to this project.
All new commands follow the three-step new-command convention and are main-window only
(`capabilities/default.json`); no new window, so no AppManifest/`expected_windows` changes.
## Testing
- **Rust unit**: repo parsing (valid + each invalid case per kind), effective-set merge,
per-item change detection, `${HOOK_DIR}` rendering, tar building, pin refs, `gix` fetch against a
local fixture repo created in the test, credential selection per host/method, error mapping for
401/403/404.
- **Sync script**: driven from a Rust test with a temp `HOME` and a stub `claude` on `PATH`:
install, update, removal, conflict with a user-owned agent, user hooks left intact, plugin
add/update/uninstall calls. Skipped when `jq` is unavailable.
- **Vitest**: Marketplace tab (browse/filter/install controls/states), Config section opt-out,
account dialogs, hook confirm step.
- **End to end** on a preview build against `shadowdao/triple-c-marketplace`.
## Starter marketplace repo
`/workspace/projects/triple-c-marketplace` → public `github.com/shadowdao/triple-c-marketplace`:
README documenting the format above; `agents/code-reviewer.md`; `skills/example-skill/SKILL.md`;
`commands/example-command.md`; `hooks/notify-on-stop/` (hook.json + script); `plugins/` with a
catalog and one single-skill plugin. Also used as the end-to-end fixture.
## Also in this branch
`SharedAuthSettings.tsx`: the action button row wraps (`flex-wrap`) so "Check snapshot images"
stays inside the sidebar (commit `ece0d74`).