# CLI: `shark`

One static binary for you and your agents (Linux, macOS; amd64, arm64). Install:

```sh
curl -fsSL https://dl.sharkmagic.si/install.sh | sh
```

It verifies the binary's sha256 against the release's `SHA256SUMS` before installing (details and the manual path:
[quickstart.md](quickstart.md#0-what-you-need)). `shark version [--json]` prints the build (`version`, `commit`, `os`,
`arch`); `shark update [--json]` says whether a newer release exists (`update_available`, `latest`) and changes
nothing; `shark update --install` downloads it, checks the same checksum and replaces the binary in place (error codes
`checksum_mismatch`, `update_check_failed`, `update_not_writable`). `SHARK_RELEASES_URL` points both at a mirror with
the same layout.

## Cheat sheet

```sh
shark login                                                # browser login with a one-time code
shark login --api-key -                                    # an account API key (stdin or a hidden prompt; CI: < keyfile)
shark whoami                                               # tenant (your account), credential, scopes
shark keys create --name ci > ci.key                       # an API key for CI (secret shown once)
shark org ls ; shark org use acme                          # personal workspace + teams; switch this login
shark org invite acme --email ana@example.com              # invite someone into a team (owner/admin)
shark code --repo acme/api                                 # a coding agent in the cloud on that repo (shark-code.md)
shark secrets set demo ANTHROPIC_API_KEY --preset anthropic --allow-egress   # value from a hidden prompt
shark egress add demo api.stripe.com                       # let the Space's commands reach one more host
shark quotas                                               # your limits, current use, limits class, node admission
shark quotas labels set end_user --awake 3                 # per end user: 3 Spaces awake (platforms)
shark create --name demo                                   # prints the Space id
shark run demo -- 'npm ci && npm test'                     # stream output, exit with the command's code
shark run demo --cwd web -- 'npm run build'                # run in a subdirectory
shark run demo --detach -- 'make long-job'                 # start it, print the op id, return
shark logs demo <op-id>                                    # re-attach to an op's output from anywhere
shark ops demo                                             # recent commands: op id, state, exit, shark_status
shark ps demo                                              # background jobs (bg-N); shark kill bg-1 demo stops one
shark fork demo -n 3                                       # three O(1) copies at the same commit
shark cp -r ./repo demo:repo                               # upload a directory (one commit)
shark cp demo:out/report.json .                            # download a file (no VM starts)
shark files ls demo [path]                                 # list files
shark hibernate demo ; shark wake demo                     # sleep now / wake now
shark usage --by space                                     # metered usage
shark ls ; shark rm demo                                   # list, delete
```

`<space>` is always a Space id or its name. Every verb has `--help` with examples (`shark secrets --help`), and
`shark --help` lists them all.

## Output, errors and exit codes

Made to be driven by people and by agents alike:

- **`--json`** on every verb that prints a result (`ls`, `whoami`, `create`, `rm`, `fork`, `ops`, `hibernate`, `wake`,
  `quotas`, `usage`, `balance`, `topup`, `secrets`, `egress`, `github`, `stop`, `logs <space>`, `run --detach`,
  `login`, `keys`, `org`, `logins`): stdout is then **exactly one JSON document**, the result or `{"error": {"code", "message", "hint",
  "status"}}`. Progress lines (`[shark: …]`) always go to stderr.
- **Errors** in text mode: `shark: <message>` and, when there is a next step, `  next: <exact command>` on stderr. The
  `code` is stable (`not_logged_in`, `usage`, `not_found`, `missing_key`, `credits_exhausted`, `insufficient_scope`,
  `quota_exceeded`, `out_of_capacity`, `github_not_connected`, `clone_failed`, `not_a_terminal`, `confirmation_required`,
  `harness_required`,
  `cancelled`, …; API codes as in
  [errors.md](errors.md)). Never a stack trace.
- **Exit codes**: `0` done; `1` the API refused or failed (the code says why); `2` usage error, or not configured / not
  logged in; `125` Shark could not report a result (`run`, `logs`), or the agent's session VM, its terminal channel or
the agent's start failed (`code`, `attach`: the `[shark: … (code 125) …]` line says which); otherwise the command's own exit code (`run`,
  `logs <space> <op>`, `run-agent`).
- **No prompts you cannot avoid**: a secret value comes from stdin when it is piped (a hidden prompt only on a
  terminal); `--no-browser` / `--print` where a browser would open; every confirmation has `--yes`. When stdin is not a
  terminal nothing asks: the command fails with the flag form in `next:` (`not_a_terminal`, `confirmation_required`).
  A question cancelled with q / Ctrl-C exits `130` (`cancelled`), nothing changed.
- **Colors** only when stderr is a terminal; `NO_COLOR=1` (or `TERM=dumb`) turns them off. Tables have a header only
  on a terminal, so scripts can split lines on tabs.

## Configuration

| source | keys |
|---|---|
| environment | `SHARK_API_KEY` (an account key), `SHARK_CP` (account service URL; default `https://api.sharkmagic.si`), `SHARK_API` (a self-hosted node's URL: **direct mode**, below), `SHARK_API_CA` (a PEM to trust, for private nodes) |
| `~/.shark/credentials` | written by `shark login` (mode 0600; ignored if group- or world-readable) |
| `~/.shark/config` | `KEY=VALUE` lines, same keys (mode 0600) |

The environment wins over the files. URLs must be `https://` (plain `http://` only to a loopback address, so a key
never travels in clear).

**Two modes.** With an account key or a `shark login` (the hosted service), the CLI talks to the account service:
it exchanges the key there for 5-minute node tokens and sends each call to the node that holds the Space. The key
never reaches a node. **Direct mode** (`SHARK_API` set, or `shark login --api-key - --api URL`) sends the key to that
one node as is: it is for a self-hosted node and that node's own keys. An account key in direct mode answers
`401 unauthenticated`, and the account verbs (`balance`, `keys`, `org`, …) answer `needs_control_plane`. To leave
direct mode: `unset SHARK_API` and `shark login --api-key -` again (the login rewrites `~/.shark/credentials`).

## Account

| command | what | API |
|---|---|---|
| `shark login --api-key [KEY\|-] [--cp URL]` | an **account API key** (servers, CI, agents): checked against the account service (default `https://api.sharkmagic.si`) and stored in `~/.shark/credentials` (0600). `-` (or nothing) reads stdin or a hidden prompt, so the key stays out of argv and shell history. Exchanged for node tokens, never sent to a node | `GET /v1/me`, `POST /v1/token` |
| `shark login [--cp URL] [--no-browser]` | browser login with a one-time code (RFC 8628): the CLI prints a link and a code, you confirm it on `https://api.sharkmagic.si/activate` (not signed in there: it sends you to its sign-in page, GitHub / Google / e-mail + password, and back to the code). The CLI then says `Logged in`, or exits 1 with `the request was denied in the browser` or `the code expired; run shark login again` (10 minutes). No browser at hand (a server, an agent): `shark login --api-key -` with an account key instead. Stores a refresh token (0600); every node call then carries a 5-minute token the CLI mints and refreshes itself. `--no-browser` only prints the link | `POST /v1/auth/device`, `/v1/auth/token` |
| `shark login --api-key [KEY\|-] --api URL` | **direct mode**, for a self-hosted node: that node's own key, checked against its `/v1/me` and stored with the URL. Not for account keys | `GET /v1/me` (node) |
| `shark logout` | revokes the login session (if any) and deletes the credentials and token cache | `POST /v1/auth/revoke` |
| `shark whoami [--json]` | who this CLI acts as. With the account service (login or account key), `--json` is `GET /v1/me` of the account service plus `control_plane`: `tenant` (your account), `kind` (`session` or `api_key`), `credential` (its id), `scopes`, `space` (a Space-bound key, else `null`), `expires_at`, `organization` `{id, name, slug, personal}`, `billing_mode`, `suspended`. In direct mode (a node key, `SHARK_API`): `tenant` and `credential` `{kind, id, scopes}` | `GET /v1/me` |
| `shark quotas [--json]` | every limit that applies to you with current use, your `limits_class` (`new`, `standard`, `partner`, …), `raise` (how to get more), per-label limit rows (`scope: label`), and `node_admission` (is the node admitting new work: `state` `open`/`closed`, `reason`) | `GET /v1/quotas` |
| `shark quotas labels [ls] [--json]` / `set <key\|key=value> [--awake N] [--spaces N]` / `rm <key\|key=value>` / `clear` | per-label limits for a platform's end users (`set end_user --awake 3`: each `end_user` value gets 3 awake; `end_user=acme` caps that one value). Account service only (`needs_control_plane` in direct mode); a bad value exits 2 before any call ([limits.md](limits.md#per-label-limits)) | `GET /v1/limits`, `PATCH`/`PUT /v1/limits/labels` |
| `shark balance [--json]` / `shark usage [--by day\|space\|kind] [--json]` | prepaid balance; rated usage (account service; against a node directly: the node's meter) | `GET /v1/balance`, `/v1/usage` |
| `shark topup [USD] [--print]` | opens a Stripe Checkout page for prepaid credit (default $5); the credit shows once the payment is confirmed. At zero balance commands get `402` and files stay readable | `POST /v1/checkout` |
| `shark plan [status\|subscribe [--print]\|cancel] [--json]` | the monthly plan: a usage allowance each month, spent before prepaid credit, gone at the end of each period (no roll-over); `subscribe` opens Stripe Checkout (or resumes a pending cancel); `cancel` ends it at the period end | `GET /v1/plan`, `POST /v1/plan/subscribe`, `POST /v1/plan/cancel` |
| `shark invoices [--json]` | a one-time link to the Stripe customer portal (invoices, receipts, payment methods); `409 no_billing_customer` before the first payment | `POST /v1/billing/portal` |
| `shark new [--name N]` | same as `create`; with the account service the Space goes to the node placement picks | `POST /v1/placement` |
| `shark keys ls\|create\|rotate\|rm [--json]` | API keys of the org the CLI acts in. `create --name N [--scopes "a b"] [--space SP]` prints the secret **once** (alone on stdout; `--json`: the `token` field); `rotate <id> [--overlap 1h]` issues a new key and keeps the old one for the overlap (default 1 day); `rm <id>` revokes. Needs your login as owner/admin of the org, or a key with `admin` | `GET`/`POST /v1/keys`, `POST /v1/keys/{id}/rotate`, `DELETE /v1/keys/{id}` |
| `shark org ls\|use\|create\|invite [--json]` | every account starts with a **personal workspace** (just you; nothing to set up); teams are opt-in. `ls` marks the one this login acts in; `use <id\|slug\|personal>` switches this login (keys, balance, usage and Spaces follow); `create --name N [--slug S]` makes a team you own; `invite <id\|slug> --email E [--role member\|admin]` invites someone into a team (owner/admin; they accept in the dashboard; the invitation lasts 48 h; the personal workspace takes no other members: `403 personal_workspace`). An API key belongs to one org: `use`, `create` and `invite` need `shark login` (`403 user_credential_required`) | `GET`/`POST /v1/orgs`, `POST /v1/orgs/{org}/use`, `POST /v1/orgs/{org}/invitations` |
| `shark logins ls\|rm <id> [--json]` | your CLI logins (each `shark login`): device, org, last use; `rm` ends one. (`shark sessions` is agent-session telemetry) | `GET /v1/logins`, `DELETE /v1/logins/{id}` |
| `shark sessions [--kind agent\|api\|shim] [--harness H] [--space ID] [--days N]`, `sessions show <id>`, `sessions summary` | your agent sessions: turns, tool calls by type, MCP servers, commands by class, thinking vs executing time, errors by cause. Metadata only: never a prompt, command text, file or argument | `GET /v1/sessions`, `/v1/sessions/{id}`, `/v1/telemetry/summary` |
| `shark context [--json]` | inside a Space: where this shell runs and what works here (read from `$SHARK_CONTEXT`; no network, no key) | — |

## Spaces

| command | what | API |
|---|---|---|
| `shark create [--name N] [--label k=v]... [--idempotency-key K] [--profile devbox\|sdk] [--account-secrets all\|none\|NAME,NAME] [--github on\|off] [--git-name N] [--git-email E] [--keep-background on\|off] [--keep-awake on\|off] [--space-cli on\|off\|SCOPE,SCOPE]` | prints the new id; retried safely (an idempotency key is generated if you give none). The CLI makes **devbox** Spaces (the account's secrets and GitHub, background jobs, the Space CLI); `--profile sdk` makes one as the SDKs do (nothing of the account unless asked: [api.md](api.md#sdk-spaces-and-devbox-spaces-profile)) | `POST /v1/spaces` |
| `shark ls [--label k:v]` | `id  state  name  created_at`, all pages | `GET /v1/spaces` |
| `shark rm <space> [--purge]` | delete; `--purge` also deletes the durable copy | `DELETE /v1/spaces/{id}` |
| `shark fork <space> [-n N] [--account-secrets …] [--github on\|off] [--keep-background on\|off] [--keep-awake on\|off] [--space-cli …]` | prints one id per fork (N ≤ 50); a fork keeps its source's profile and choices unless a flag says otherwise | `POST /v1/spaces/{id}/fork` |
| `shark hibernate <space>` / `shark wake <space>` | `<id> hibernated` / `<id> ready (woke in X ms)` | `POST …/hibernate`, `…/wake` |
| `shark checkpoint <space>` | waits until HEAD is in the durable store; prints JSON | `POST …/checkpoint` |
| `shark status <space>` | replication status JSON | `GET …/durable` |
| `shark space get <space> [--json]` | every setting of the Space in one view: name, labels, env, egress (policy and effective hosts), secrets (names, exposure, hosts), exposed ports (public or not), hibernation, durability, agent, limits | `GET /v1/spaces/{id}` (`settings`) |
| `shark space set <space> [--name N] [--label k=v] [--unlabel k] [--env K=V] [--unenv K] [--idle 30m\|off\|default] [--allow H] [--disallow H] [--public PORT=on\|off] [--account-secrets all\|none\|NAME,NAME\|default] [--github on\|off\|default] [--git-name N] [--git-email E] [--keep-background on\|off\|default] [--keep-awake on\|off\|default] [--space-cli on\|off\|default\|SCOPE,SCOPE] [--harness AGENT\|default [--harness-auth subscription\|api-key]]` | change several settings in one request; every field is checked first and a refused one changes nothing. `--allow`/`--disallow` need `network:write`, `--public`, `--keep-*` and `--space-cli` need `exec`, `--account-secrets`/`--github`/`--git-*` need `secrets:write`. `default` = the profile's. `--harness`: the agent `shark code` starts without asking (devbox Spaces only) | `PATCH /v1/spaces/{id}` |
| `shark space defaults [--account-secrets all\|none\|default] [--github on\|off\|default] [--harness AGENT\|default [--harness-auth M]] [--json]` | the account's defaults for every Space that does not set them (then the profile's); `--harness`: the account's default agent for devbox Spaces; no flag: show them | `GET`/`PUT /v1/space-defaults` |

## Commands

| command | what | API |
|---|---|---|
| `shark run <space> [--op-id ID] [--cwd DIR] [--detach] [--keep-background\|--no-keep-background] -- <command...>` | runs the command line in a fresh microVM; streams stdout/stderr live; exits with the command's exit code. `--keep-background`: what it leaves running (`cmd &`) stays as a job `bg-N`; `--no-keep-background`: it ends with the command (unset: the Space's `keep_background`; sdk off, devbox on). A dropped connection resumes from the last event; the generated `op_id` makes retries exactly-once. Ctrl-C signals the command (`INT`), not only the CLI. `--detach` prints the op id and returns. Timeout: 2 h | `POST …/exec` (SSE), `…/ops/{op_id}/signal` |
| `shark ops <space>` | `op_id  state  exit  shark_status  created_at`, newest first | `GET …/ops` |
| `shark logs <space> <op-id>` | the op's full output (live if it still runs); exits with its code | `GET …/ops/{op_id}/events` |
| `shark ps [<space>] [--json]` (alias `processes`) | background jobs a command left running: `id (bg-N)  state  ports  started  op  cmd` (the command redacted and cut as op logs show it). Inside a Shark session the Space is `$SHARK_SPACE` | `GET …/processes` |
| `shark kill bg-N [<space>]` | stops the job (TERM); what it wrote lands in the Space. `shark service rm bg-N` (what older job lines said) does the same | `POST …/processes/{pid}/signal` |
| `shark logs bg-N [<space>] [--tail BYTES] [-f]` | the job's output as committed (grows at its snapshots and end); `-f` follows it until the job ends | `GET …/processes/{pid}/logs` |
| `shark screenshot <url> [out.png] [--size WxH] [--wait S] [--budget-ms N] [--json]` | local, no API: waits for the URL, runs the headless browser once (`$CHROME_PATH`, else chrome-headless-shell/chromium on `PATH`), writes the PNG at the path given (relative to the command's cwd) and checks it whole (IEND, the asked size, synced); exit 0 only then. Errors `no_browser` (127), `not_ready`, `screenshot_failed` (no file left), `bad_path` (2: directory missing) | — |

`shark run` joins its arguments with spaces into one bash command line: quote the whole thing,
`shark run demo -- 'cd web && npm test'`. Exit codes: the command's own code when it ran; `1` when the API refused it
and nothing ran (for example the Space does not exist; the `next:` line says what to do); `125` when Shark could not
report a result (the op goes on: `shark logs <space> <op-id>`); `2` for usage or configuration errors. When `shark_status` is not `ok` (conflict, timeout, infra error) it is
printed on stderr.

## Files

| command | what | API |
|---|---|---|
| `shark cp <local> <space>:<path>` | upload a file (a path ending in `/` keeps the file name) | `PUT …/files` |
| `shark cp -r <localdir> <space>:<dir>` | upload a directory as one commit (skips `.git/`) | `POST …/files/archive` |
| `shark cp <space>:<path> <local>\|-` | download a file (`-` = stdout) | `GET …/files` |
| `shark cp -r <space>:<dir> <localdir>` | download a directory | `GET …/files/archive` |
| `shark files ls <space> [path]` | `type  size  path` | `GET …/tree` |
| `shark files get <space> <path> [local]` / `put <space> <local> <path>` | one file | `GET` / `PUT …/files` |
| `shark files rm <space> <path> [-r]` / `mv <space> <from> <to>` | remove / move (one commit each) | `DELETE …/files`, `POST …/files/move` |
| `shark files push <space> <localdir> [remote]` / `pull <space> <remote> <localdir>` | directories | archive routes |

Reads never start a VM and never wake a hibernated Space.

## Live: exposed processes, previews, terminal

**`expose` and `open` are live on the hosted node** (P130 flow PASS on metal, 2026-11-07): each exposed process runs
in its own microVM, private by default, sleeps after its idle time and wakes on the next request. It is billed while
awake, by size; asleep it is not ([billing.md](billing.md#what-is-metered)): remove the ones you no longer need with
`--rm`. **`shark connect` is coming**: it passed its local gates (P112) but is not yet verified on the hosted node. On
a node without exposed processes every verb here prints the node's `422 unsupported`.

| command | what | API |
|---|---|---|
| `shark connect <space>` | (coming) interactive shell: each line runs as one op; `cd`, `export`/`unset` and `$?` carry over; Ctrl-C interrupts; `exit` or Ctrl-D leaves. No TTY inside the command (use it for shells, not full-screen programs) | WebSocket `GET …/terminal` |
| `shark expose <space> <port> [--public] [--idle S] [--cwd DIR] [-e K=V]... -- <cmd...>` | run `cmd` as a sleeping server of the Space on `port`: starts on the first connection, sleeps after `S` s idle (default 30), sees every commit | `PUT …/ports/{port}` |
| `shark expose <space> --ls \| --rm PORT \| --logs PORT \| --sleep PORT \| --public PORT on\|off` | list, remove (stops it and revokes every preview cookie), logs, put to sleep now (wakes on the next request), toggle public | `GET`/`DELETE`/`POST`/`PATCH …/ports…` |
| `shark expose --ls --all [--awake] [--json]` | every exposed process of the account, awake first: Space, port, state, public, last request, URL, and how many are awake (billed now); `--awake` only those running or starting. `--json`: `{"data": [{space, space_name, port, state, awake, public, url, last_request_at, awake_since, …}], "awake": N}` | `GET /v1/ports` |
| `shark open <space> <port> [--print] [--path /p]` | mints a one-time login link (60 s) to `https://<id>-<port>.preview.sharkmagic.si` and opens your browser (`--print`: only print it) | `POST …/ports/{port}/url` |

Expected (the output shape; the Space id is an example):

```text
$ shark expose demo 3000 --cwd app -- node server.js
exposed node server.js on port 3000 (declared); it starts on the first connection and sleeps after 30s idle
preview: https://tti-ec8e46415dcc2dc3182daae2bc8a8aa2-3000.preview.sharkmagic.si/
private: open it with `shark open demo 3000`
```

## Agent instructions: `shark skills`

Local files only (no key, no network). The text ships inside the CLI; its source is `skills/shark/` in the Shark
repository ([agents-md.md](agents-md.md)).

| command | what |
|---|---|
| `shark skills install` | writes the Claude Code skill to `$CLAUDE_CONFIG_DIR/skills/shark` (else `~/.claude/skills/shark`); files already identical are not rewritten |
| `shark skills install --project DIR` / `--dir DIR` | the skill into `DIR/.claude/skills/shark` (a project skill you can commit, or copy into a Space with `shark cp -r`) / into `DIR` |
| `shark skills install --agents-md FILE [--space NAME] [--no-skill]` | also (or only) writes the AGENTS.md snippet into `FILE` between `<!-- shark:begin -->` and `<!-- shark:end -->`, replacing an earlier block and keeping the rest of the file; `--space` puts your Space's name in it |
| `shark skills install … --dry-run` | prints what it would create or update, writes nothing |
| `shark skills snippet [--space NAME]` | prints the snippet |

## Secrets

The value is read from **stdin** (pipe it), a **hidden prompt** or `--from-file`, never from argv (shell history and
`ps` would keep it). Inject secrets (the default) never enter the VM: commands see a placeholder `shk_ph_…` and the
node's egress proxy puts the real value only into requests to the scope's hosts
([concepts.md](concepts.md#secrets-inject-default-vs-exec)).

### Guided setup (a person at a terminal)

`shark secrets` (or `shark secrets setup [--account | <space>] [NAME] [--preset P …]`) asks, one step at a time:
where (the account, or one of your Spaces), the kind (a preset, custom, or the account's secret for a Space), the
name, for custom the exposure (inject / exec, exec confirmed twice), hosts and where in the request, for the account
which Spaces get it, for a Space whether to add the hosts to its egress, then the value (hidden, paste-friendly: a
pasted newline does not submit; it says how long it is and whether it starts as the provider's keys do, e.g. `sk-ant-`,
never what it is), and a review with the equivalent flag line. Nothing is sent before **Save**.

Keys: **Enter** = the default (forward), **b** or **←** = back (the answers are kept; going forward again walks through
them), **q**, Ctrl-C or Ctrl-D = cancel (nothing saved, exit 130), menus: **↑/↓** or a number. From the review you can
jump to any step and come straight back. Flags given on the command line fill their steps. After saving it says what
happens now (`$NAME=shk_ph_…`, the egress) and the next commands (`shark secrets status`, `shark code`). `shark secrets
set` with a missing name or hosts on a terminal opens the same setup with what you gave; off a terminal it fails with
the flag form.

### Commands

| command | what | API |
|---|---|---|
| `shark secrets set <space> NAME --preset P [--allow-egress]` | an inject secret for a known provider (below); `--allow-egress` also adds its hosts to the Space's egress allowlist | `PUT /v1/spaces/{id}/secrets` |
| `shark secrets set <space> NAME --host H [--host H2] [--in LOC]...` | an inject secret for any host; `LOC` is `bearer` (default), `basic`, `header:NAME` or `query:PARAM` | same |
| `shark secrets set <space> NAME --exec` | the value in the environment of commands that ask for it (`exec` with `"secrets": ["NAME"]`) and of terminals; any code they run can read it | same |
| `… --from-file F` | the value from a file instead of stdin | |
| `shark secrets set --account NAME --preset P [--apply all\|ref]` | an **account secret**: set once, used by every Space of the account (`all`, default; new Spaces and forks too) or only by Spaces that reference it (`ref`). Same flags as a Space's (`--host`, `--in`, `--exec`, `--from-file`) | `PUT /v1/secrets` |
| `shark secrets set <space> NAME --from-account` | the Space uses the account secret NAME (needed for `--apply ref`); no value | `PUT /v1/spaces/{id}/secrets` (`{"ref": "account"}`) |
| `shark secrets status [<space> \| --account] [--agent claude\|codex\|opencode]... [--preset P]...` | **what is missing**: every secret of the scope (names only) with its state (ok, shadowed, unset reference, egress blocks a host, available to reference), last use, and each gap with the exact command that fixes it. Without `--agent` a Space is checked for the agent running in it. The node computes it (`--json` is its document); against an older node without that route the CLI computes the same document itself | `GET /v1/spaces/{id}/secrets/status`, `GET /v1/secrets/status` |
| `shark secrets ls (<space> \| --account)` | a table: scope (`space`/`account`), name, preset, hosts, mode, apply, shadowed, last used (`--json`: the node's rows plus `preset`, `uses`, `last_used`) | `GET …/secrets`, `…/usage` |
| `shark secrets rotate (<space> \| --account) NAME [--from-file F]` | a new value, the same exposure, hosts, locations and apply | `PUT …/secrets` |
| `shark secrets edit (<space> \| --account) NAME [--preset P \| --host H... \| --add-host H \| --rm-host H] [--in LOC...] [--exec \| --inject] [--apply all\|ref] [--allow-egress]` | change how a stored secret is used **without typing the value again** (on a terminal with no flags: the review screen of the setup) | `PUT …/secrets` with `"keep_value": true` |
| `shark secrets rm (<space> \| --account) NAME [--yes]` | delete (from the next command); asks on a terminal, needs `--yes` otherwise | `DELETE …/secrets/{name}` |
| `shark secrets usage (--account \| <space>) NAME` | per Space and host: requests that got the value, requests refused for carrying it out of scope, last use (never the value) | `GET /v1/secrets/{name}/usage`, `GET /v1/spaces/{id}/secrets/{name}/usage` |
| `shark secrets presets` | the presets | `GET /v1/secret-presets` (same catalog) |

A Space secret with the same name as an account secret always wins in that Space (`ls` and `status` show the account
one as shadowed).

| preset | suggested name | hosts | where the value goes |
|---|---|---|---|
| `anthropic` | `ANTHROPIC_API_KEY` | `api.anthropic.com` | header `x-api-key` |
| `openai` | `OPENAI_API_KEY` | `api.openai.com` | `Authorization: Bearer` |
| `github` | `GITHUB_TOKEN` | `github.com`, `api.github.com` | Basic (git over https, user `x-access-token`), Bearer or `token` header (REST API) |
| `npm` | `NPM_TOKEN` | `registry.npmjs.org` | `Authorization: Bearer` (`_authToken`) |

```sh
shark secrets                                                                  # guided setup (a terminal)
shark secrets set demo ANTHROPIC_API_KEY --preset anthropic --allow-egress    # prompts, input hidden
gh auth token | shark secrets set demo GITHUB_TOKEN --preset github
shark secrets set demo STRIPE_KEY --host api.stripe.com < stripe-key.txt
shark secrets status demo --agent claude                                      # what is missing, and the fix
shark secrets edit demo STRIPE_KEY --add-host files.stripe.com                 # no value needed
pbpaste | shark secrets rotate demo STRIPE_KEY
shark secrets rm demo STRIPE_KEY --yes
pbpaste | shark secrets set --account OPENAI_API_KEY --preset openai   # every Space, forks included
shark secrets usage --account OPENAI_API_KEY --json
```

### Every setup step has a flag

The review prints the flag line of what you chose (never the value); `shark secrets set` with those flags and the
value on stdin stores exactly the same thing (tested: `cmd/shark/secrets_wizard_test.go`, `TestSetFlagParity`).

| setup step | answer | flag |
|---|---|---|
| where | the account / a Space | `--account` / `<space>` |
| kind | a preset / custom / the account's secret | `--preset P` / `--host …` or `--exec` / `--from-account` |
| name | `NAME` | `NAME` |
| exposure | inject / exec | (default) / `--exec` |
| hosts | `api.x.com, *.x.com` | `--host api.x.com --host '*.x.com'` |
| where in the request | Bearer / a header / Basic / a query parameter | `--in bearer` / `--in header:X-Api-Key` / `--in basic` / `--in query:key` |
| which Spaces (account) | every Space / only those that reference it | (default) / `--apply ref` |
| egress (Space) | add the hosts | `--allow-egress` |
| value | hidden prompt | stdin, or `--from-file F` |
| value, kept (a stored secret) | Enter | `shark secrets edit …` |
| review | Save / Cancel | (the command runs) / (not running it) |
| rm's question | yes | `--yes` |

### `status --json`

```json
{"scope": "space", "space": "demo", "agents": ["claude"], "presets": [], "egress": {"mode": "allowlist", "source": "space"},
 "secrets": [{"name": "STRIPE_KEY", "scope": "space", "expose": "inject", "hosts": ["api.stripe.com"], "in": [{"kind": "bearer"}],
              "preset": "", "active": true, "shadowed": false, "missing": false, "egress_blocked": ["api.stripe.com"],
              "usage": {"available": true, "count": 7, "blocked": 0, "last_used": "2026-11-06T10:00:00Z"}}],
 "gaps": [{"code": "missing_secret", "severity": "error", "secret": "ANTHROPIC_API_KEY", "agent": "claude", "message": "…",
           "fix": "shark secrets set demo ANTHROPIC_API_KEY --preset anthropic --allow-egress < value.txt"},
          {"code": "egress_not_allowed", "severity": "error", "secret": "STRIPE_KEY", "hosts": ["api.stripe.com"], "message": "…",
           "fix": "shark egress add demo api.stripe.com"}],
 "ok": false}
```

`ok` is false while any gap has `severity: "error"`; the exit code is 0 either way (1 only when the API fails). Gap
codes: `missing_secret` (a harness or `--preset` expects it; the fix is `--from-account` when the account has it as
`apply: ref`), `egress_not_allowed`, `account_secret_unset` (a reference to no account secret), and the `info` ones
`shadowed`, `placeholder_blocked` (requests carried the placeholder to a host outside the scope: `usage` shows where),
`egress_unknown` (an older node does not say which hosts its egress allows). `fix` is a command to run as printed (a
`< value.txt` means the value on stdin; `HOST` is yours to fill in).

**Errors** (stable codes, `--json` as everywhere): `not_a_terminal` (2: the guided setup without a terminal; the hint
is the flag form), `confirmation_required` (2: `rm` without `--yes` off a terminal), `cancelled` (130: q, Ctrl-C or
Ctrl-D in a question; nothing saved), `no_value` / `bad_value` (2: empty, under 8 or over 16 KiB, a control
character, or a `shk_ph_` placeholder pasted back), `unknown_preset`, `unknown_agent` (2), `secret_is_account` (2:
`rotate`/`edit` of an account secret through a Space: the hint is the `--account` form), `not_found` (1),
`unsupported` (1: a node too old for `edit`'s `keep_value`; set it again with the value), `usage` (2).

A host the Space's egress does not allow is warned, with the command that fixes it. The old argv form
`NAME=VALUE` still works (stored as `--exec`) but prints a deprecation warning; `NAME=-` and `NAME=@file` too.

## Egress

| command | what | API |
|---|---|---|
| `shark egress profiles [NAME] [--json]` | Shark's named host lists; with a name, its hosts with why each is there and how it was verified (`binary:<pkg>@<version>`, `probe`, `docs:…`, `legacy`), and what is left out on purpose | `GET /v1/egress/profiles` |
| `shark egress use <space> +PROFILE -PROFILE...` | add / remove profiles in one atomic change (`-npm` also takes npm out of `essentials`; `+open`/`-open` = mode open) | `PATCH …/egress {use}` |
| `shark egress show <space> [--effective]` (`get` is the same) | mode, profiles (and those Shark added by itself), your hosts, your deny list, where the policy comes from (`space`, `default`, `host`); `--effective` lists every host with the profile it comes from; `--json` has it all in `effective` | `GET /v1/spaces/{id}/egress` |
| `shark egress add <space> HOST...` / `rm <space> HOST...` | allow or stop allowing hosts of your own, keeping the rest (`example.com`, `*.example.com`, `db.example.com:5432`, `203.0.113.0/24:5432`): one atomic change on the node. `rm` of a host not listed exits 1 `not_found` | `PATCH …/egress {add\|remove}` |
| `shark egress deny <space> HOST...` / `undeny <space> HOST...` | refuse hosts (`name` or `*.suffix`) even when a profile has them (deny wins) / drop deny entries | `PATCH …/egress {add_deny\|remove_deny}` |
| `shark egress denials <space> [--since 1h\|30m\|2d\|RFC3339]` | hosts the egress proxy refused (names, ports, counts, first/last; never content), each with the profile that contains it and the command to allow it | `GET …/egress/denials` |
| `shark egress clear <space>` | remove every host you added (the profiles stay) | `PATCH …/egress {clear}` |
| `shark egress set <space> [--mode allowlist\|deny\|open] [--base coding\|none] [--allow H,H] [--add H] [--remove H] [--auto on\|off]` | change the policy (`--allow` replaces the list); `open` only where the account may (`403 plan_restriction`) | same |

Changes apply from the next command (the agent's session VM: from its next start); forks inherit them. `shark code
--agent X` adds X's profile by itself (and with an API key the key's provider); `shark secrets set … --preset P
--allow-egress` adds P's profile. A refused entry is `400 invalid_request` with `details.field`
(`add[i]`/`allow[i]`/`use[i]`/`add_deny[i]`), `details.host` or `details.profile` and `details.reason`
(`ip_without_port`, `private_range`, `cidr_without_port`, `bad_cidr`, `bad_port`, `bad_name`, `invalid_host`,
`too_many_entries`, `unknown_profile`, `included_by`, `deny_needs_allowlist`); `--json` prints them.

```bash
shark egress profiles                               # the catalog
shark egress use demo +opencode +openrouter -docker-hub
shark egress show demo --effective                  # every host and the profile it comes from
shark egress denials demo --since 1h --json         # what was refused, and which profile has it
```

## GitHub

**Connect GitHub once; every Space of your account can then use `git` and `gh` on the repositories you chose** (new
Spaces, existing ones and forks, with no per-Space step). The node keeps a managed `GITHUB_TOKEN` account secret
(inject, apply all) fresh: short-lived App installation tokens, minted before a command when the current one is
missing or expires within 15 min. Your commands see a placeholder; `git clone/fetch/push` over https and `gh` work as
usual (`git@github.com:` URLs are rewritten to https), and commits default to your GitHub noreply address unless the
Space sets its own `user.name`/`user.email`.

| command | what | API (account service) |
|---|---|---|
| `shark github connect [--no-browser] [--wait] [--install]` | opens GitHub's authorization for the Shark GitHub App: installations of the App you can already access (your account, your organizations) are found and linked; with none, GitHub continues to the install page, where you pick your account and/or organizations and the repositories. `--wait` returns once the installation arrived. `--install` opens the install page itself (to add an organization). `--json`: `url` (the link to open), `install_url`, `connect_url`. Needs `secrets:write` (it puts an account credential in your Spaces, like an account secret): a `shark login` has it; an API key needs it in its scopes (`403 insufficient_scope`, `details.required`) | `POST /v1/github/install` |
| `shark github status [--no-check]` | the installations (default owner, token health, repository count), the commit identity, the node's managed `GITHUB_TOKEN` (expiry, owners) and the Spaces that used it | `GET /v1/github/status?check=1`, node `GET /v1/secrets`, `…/GITHUB_TOKEN/usage` |
| `shark github disconnect [--installation ID] [--keep-on-github] --yes` | forget the connection (all, or one installation). By default the App is uninstalled on GitHub, which revokes its tokens; every Space loses `GITHUB_TOKEN` within seconds | `DELETE /v1/github[/installations/{id}]` |
| `shark github token <space> --repo owner/name` | a token for that one repository (contents and pull requests, ≤ 1 h) stored as the Space's own `GITHUB_TOKEN` (it wins over the account's there); never printed | `POST /v1/github/token`, then `PUT …/secrets` |
| `shark github link <space>` / `unlink <space>` | which repository a per-Space token is for (its node keeps it fresh while linked) / stop that | `GET` / `DELETE /v1/spaces/{id}/github` |

With several installations (your account and organizations), requests that name a repository
(`github.com/<owner>/…`, `api.github.com/repos/<owner>/…`) get that owner's token; others (GraphQL, `/user`) get the
default one (your own account's). `shark code --repo` needs no token step when the account is connected. Without the
App (`503 github_not_configured`), `gh auth token | shark secrets set --account GITHUB_TOKEN --preset github` works.

The account's connection reaches **devbox** Spaces. An **sdk** Space (made by the API/SDK) gets it only with
`github: true` (`shark space set <space> --github on`); its commit identity is `--git-name`/`--git-email`
(`git: {name, email}`). A platform whose users install **its own** GitHub App registers it once and links each
Space to one installation:

| command | what | API (account service) |
|---|---|---|
| `shark github app register --app-id ID --private-key-file app.pem [--json]` | the App once; the key is sealed, never shown again | `PUT /v1/github/app` |
| `shark github app status [--json]` | registered?, app id, slug, linked Spaces | `GET /v1/github/app` |
| `shark github app link <space> --installation ID [--repo NAME]... [--json]` | a token of that installation (narrowed to `--repo`) stored as the Space's `GITHUB_TOKEN`; its node refreshes it while the Space is used | `POST /v1/github/app/link`, then `PUT …/secrets` |
| `shark github app rm --yes [--json]` | forget the App and its links | `DELETE /v1/github/app` |

SDK: `shark.github_app.register(app_id, pem)`, `.link(space, installation_id, repos=[…])`, `.status()`, `.remove()`
(TS: `shark.githubApp.*`). Or, with a token you mint yourself, per Space:
`shark secrets set <space> GITHUB_TOKEN --preset github < token.txt` (SDK: `space.secrets.set("GITHUB_TOKEN", tok,
preset="github")`).

## Coding agent and terminals in a session VM

Details: [shark-code.md](shark-code.md).

| command | what |
|---|---|
| `shark code [--repo owner/name\|URL] [--branch B] [--work-branch W] [--agent claude\|codex\|opencode] [--auth subscription\|api-key] [--remember space\|account] [--view] [--continue \| --resume [ID]] [space]` | create or wake the Space, clone the repository once (work branch `shark/<space>`), start or join the agent, attach. `--continue` / `--resume [ID]` start the agent on a past conversation kept in the Space. Ctrl-\ detaches. Which agent: `--agent` (or `$SHARK_AGENT`) > the Space's remembered one > the account's > on a terminal, the first-run questions > exit 2 `harness_required` (no terminal, or `--json`, never asks; [shark-code.md](shark-code.md#choosing-the-agent---agent---remember---forget)) |
| `shark code --agent list [--json]` | the coding agents this node runs, with the logins each supports (`GET /v1/agents`; the CLI's own list, `source: "cli"`, from an older node) |
| `shark code --forget [space\|account\|all] [space]` | forget the remembered agent of the Space (default), the account's, or both; starts nothing. `--json`: `{"space", "forgot": [...], "still"}` |
| `shark code --sessions [--agent A] [--json] [space]` | the agent's past conversations in the Space: id, start, last activity, size (never their content) |
| `shark attach <space> [--view] [--shell] [--resume ID] [--no-escape] [-- cmd...]` | join the running agent; with no agent running (or `--shell`) a **terminal**: a login shell in the Space's session VM. Plain `attach` never starts an agent; an agent you type there (`claude`, `codex`, `opencode`) runs as under `shark code` ([shark-code.md](shark-code.md#the-agent-and-the-terminal)). `~.` after Enter detaches |
| `shark logs <space>` / `shark stop <space>` / `shark run-agent <space> "<task>"` | what the agent printed / end it / a headless run |

## Usage

| command | what | API |
|---|---|---|
| `shark usage [--by space\|day] [--json]` | `key  commands N  command_s S` per row | `GET /v1/usage` |

## Other binaries (self-hosted and harness integration)

| binary | for | page |
|---|---|---|
| `shark-bash` | runs unmodified Claude Code's Bash tool in Shark leases (a shell shim) | [agents.md](agents.md#c-unmodified-claude-code-the-shell-shim) |
| `shark-rg` | answers Claude Code's Grep and Glob from an index instead of walking a FUSE mount | [agents.md](agents.md#grep-and-glob-shark-rg) |
| `sharkfn` | Read, Write, Edit, Glob, Grep served from a Space's committed tree; an MCP server (`sharkfn serve -stdio`) | [agents.md](agents.md#d-mcp-sharkfn) |
| `sharkfs` | the node itself (`sharkfs api`), the SharkFS mount, durability and the usage meter | operators only |
| `shark service …` | declares Postgres/Redis on the host side of a Space (`catalog`, `add postgres@17`, `rm`, `ls`, `checkpoint`, `env`); as a client, commit `.shark/services.toml` instead ([concepts.md](concepts.md#services-that-sleep)) | |

`shark service catalog` (works anywhere):

```text
postgres@16  port 5432  2 vCPU 1024 MiB  env DATABASE_URL PGDATABASE PGHOST PGPASSWORD PGPORT PGUSER POSTGRES_URL
postgres@17  port 5432  2 vCPU 1024 MiB  env DATABASE_URL PGDATABASE PGHOST PGPASSWORD PGPORT PGUSER POSTGRES_URL
redis@7      port 6379  1 vCPU  512 MiB  env REDIS_HOST REDIS_PORT REDIS_URL
```

Without `SHARK_API` and a key, `shark create <dir>`, `shark fork`, `shark log`, `shark head` and `shark checkout` work
on a local Space store (`SHARK_HOME`): the early prototype of the content-addressed Space, no server and no VM.