2026-09-27 07:50:22 -07:00
# 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.
2026-09-27 09:54:24 -07:00
- Exec/upload: `docker/exec.rs` `upload_bytes_to_container(container_id, dest_dir, file_name, data, mode)` ,
2026-09-27 07:50:22 -07:00
`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`
2026-09-27 08:13:59 -07:00
(≈:408-447), runs `claude update` under `flock /tmp/.triple-c-claude-update.lock` (≈:649),
prints `Triple-C container ready.` and execs `su -s /bin/bash claude -c "exec sleep infinity"`
(≈:654-655). Nothing marks readiness on disk; that final process is the observable signal
(verified on a live container, where `jq` , `flock` and `tar` are also present).
2026-09-27 07:50:22 -07:00
- `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 {
2026-09-27 10:14:30 -07:00
pub id: String, // uuid; slug "mp-<id8>" used in container paths
pub name: String, // display only (renaming never changes the slug)
2026-09-27 07:50:22 -07:00
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
` ``
2026-09-27 09:54:24 -07:00
**Secrets.** Token and GhContainer accounts keep their token in the keychain, one service per
account (` triple-c-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.
2026-09-27 07:50:22 -07:00
**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
2026-09-27 09:54:24 -07:00
export as ordinary ` AppSettings` fields; account tokens follow the existing encrypted-secrets
policy of ` settings_export.rs` (` ExportedSecrets::marketplace_account_tokens`, keyed by account
id, restored to the keychain before the settings replace). Because the import is untrusted input
and not merely a restore, imported marketplaces and installs are validated on import the same way
the add-marketplace/install commands validate them (host, key pattern, pinned-commit shape), and
the confirmation preview warns whenever the import contains a global hook or global plugin install
— those skip the hook-confirm step an install from the Marketplace tab shows.
2026-09-27 07:50:22 -07:00
## 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.
2026-09-27 10:11:19 -07:00
Hooks' diffs always show the rendered commands. Install and update both carry the commit the user
reviewed (the head the item was read at, the head of the accepted diff); the backend pins exactly
that commit and refuses with "changed since you reviewed this item — review it again" if the
marketplace's head has moved since.
2026-09-27 07:50:22 -07:00
**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
2026-09-27 09:54:24 -07:00
` gh auth login --hostname <host> --web --git-protocol ssh --skip-ssh-key --scopes repo` in an
attached pty exec, with ` GH_CONFIG_DIR` and ` GIT_CONFIG_GLOBAL` both pointed at a temp dir
(` $(mktemp -d)`) — ` --git-protocol ssh --skip-ssh-key` avoids gh's "Authenticate Git with your
GitHub credentials?" prompt, which under ` https` would otherwise write a credential helper into
` ~/.gitconfig`. It 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.
2026-09-27 07:50:22 -07:00
- **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.
2026-09-27 08:13:59 -07:00
**Readiness.** The entrypoint's last act is ` exec su -s /bin/bash claude -c "exec sleep infinity"`,
so that process existing means the settings.json merge and ` claude update` are done. The sync polls
` pgrep -x -f 'su -s /bin/bash claude -c exec sleep infinity'` (up to 180 s) before touching
anything. No entrypoint change is needed, which matters: image and entrypoint changes reach an
existing project only through a base-image migration or a Reset (CLAUDE.md "` /home/claude` in the
image is seed-only" and the VPN notes), so a marker written by a new entrypoint would never appear
in existing projects. Plugin commands additionally run under ` flock /tmp/.triple-c-claude-update.lock`.
2026-09-27 07:50:22 -07:00
**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
` ``
2026-09-27 08:13:59 -07:00
uploaded to ` ~/.claude/triple-c/marketplace/incoming/` together with the sync script itself and
applied by that **constant** script (data only via env and files) run as ` claude`. The script lives
in the app (` app/src-tauri/src/marketplace/sync.sh`, embedded with ` include_str!`) and is uploaded
on every sync rather than baked into the image, for the same reason as the readiness check: every
existing project gets it immediately and it is always the version that matches the app. Results
come back as JSON on stdout.
2026-09-27 07:50:22 -07:00
**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`
2026-09-27 10:07:57 -07:00
removed; drop the marketplace registration when it has no plugins left. Plugin state is kept
per marketplace (` plugin:<slug>/<key>`), so two marketplaces may ship a plugin of the same
name; older ` plugin:<key>` records are migrated using their recorded slug.
2026-09-27 07:50:22 -07:00
- 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`).