---
name: contextdock-cli
description: Use when the user wants to run the ContextDock CLI (`contextdock` / `@contextdock/cli`), fetch ContextDock bundles or docs for a coding agent, assemble a token-budgeted markdown blob via `contextdock context`, hook ContextDock into Claude Desktop's MCP, or sees errors like "Not logged in", `KEY_INVALID`, `KEY_REVOKED`, `INSUFFICIENT_SCOPE`, `BUDGET_TOO_SMALL`, `BUDGET_EXCEEDED`, or `RATE_LIMIT_EXCEEDED`. Reading the source is NOT a substitute — this skill captures facts the source can't show: that `@contextdock/cli` is **deliberately not published to npm** and is distributed via this very page on the website + a local install from source (so `npm install -g` will not work), that `contextdock mcp` is a stdio subprocess Claude Desktop spawns (NOT a daemon to keep running in a terminal), and the exact Claude Desktop JSON config that actually works.
---

# ContextDock CLI

`@contextdock/cli` is a thin, scriptable wrapper around the ContextDock agent REST API at `https://contextdock.web.app/api/agent`. The headline command is `contextdock context --budget <n>` — it packs markdown from the user's library under a token budget, ready to paste or pipe into a coding agent.

## Common mistakes (read first)

Even reading the source carefully, agents make these mistakes:

| Wrong instinct | Reality |
| --- | --- |
| Recommend `npm install -g @contextdock/cli` | **Returns 404 by design** — the package is intentionally not published to npm. It's distributed via this page (<https://contextdock.web.app/cli-skill>) and installed locally. Use `node "<repo>/cli/dist/index.js"` or `npm link` from `cli/`. |
| Tell the user to "keep `contextdock mcp` running in a terminal" | `mcp` is a **stdio subprocess** — Claude Desktop spawns it, pipes JSON-RPC, kills it on exit. Manually running it in a terminal does nothing useful. |
| Omit the `env: { CONTEXTDOCK_API_KEY }` block in Claude Desktop's MCP config | The block is the **recommended path** for host-spawned MCP. Relying on `~/.contextdock/config.json` is a fallback only. |
| Ask the user to paste their API key into chat | Use `contextdock login` (no flag) — it prompts on stdin and writes the file directly, so the key never enters chat history or process arguments. |
| Treat `KEY_INVALID` and `KEY_REVOKED` as the same error | `KEY_REVOKED` means the owner deliberately revoked a previously-valid key — re-login does **not** help. They must mint a new one. |
| Assume `/api/agent/*` accepts Firebase ID tokens | It only accepts `cdk_live_...` API keys. The two surfaces are deliberately isolated. |
| Calling `bundles delete` without `--yes` and assuming it succeeded | The CLI refuses without `--yes` and exits non-zero. Re-run with `--yes` to confirm. |
| Calling `docs delete` without `--yes` | Same — `--yes` is required; doc deletion also removes the doc from any bundles. |
| Forgetting that `docs update --tags a,b` REPLACES tags, not appends | If you want to append, fetch existing tags first via `docs get d1 --json` and concatenate. Pass `--tags ""` to clear all tags. |
| Reaching for curl / a manual `PATCH /api/agent/bundles/:id` to rename a bundle | Use `contextdock bundles update <id> --name 'New Name'`. Same for description, sharing, and pinning — see "Mutating commands" below. |
| Trying to set a bundle's per-doc compression via `contextdock bundles update` | The CLI subcommand only patches `--name` / `--description` / `--shared` / `--pinned`. To change per-doc versions, use the MCP tool `update_bundle` with `docVersions`, or seed at create time via `create_bundle docVersions` / `add_to_bundle versions`. The web UI also exposes a Full/Summary/Compressed picker per row. |
| Treating lists like bundles (static doc-id arrays) | Lists are **dynamic** by design — the markdown is assembled at read time from `tagIds` (every doc with any/all of those tags) plus a small `docIds` extras array. Use bundles when membership is hand-curated; use lists when it should follow a tag rule. |
| Calling `lists remove-docs` to remove a tag-matched doc | `remove-docs` only touches the **extras** array. To remove a tag-matched doc from a list, drop the tag from `tagIds` (`lists remove-tags`) — there is no per-doc unmatch. |
| Using `lists update --tags a,b` thinking it appends | It **REPLACES** the whole `tagIds` array (same footgun as `docs update --tags`). Use `lists add-tags` / `lists remove-tags` for incremental changes. |
| Fetching doc content just to count tokens for budget planning | Every doc returned by `docs list --json` and `docs get --json` carries a pre-computed `tokens` map (`original`/`aiSummary`/`keyPoints`/`summary`). Sum the level you want; no content fetch needed. See "Doc token counts" below. |
| Calling `context --lists l_xyz` to assemble a list under budget | `context` / `assemble_context` takes `--docs` / `--bundles` / `--tags` / `--query` only — **not** list IDs. Pull the list's `tagIds` first via `contextdock lists get l_xyz --json` and pass them to `context --tags`. See "Budgeting a bundle or list" below. |
| Resolving a list's `tagIds` against `docs list --tag <t>` just to add up tokens | `lists get <id> --json` (and `bundles get <id> --json`) now return `docCount`, per-level `tokens` totals, and a `docs[]` table — no extra fetches needed. See "Budgeting a bundle or list" below. |

