Spec: Triple-C marketplace design
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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`).
|
||||
Reference in New Issue
Block a user