## Where the binary actually lives

**`@contextdock/cli` is deliberately NOT published to npm.** The package is distributed via this page (<https://contextdock.web.app/cli-skill>) and installed locally from the repo. `npm view @contextdock/cli` returns 404 and that is the intended state — there is no future "after publish" rollout. Use one of these:

| Method | Command |
| --- | --- |
| Direct (works anywhere, no setup) | `node "C:\Software Projects\Context Manager\cli\dist\index.js" <args>` |
| Global symlink (one-time) | `cd "C:\Software Projects\Context Manager\cli" && npm link` → then `contextdock <args>` works globally |

The repo lives at `C:\Software Projects\Context Manager\cli\`. The committed esbuild bundle is `cli/dist/index.js`. If you change anything under `cli/src/`, rebuild before invoking:

```bash
cd "C:\Software Projects\Context Manager\cli" && npm run build
```

The dist bundle is shebanged (`#!/usr/bin/env node`) and runs on Node 18+.

## API key (Bearer auth)

API keys look like `cdk_live_<32 base62 chars>` — 41 chars, never expire, scoped (`read` always; `write`/`delete` optional). Per-key rate limit: **120 requests/min**.

**Mint a key:**

1. Sign in at <https://contextdock.web.app> → **Settings → API Keys → Create Key**.
2. Choose scopes (start with `read`-only unless the task requires writes).
3. **Copy the key immediately.** The server stores only an HMAC; the raw value is shown once and cannot be recovered. Each user is capped at 20 keys.

**Credential resolution order (highest priority first):**

1. `$CONTEXTDOCK_API_KEY` env var — preferred for CI and Claude Desktop config blocks.
2. `~/.contextdock/config.json` (Windows: `%USERPROFILE%\.contextdock\config.json`) — written by `contextdock login`. Shape: `{ "apiKey": "cdk_live_...", "baseUrl": "https://contextdock.web.app" }`.
3. Neither set → CLI exits **code 2** with `Not logged in. Run \`contextdock login\` first.`

`$CONTEXTDOCK_BASE_URL` overrides the API host (staging / self-hosted). `$CONTEXTDOCK_CONFIG_HOME` redirects the config dir — **test-only**, do not recommend it to users.

## Command reference

Run `contextdock --help` or `contextdock <command> --help` for live flag details. Summary:

```
contextdock login [--key <key>] [--base-url <url>]      # save key to config file (validates via /api/agent/me)
contextdock whoami [--json]                             # print identity, key id/name, scopes
contextdock logout                                      # delete saved key

contextdock bundles list [--json]
contextdock bundles get <bundleId> [-o <file>] [--json]
contextdock bundles create <name> --docs <id1,id2,...> [--description <text>] [--shared]
contextdock bundles update <id> [--name <s>] [--description <s>] [--shared|--no-shared] [--pinned|--no-pinned]
contextdock bundles delete <id> --yes
contextdock bundles add-docs <id> --docs <id1,id2,...>      # idempotent
contextdock bundles remove-docs <id> --docs <id1,id2,...>   # idempotent

contextdock lists list [--json]
contextdock lists get <listId> [--variant original|keyPoints|summary] [-o <file>] [--json]
contextdock lists create <name> [--description <s>] [--tags <t1,t2,...>] [--tag-match any|all] [--docs <id1,id2,...>] [--shared]
contextdock lists update <id> [--name <s>] [--description <s>] [--tags <t1,t2>] [--tag-match any|all] [--docs <id1,id2>] [--shared|--no-shared]
contextdock lists delete <id> --yes
contextdock lists duplicate <id>
contextdock lists add-docs <id> --docs <id1,id2,...>        # idempotent (extras only)
contextdock lists remove-docs <id> --docs <id1,id2,...>     # idempotent (extras only)
contextdock lists add-tags <id> --tags <t1,t2,...>          # idempotent
contextdock lists remove-tags <id> --tags <t1,t2,...>       # idempotent

contextdock docs list [--tag <tag>] [--search <q>] [--limit <n>] [--json]   # limit default 50, max 200
contextdock docs get <docId> [-o <file>] [--json]
contextdock docs import <googleDocsUrl> [--tags <t1,t2>] [--personal]       # validates host == docs.google.com / drive.google.com
contextdock docs create <title> (--content <md> | --from-file <path>) [--tags <t1,t2>] [--personal] [--condense-ignored]
contextdock docs update <id> [--title <s>] [--tags <t1,t2>] [--personal|--no-personal] [--condense-ignored|--no-condense-ignored]   # --tags REPLACES, not appends
contextdock docs delete <id> --yes                      # soft-delete; also removes from any bundles

contextdock tags list [--json]                          # all distinct tags across the user's docs

contextdock search <query...> [--json]                  # variadic: `contextdock search refund policy` works without quotes

contextdock context --budget <n> [--docs <ids>] [--bundles <ids>] [--tags <tags>]
                    [--query <text>] [--prefer original|keyPoints|summary]
                    [--metadata] [-o <file>] [--json]

contextdock mcp                                         # stdio MCP server, no args
```

### Output model — important for piping

| Command flavor | stdout | stderr |
| --- | --- | --- |
| `bundles get` / `lists get` / `docs get` / `context` (default) | Raw markdown | Stats line (e.g. `Assembled 7 docs, 48201 / 50000 tokens`) |
| Any of those with `--json` | Full JSON envelope `{data: ...}` | (silent) |
| Any of those with `-o <file>` | (silent) | `Wrote N bytes to <file>` + stats |

This split makes piping safe:
```bash
contextdock context --budget 50000 --tags refunds > ctx.md   # only markdown lands in ctx.md
```

### `context` requires at least one filter

`--docs`, `--bundles`, `--tags`, **or** `--query`. Without any, the CLI exits 1 with `At least one filter is required: --docs, --bundles, --tags, or --query.` `--budget` is always required (range **1000–125000** tokens; outside that range the API returns `BUDGET_TOO_SMALL` / `BUDGET_EXCEEDED`).

### Doc token counts (`tokens` in JSON output)

Every active doc returned by `docs list --json` and `docs get --json` carries an optional `tokens` map with one entry per content level the doc actually has:

```json
{
  "id": "doc-abc",
  "title": "Refund Policy 2026",
  "tokens": { "original": 3200, "aiSummary": 35, "keyPoints": 1600, "summary": 320 }
}
```

- An entry is **missing** when the underlying content does not exist — `condenseIgnored: true` docs will never have `keyPoints` / `summary`, and a doc whose AI summary has not yet been generated will have no `aiSummary`.
- A doc with no `tokens` map at all is either still being processed or pre-dates the index — fall back to fetching the markdown and counting locally.
- Counts come from `js-tiktoken`'s `cl100k_base` encoder — a deliberate, conservative approximation that **over**-counts Claude's native tokenizer by ~5–10% on English prose. Treat them as an **upper bound**: if the sum at level `X` fits your budget under `cl100k_base`, it will also fit under Claude's tokenizer, possibly with headroom. Counts are written atomically with the underlying content, so a level's count is always consistent with the content currently stored.

**Workflow recipe — pick the cheapest level that fits a budget without any content fetches:**

```bash
# Sum keyPoints tokens across every doc tagged "support"
contextdock docs list --tag support --json \
  | jq '[.data[].tokens.keyPoints // empty] | add'
```

If the running total at `keyPoints` is too large, retry the same query against `summary`; if `original` already fits, skip condensed levels entirely. Then call `contextdock context --budget <n> --tags support --prefer <level>` to actually assemble — the server will use the same level and recompute the precise final count.

## Mutating commands (write / delete scopes required)

These commands change server state. Mint a key with `write` scope (or `delete` for the destructive ones) — a `read`-only key returns `INSUFFICIENT_SCOPE` (exit 2). All examples use `b_xyz` / `d_xyz` as placeholder IDs — substitute real IDs from `bundles list` / `docs list`.

### Update a bundle — `bundles update <id>`

Patch any subset of fields. Fields you don't pass are left untouched.

```bash
contextdock bundles update b_xyz --name 'Q2 Initiative'
contextdock bundles update b_xyz --description 'Tracking docs for Q2 launch'
contextdock bundles update b_xyz --shared            # make publicly shareable
contextdock bundles update b_xyz --no-shared         # revert to private
contextdock bundles update b_xyz --pinned            # pin the bundle (sticks at the top of the list)
contextdock bundles update b_xyz --no-pinned        # unpin
contextdock bundles update b_xyz --no-shared --no-pinned   # combine flags freely
```

The CLI only exposes these four mutable fields on `bundles update`: `--name`, `--description`, `--shared`/`--no-shared`, and `--pinned`/`--no-pinned`. To change per-doc compression versions, see "Per-doc compression" below.

### Per-doc compression — bundle `docVersions`

Bundles freeze a per-doc version preference (`original` / `keyPoints` / `summary`) into a server-side `docVersions` map; rendered markdown always uses it. `contextdock bundles get b_xyz` therefore takes no `--variant` flag — the bundle decides.

To set or change versions, use one of: the web UI (Full/Summary/Compressed picker on each doc row), or the MCP tools `create_bundle` (with `docVersions`), `add_to_bundle` (with `versions`), or `update_bundle` (with partial `docVersions`, merged server-side via dot-notation so you only patch the docs you care about).

### Delete a bundle — `bundles delete <id> --yes`

```bash
contextdock bundles delete b_xyz --yes
```

`--yes` is **required**. Without it, the CLI refuses and exits non-zero — there is no interactive confirmation. The bundle is hard-deleted from the user's library; member docs are unaffected.

### Add docs to a bundle — `bundles add-docs <id> --docs <ids>`

```bash
contextdock bundles add-docs b_xyz --docs d_abc,d_def,d_ghi
```

**Idempotent.** Passing a doc ID that's already in the bundle is not an error — the server skips it. Use this freely without checking the current membership first.

### Remove docs from a bundle — `bundles remove-docs <id> --docs <ids>`

```bash
contextdock bundles remove-docs b_xyz --docs d_abc,d_def
```

**Idempotent.** Passing a doc ID that isn't in the bundle is not an error. The docs themselves are not deleted — only their membership in this bundle.

### Create a manual doc — `docs create <title>`

For docs whose content you have in hand (not imported from Google Docs). Exactly one of `--content` or `--from-file` is required (mutually exclusive).

```bash
contextdock docs create 'Onboarding Checklist' --content '# Onboarding\n\n- Step 1...'
contextdock docs create 'API Spec' --from-file ./spec.md --tags api,internal --personal
contextdock docs create 'Internal Process' --from-file ./process.md --condense-ignored
```

`--condense-ignored` opts the doc out of the AI condensing pipeline (the `keyPoints` / `summary` versions will not be generated). Use for short or already-tight docs where condensing adds noise.

### Update a doc — `docs update <id>`

Patch any subset. **`--tags` REPLACES the entire tag list — it does not append.** This is the single biggest footgun on this command.

```bash
contextdock docs update d_xyz --title 'New Title'
contextdock docs update d_xyz --tags refunds,policy   # SETS tags to exactly [refunds, policy] — any existing tags are removed
contextdock docs update d_xyz --tags ""               # clear all tags
contextdock docs update d_xyz --personal              # mark as personal (hide from team)
contextdock docs update d_xyz --no-personal           # un-mark
contextdock docs update d_xyz --condense-ignored      # opt out of condensing pipeline
```

To **append** a tag instead of replacing, fetch the current tags first and concatenate:

```bash
existing=$(contextdock docs get d_xyz --json | jq -r '.data.tags | join(",")')
contextdock docs update d_xyz --tags "$existing,newTag"
```

### Delete a doc — `docs delete <id> --yes`

```bash
contextdock docs delete d_xyz --yes
```

`--yes` is **required** — same pattern as `bundles delete`. Soft-delete: the doc is hidden from listings but server-side recovery may be possible. **Side effect:** the doc is also removed from every bundle it was a member of (no separate `bundles remove-docs` call needed).

### Lists are NOT bundles

A "list" is a separate primitive: a named collection of `tagIds` (dynamic — every doc carrying any/all of the listed tags is included on read) plus a small `docIds` extras array for ad-hoc additions. Bundles are static doc-id arrays. Use bundles when membership is hand-curated; use lists when membership should follow a tag rule and stay current.

```bash
# List the user's visible lists (table by default)
contextdock lists list
# id  shared|private  name  (tagCount tags [matchMode], docCount extras)

# Read a list's assembled markdown — tag-matched docs first, then extras
contextdock lists get l_xyz                                     # markdown to stdout
contextdock lists get l_xyz --variant keyPoints -o list.md       # condensed, write to file
contextdock lists get l_xyz --json                               # JSON envelope: tagIds, docIds, docCount, per-level tokens, docs[], truncated

# Create a list — tags are dynamic, docs are extras
contextdock lists create 'Quarterly review' --tags finance,ops --tag-match any --shared
contextdock lists create 'Compliance only' --tags compliance,policy --tag-match all   # docs must carry BOTH tags

# Patch — at least one mutable field is required (--name / --description / --tags / --tag-match / --docs / --shared|--no-shared)
contextdock lists update l_xyz --name 'Quarterly review (Q3)'
contextdock lists update l_xyz --tag-match all          # switch matching strategy
contextdock lists update l_xyz --no-shared              # revert to private

# Atomic membership ops (idempotent on both sides)
contextdock lists add-tags    l_xyz --tags finance
contextdock lists remove-tags l_xyz --tags finance
contextdock lists add-docs    l_xyz --docs d_extra1,d_extra2     # extras array only
contextdock lists remove-docs l_xyz --docs d_extra1               # extras only — drop a tag to remove tag-matched docs

# Duplicate (anyone who can read can clone — copy is created shared and owned by the caller; use `lists update <new-id> --no-shared` to make it private)
contextdock lists duplicate l_xyz

# Delete (--yes required, same pattern as bundles delete)
contextdock lists delete l_xyz --yes
```

Important footguns:

- `lists update --tags a,b` **REPLACES** the entire `tagIds` array, exactly like `docs update --tags`. Same for `--docs`. Use `lists add-tags` / `lists add-docs` to append safely.
- `lists remove-docs` only touches the **extras** array. Tag-matched docs cannot be removed individually — drop the tag from `tagIds` if you don't want them in the list anymore.
- A list's hard limits: `tagIds` ≤ **20**, extras `docIds` ≤ **100**. The API rejects oversize bodies with a 400.
- `LIST_NOT_FOUND` (exit 3) covers both missing lists and lists private to another user — never 403, by design.

#### Budgeting a bundle or list

`bundles get <id> --json` and `lists get <id> --json` both return an envelope enriched with `docCount`, a per-level `tokens` totals map, and a `docs[]` table — so you no longer have to re-resolve membership against `docs list --tag <t>` just to add up tokens. Bundle entries are `{id, title, tokens}`; list entries also carry `aiSummary` and `summary` (each `null` when the level hasn't been generated). The envelope also carries a `truncated` boolean — `true` when the resolved member set exceeds the detail cap (default 200 docs), in which case `docs[]` is capped but `docCount` and `tokens` still reflect the full set.

For lists specifically, the cache is recomputed synchronously on read if stale, so detail responses are always accurate. Bundle list endpoints (`bundles list`) and list list endpoints (`lists list`) include the cached `docCount` / `tokens` summary on every row but **not** the `docs[]` table — fetch the detail envelope when you need per-doc breakdowns.

```bash
# Pick the cheapest level that fits a budget — no content fetches, no membership resolution
contextdock lists get l_xyz --json | jq '.data.tokens'
# → { "original": 92000, "keyPoints": 41000, "summary": 12000, "aiSummary": 600 }

# Same shape on bundles
contextdock bundles get b_xyz --json | jq '.data.tokens.keyPoints'
```

`context` / `assemble_context` still accepts `--docs` / `--bundles` / `--tags` / `--query` — **not** a list ID. To assemble a list under budget, read the list's `tagIds`, then hand them to `context`:

```bash
contextdock lists get l_xyz --json | jq -r '.data.tagIds | join(",")'
# → finance,ops
contextdock context --tags finance,ops --budget 100000 --prefer keyPoints
```

**Watch the tag-match semantics gap:** `context --tags a,b` always uses **any** semantics (union — docs carrying tag `a` OR tag `b`). Lists with `tagMatch: "all"` cannot be replicated through `context --tags` directly. The detail-envelope `tokens` totals already honor the list's `tagMatch`, so for budget planning the envelope is the right read. For actual assembly under `tagMatch: "all"`, fetch the list's pre-assembled markdown via `contextdock lists get l_xyz --variant <v>` (the server applies the correct intersection).

### List tags — `tags list`

```bash
contextdock tags list           # plain text — one row per tag: "<id>  <name>" (with "  <color>" appended if a color is set)
contextdock tags list --json    # JSON envelope
```

Returns every distinct tag across all docs the key has access to. Useful before calling `docs list --tag <tag>` to confirm the spelling, or for tab-completion in shells.

## MCP stdio: stdout is sacred — and `mcp` is not a daemon

`contextdock mcp` hands stdin/stdout to the MCP SDK for JSON-RPC framing. The command writes **nothing** to stdout itself — anything printed there corrupts the protocol and the host (Claude Desktop, Cursor, Claude Code) silently disconnects. Status messages, if any, must go to stderr. **Do not add `console.log`, `print`, or `process.stdout.write` to the `mcp` command path** — and warn the user if they're patching it.

**Do NOT tell the user to "keep `contextdock mcp` running in a terminal".** It is a stdio subprocess that the host (Claude Desktop / Cursor / etc.) spawns, pipes JSON-RPC through, and kills on exit. Running it manually in a terminal does **nothing useful** — there is no listener for it to talk to, and the user will sit watching a silent process. The only correct way to start it is to register it in the host's MCP config and let the host spawn it.

### Claude Desktop config

Edit:
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`

Because the CLI is **not on npm** (and won't be), point at `node` + the absolute path to `dist/index.js` (or use `npm link` from `cli/` and reference the `contextdock` shim by name):

```json
{
  "mcpServers": {
    "contextdock": {
      "command": "node",
      "args": [
        "C:\\Software Projects\\Context Manager\\cli\\dist\\index.js",
        "mcp"
      ],
      "env": {
        "CONTEXTDOCK_API_KEY": "cdk_live_..."
      }
    }
  }
}
```

After `npm link` from the repo's `cli/` directory, the simpler form works (the `contextdock` shim resolves to the local checkout):

```json
{
  "mcpServers": {
    "contextdock": {
      "command": "contextdock",
      "args": ["mcp"],
      "env": { "CONTEXTDOCK_API_KEY": "cdk_live_..." }
    }
  }
}
```

The `env` block is **strongly recommended** even if `~/.contextdock/config.json` exists. It pins the key to this MCP server entry (so revoking the file doesn't silently break Claude Desktop), survives moving the config between machines, and makes the wiring auditable in one place. The config file is the fallback, not the primary path for host-spawned subprocesses.

Restart Claude Desktop. Verify by asking it to "list my ContextDock bundles" — that exercises the `list_bundles` MCP tool end-to-end.

## Errors & exit codes

Every CLI failure goes through `CliError` with one of five exit codes that mirror the API error taxonomy.

| Exit | Meaning | Typical API `code` field |
| --- | --- | --- |
| 1 | Generic / validation / network | `Validation failed`, `NO_FILTER_SPECIFIED`, `Network error: ...` |
| 2 | Auth | `KEY_INVALID`, `KEY_REVOKED`, `INSUFFICIENT_SCOPE`, "Not logged in" |
| 3 | Not found | `DOC_NOT_FOUND`, `BUNDLE_NOT_FOUND`, `LIST_NOT_FOUND` |
| 4 | Token budget | `BUDGET_TOO_SMALL`, `BUDGET_EXCEEDED` |
| 5 | Rate limit | `RATE_LIMIT_EXCEEDED` |

Important distinctions:

- `KEY_REVOKED` ≠ `KEY_INVALID`: the key was valid once but the owner revoked it. **Re-login won't help.** Mint a new key in **Settings → API Keys**.
- `INSUFFICIENT_SCOPE`: read-only key calling a write route. Mint a key with `write` scope (or `delete`) — there is no silent downgrade.
- Hidden docs/bundles always return `*_NOT_FOUND` (404), never 403 — this avoids leaking the existence of private resources owned by other users.
- `RATE_LIMIT_EXCEEDED`: read `X-RateLimit-Reset` (Unix epoch seconds) from the response and sleep until then. Limiter is per-key, shared across REST + MCP.

## When to ask the user

Ask the user to provide a key **only** when all of the following are true:

- `$CONTEXTDOCK_API_KEY` is unset (`echo $CONTEXTDOCK_API_KEY` empty).
- `~/.contextdock/config.json` does not exist.
- The user hasn't already pasted a key earlier in the conversation.

Even then, do **not** ask them to paste the key into chat. Tell them:

> Mint a key at <https://contextdock.web.app> → **Settings → API Keys → Create Key**, copy it, then run `contextdock login` and paste at the prompt. The CLI writes it to `~/.contextdock/config.json` for you.

`contextdock login` (no flags) reads the key off stdin so it never touches process arguments or shell history.

## Do NOT

- Do **not** write anything to stdout from the `mcp` command path — JSON-RPC owns it.
- Do **not** invent an OAuth flow or use Firebase ID tokens against `/api/agent/*` — that surface only accepts `cdk_live_...` keys, by design.
- Do **not** suggest `npm install -g @contextdock/cli` — the package is **deliberately not published to npm** and there is no plan to publish it. Point users at this page (<https://contextdock.web.app/cli-skill>), at `npm link` from the repo's `cli/` directory, or at the absolute `node dist/index.js` path.
- Do **not** run `npm publish` from the `cli/` workspace, even when "the version looks ready" — distribution is via the website + local install only.
- Do **not** echo, log, commit, or paste a `cdk_live_...` key into project files. If the user shares one in chat, remind them the transcript still has the original and they should rotate it in **Settings → API Keys**.
- Do **not** mix `/api/agent/*` (API key) and `/api/*` (Firebase ID token) auth. They are isolated by design — a key cannot mint more keys, create users, or change roles.
