From 04a38bb1017d92be5fd76675dc7e93aa4777c326 Mon Sep 17 00:00:00 2001 From: John Pruitt Date: Thu, 25 Jun 2026 13:54:47 -0500 Subject: [PATCH 1/3] feat(cli): make user API keys first-class in `me apikey` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invert the command so a personal access token (PAT) is the default and agents opt in via `--agent`: me apikey create [name] [--agent ] [--expires ] me apikey list [--agent ] - Drop `--self`; self is now the default. The old guard made `--self [name]` impossible, so PATs could never be named and two same-day unnamed PATs collided on `unique (member_id, name)`. - `name` is now the only positional (unambiguous). - Default name gains a random suffix (`cli--`) so repeated unnamed creates never collide. - `list` defaults to your own keys (was a required `` positional). Breaking, intentional, strict + silent: `me apikey create my-agent` mints a PAT named `my-agent`; agent provisioning moves to `--agent`. No server/protocol/DB/engine changes — the wire is already memberId-based. Sweeps agent-centric copy (`me apikey create ` → `--agent`) across CLI/server messages, the claude-plugin, and docs. Refs TNT-145 --- AUTH_DESIGN.md | 7 +- docs/cli/me-apikey.md | 48 ++++-- docs/cli/me-claude.md | 2 +- docs/cli/me-codex.md | 2 +- docs/cli/me-gemini.md | 2 +- docs/cli/me-login.md | 2 +- docs/cli/me-opencode.md | 2 +- docs/mcp-integration.md | 2 +- e2e/cli.e2e.test.ts | 43 ++++++ .../claude-plugin/.claude-plugin/plugin.json | 2 +- packages/claude-plugin/README.md | 2 +- packages/cli/commands/access.ts | 2 +- packages/cli/commands/agent.ts | 11 +- packages/cli/commands/apikey.ts | 146 +++++++++--------- packages/cli/commands/mcp.ts | 2 +- .../server/middleware/authenticate-space.ts | 2 +- scripts/integration-test.ts | 3 +- 17 files changed, 168 insertions(+), 112 deletions(-) diff --git a/AUTH_DESIGN.md b/AUTH_DESIGN.md index 6b60c9e9..2f208b63 100644 --- a/AUTH_DESIGN.md +++ b/AUTH_DESIGN.md @@ -35,7 +35,8 @@ the login components). A human can hold several credentials by surface: a cookie for the browser, OAuth tokens for the interactive CLI (`me login`), and a **personal access token** for -headless/SSH/VM use (`me apikey create --self`). All share one identity +headless/SSH/VM use (`me apikey create`, which targets yourself by default). +All share one identity (the `auth.users` / `core.principal` row). A user PAT carries the user's *full* authority on the data plane but is barred from minting/revoking credentials — see flow E. (An agent key is the right choice for sandboxes; a user PAT is for "be me, @@ -254,8 +255,8 @@ gated by `requireOwnMember` — the caller's own user or an owned agent): `space.list`) — but not account management (it owns no agents/keys/spaces and is never an admin), which the user RPC denies per-method. - **User PAT** (kind `'u'`, the caller's own principal) — "be me, headless" - (`me apikey create --self`; explicit opt-in, since it's a full-access - credential). Authenticates as + (`me apikey create`, which targets yourself by default; an agent key is the + `--agent` opt-in). Authenticates as the **user** with their full grants, on **both** the memory RPC and the user RPC — *except* it cannot mint or revoke keys (`apiKey.create` / `apiKey.delete` stay session-only). That carve-out keeps a leaked key from minting a sibling diff --git a/docs/cli/me-apikey.md b/docs/cli/me-apikey.md index 3e9fc7da..738bd655 100644 --- a/docs/cli/me-apikey.md +++ b/docs/cli/me-apikey.md @@ -1,17 +1,20 @@ # me apikey -Manage API keys. +Manage API keys — your own **personal access token (PAT)**, or a key for one of your **agents** (`--agent`). -API keys are how **agents** authenticate. Each key belongs to one of your agents and is **global** — not bound to a space. The same key works in any space the agent has been admitted to; the space comes from the `X-Me-Space` header (`--space` / `ME_SPACE`). Keys are formatted `me..`. +An API key is a global, per-principal credential — **not** bound to a space. The same key works in any space its principal has been admitted to; the space comes from the `X-Me-Space` header (`--space` / `ME_SPACE`). Keys are formatted `me..`. -Humans authenticate with a session (`me login`), not an API key. These commands authenticate with your **session**. +There are two kinds, distinguished only by who they act as: -The CLI never persists API keys. A created key is printed **once** for you to place where the agent runs (typically via the `ME_API_KEY` environment variable). The alias `me apikey revoke` is equivalent to `me apikey delete`. +- **Personal access token** (default) — acts as **you**, for headless/CLI use (a VM, SSH, CI) where your `me login` session isn't available. Full access as you, but it **cannot** manage keys (minting/revoking always needs a session). +- **Agent key** (`--agent `) — acts as one of your agents, for a dedicated/unattended agent install. + +Minting and revoking keys authenticate with your **session** (`me login`); an API key can't mint or revoke keys. The CLI never persists API keys — a created key is printed **once** for you to place where it's used (typically the `ME_API_KEY` environment variable). The alias `me apikey revoke` is equivalent to `me apikey delete`. ## Commands -- [me apikey create](#me-apikey-create) -- mint a key for an agent -- [me apikey list](#me-apikey-list) -- list an agent's keys +- [me apikey create](#me-apikey-create) -- mint a personal access token (or an agent key) +- [me apikey list](#me-apikey-list) -- list your keys (or an agent's) - [me apikey get](#me-apikey-get) -- show key metadata - [me apikey delete](#me-apikey-delete) -- delete (revoke) a key @@ -19,36 +22,47 @@ The CLI never persists API keys. A created key is printed **once** for you to pl ## me apikey create -Mint a new API key for one of your agents. The raw key is shown only once — store it securely. +Mint a new API key. With no `--agent`, mints a **personal access token** for yourself; with `--agent`, mints a key for that agent. The raw key is shown only once — store it securely. ``` -me apikey create [name] [--expires ] +me apikey create [name] [--agent ] [--expires ] ``` | Argument | Required | Description | |----------|----------|-------------| -| `agent` | yes | Agent id or name. | -| `name` | no | Key name (auto-generated if omitted). | +| `name` | no | Key name (auto-generated as `cli--` if omitted). | | Option | Description | |--------|-------------| +| `--agent ` | Mint a key for one of your agents (id or name) instead of yourself. | | `--expires ` | Expiration timestamp (ISO 8601). | +Names are unique per principal, so you can't mint two keys with the same name for the same target. The auto-generated default carries a random suffix, so repeated `me apikey create` calls never collide. + +```bash +# A personal access token for yourself (e.g. to use headlessly in a VM) +me apikey create +me apikey create my-laptop # …with a name + +# A key for one of your agents +me apikey create --agent claude-code-agent plugin-key +``` + --- ## me apikey list -List an agent's API keys (metadata only — never the secret). Alias: `me apikey ls`. +List API keys (metadata only — never the secret). With no `--agent`, lists **your own** keys; with `--agent`, lists that agent's keys. Alias: `me apikey ls`. ``` -me apikey list +me apikey list [--agent ] ``` -| Argument | Required | Description | -|----------|----------|-------------| -| `agent` | yes | Agent id or name. | +| Option | Description | +|--------|-------------| +| `--agent ` | List one of your agents' keys (id or name) instead of your own. | -Displays a table of keys with ID, name, last-used date, and expiry. +Displays a table of keys with ID, name, created date, and expiry. --- @@ -84,5 +98,5 @@ me apikey delete [-y] ## See also -- [`me agent`](me-agent.md) -- create the agents that hold these keys and add them to spaces. +- [`me agent`](me-agent.md) -- create the agents that hold `--agent` keys and add them to spaces. - [MCP Integration](../mcp-integration.md) -- supply a key to an MCP-connected agent via `--api-key` or `ME_API_KEY`. diff --git a/docs/cli/me-claude.md b/docs/cli/me-claude.md index e6c0a263..f02472cc 100644 --- a/docs/cli/me-claude.md +++ b/docs/cli/me-claude.md @@ -41,7 +41,7 @@ Pass `--mcp-only` to skip the plugin and register just the `me` MCP server (no h | `--server ` | Pin a server. Default: use your `me login` server at runtime. | | `-s, --scope ` | Claude Code config scope: `local`, `user`, or `project`. Default: `user`. | -Credential handling: by default (a personal install) nothing is pinned, so the plugin (and the MCP server) uses your `me login` session, server, and active space, resolved from the OS keychain / `~/.config/me` at runtime — so it follows `me login` / `me space use` and survives re-login. Pass `--server` / `--space` to pin either. Pass `--api-key` (mint one with `me apikey create ` or `--self`) for a **headless** install that can't reach your keychain — since there's no session to fall back to, an api key bakes in a fixed server + space + key together. The space is resolved from `--space`, `ME_SPACE`, or your active space (whichever is set — install errors if none, since a global key has no active space to fall back to at runtime), and `--server` defaults to your resolved server. +Credential handling: by default (a personal install) nothing is pinned, so the plugin (and the MCP server) uses your `me login` session, server, and active space, resolved from the OS keychain / `~/.config/me` at runtime — so it follows `me login` / `me space use` and survives re-login. Pass `--server` / `--space` to pin either. Pass `--api-key` (mint one with `me apikey create` for a personal access token, or `me apikey create --agent ` for an agent) for a **headless** install that can't reach your keychain — since there's no session to fall back to, an api key bakes in a fixed server + space + key together. The space is resolved from `--space`, `ME_SPACE`, or your active space (whichever is set — install errors if none, since a global key has no active space to fall back to at runtime), and `--server` defaults to your resolved server. The `--scope` flag mirrors `claude plugin install --scope` / `claude mcp add --scope`: diff --git a/docs/cli/me-codex.md b/docs/cli/me-codex.md index ea6eff47..c5ce0dd5 100644 --- a/docs/cli/me-codex.md +++ b/docs/cli/me-codex.md @@ -23,7 +23,7 @@ me codex install [options] | `--space ` | Pin a space. Default: resolve `ME_SPACE` / active space at runtime. | | `--server ` | Server URL to embed in the MCP config. | -By default only the server URL is baked into the config: at runtime `me mcp` uses your `me login` session (resolved from the OS keychain / `~/.config/me` each run, so it survives re-login) and your active space (set by `me space use` / `ME_SPACE`). Pass `--api-key` (mint one with `me apikey create `) for a headless agent that cannot reach your keychain; that bakes the key and requires a pinned `--space`. +By default only the server URL is baked into the config: at runtime `me mcp` uses your `me login` session (resolved from the OS keychain / `~/.config/me` each run, so it survives re-login) and your active space (set by `me space use` / `ME_SPACE`). Pass `--api-key` (mint one with `me apikey create --agent `, or `me apikey create` for a personal access token) for a headless agent that cannot reach your keychain; that bakes the key and requires a pinned `--space`. For manual MCP client configuration, see [MCP Integration](../mcp-integration.md). diff --git a/docs/cli/me-gemini.md b/docs/cli/me-gemini.md index 361990aa..2f6357e1 100644 --- a/docs/cli/me-gemini.md +++ b/docs/cli/me-gemini.md @@ -23,6 +23,6 @@ me gemini install [options] | `--server ` | Server URL to embed in the MCP config. | | `-s, --scope ` | Gemini CLI config scope: `user` or `project`. Default: `user`. | -By default only the server URL is baked into the config: at runtime `me mcp` uses your `me login` session (resolved from the OS keychain / `~/.config/me` each run, so it survives re-login) and your active space (set by `me space use` / `ME_SPACE`). Pass `--api-key` (mint one with `me apikey create `) for a headless agent that cannot reach your keychain; that bakes the key and requires a pinned `--space`. +By default only the server URL is baked into the config: at runtime `me mcp` uses your `me login` session (resolved from the OS keychain / `~/.config/me` each run, so it survives re-login) and your active space (set by `me space use` / `ME_SPACE`). Pass `--api-key` (mint one with `me apikey create --agent `, or `me apikey create` for a personal access token) for a headless agent that cannot reach your keychain; that bakes the key and requires a pinned `--space`. For manual MCP client configuration, see [MCP Integration](../mcp-integration.md). diff --git a/docs/cli/me-login.md b/docs/cli/me-login.md index 9592e134..ec54bb4f 100644 --- a/docs/cli/me-login.md +++ b/docs/cli/me-login.md @@ -30,7 +30,7 @@ Login also runs the same version compatibility check as `me version` before open - The session token is stored in your OS keychain when one is available (macOS `security`, Linux `secret-tool`); otherwise it falls back to `~/.config/me/credentials.yaml` (mode 0600). Set `ME_NO_KEYCHAIN=1` to force the file fallback. - Non-secret settings (default server and per-server active space) live in `~/.config/me/config.yaml`. -- **API keys are for agents, not humans** — `me login` never creates one. Mint agent keys with [`me apikey create`](me-apikey.md#me-apikey-create). +- **Humans authenticate with a session, not an API key** — `me login` never creates a key. For headless/CLI use where a session isn't available you can mint a **personal access token** (acts as you) with [`me apikey create`](me-apikey.md#me-apikey-create); agent keys come from `me apikey create --agent `. - Use [`me logout`](me-logout.md) to clear the session; the non-secret config is kept so re-login resumes. ## See also diff --git a/docs/cli/me-opencode.md b/docs/cli/me-opencode.md index 2d283a5d..0f546962 100644 --- a/docs/cli/me-opencode.md +++ b/docs/cli/me-opencode.md @@ -23,7 +23,7 @@ me opencode install [options] | `--space ` | Pin a space. Default: resolve `ME_SPACE` / active space at runtime. | | `--server ` | Server URL to embed in the MCP config. | -By default only the server URL is baked into the config: at runtime `me mcp` uses your `me login` session (resolved from the OS keychain / `~/.config/me` each run, so it survives re-login) and your active space (set by `me space use` / `ME_SPACE`). Pass `--api-key` (mint one with `me apikey create `) for a headless agent that cannot reach your keychain; that bakes the key and requires a pinned `--space`. +By default only the server URL is baked into the config: at runtime `me mcp` uses your `me login` session (resolved from the OS keychain / `~/.config/me` each run, so it survives re-login) and your active space (set by `me space use` / `ME_SPACE`). Pass `--api-key` (mint one with `me apikey create --agent `, or `me apikey create` for a personal access token) for a headless agent that cannot reach your keychain; that bakes the key and requires a pinned `--space`. For manual MCP client configuration, see [MCP Integration](../mcp-integration.md). diff --git a/docs/mcp-integration.md b/docs/mcp-integration.md index b2357366..f05b49ad 100644 --- a/docs/mcp-integration.md +++ b/docs/mcp-integration.md @@ -20,7 +20,7 @@ Each `me mcp` instance is locked to a single **space**, carried as the `X-Me-Spa ### Prerequisites -Log in with `me login` and select a space — `me whoami` shows your active space and identity. That session is enough to run the MCP server locally. For an unattended or dedicated-agent install, mint an API key with `me apikey create ` and pass it with `--api-key`. +Log in with `me login` and select a space — `me whoami` shows your active space and identity. That session is enough to run the MCP server locally. For an unattended or dedicated-agent install, mint an API key — `me apikey create` for a personal access token (acts as you) or `me apikey create --agent ` for a dedicated agent — and pass it with `--api-key`. The server defaults to `https://api.memory.build`. Pass `--server ` only if you're running a self-hosted server. diff --git a/e2e/cli.e2e.test.ts b/e2e/cli.e2e.test.ts index b1501977..e040ec7a 100644 --- a/e2e/cli.e2e.test.ts +++ b/e2e/cli.e2e.test.ts @@ -660,6 +660,7 @@ describe.skipIf( const key = await meJson<{ id: string; key: string }>([ "apikey", "create", + "--agent", agent.id, ]); expect(key.key).toMatch(/^me\./); @@ -697,6 +698,48 @@ describe.skipIf( expect(denied.code).not.toBe(0); }); + test("7b. personal access tokens: self-default create/list, no same-day collision", async () => { + // `me apikey create` with no agent mints a PAT for the caller. Two unnamed + // PATs minted back-to-back must NOT collide on `unique (member_id, name)` — + // the default name carries a random suffix. (This is the bug TNT-145 fixes.) + const pat1 = await meJson<{ id: string; key: string }>([ + "apikey", + "create", + ]); + const pat2 = await meJson<{ id: string; key: string }>([ + "apikey", + "create", + ]); + expect(pat1.key).toMatch(/^me\./); + expect(pat2.key).toMatch(/^me\./); + expect(pat2.id).not.toBe(pat1.id); + + // A named PAT is now possible (the old `--self` shape couldn't take a name). + const named = await meJson<{ id: string }>([ + "apikey", + "create", + `pat-${rand()}`, + ]); + + // `me apikey list` (no --agent) lists the caller's OWN keys — all three. + const { apiKeys } = await meJson<{ apiKeys: { id: string }[] }>([ + "apikey", + "list", + ]); + const ids = new Set(apiKeys.map((k) => k.id)); + expect(ids.has(pat1.id)).toBe(true); + expect(ids.has(pat2.id)).toBe(true); + expect(ids.has(named.id)).toBe(true); + + // The PAT authenticates as the user themselves (kind "u"), no session. + const patEnv = { ME_API_KEY: pat1.key, ME_SESSION_TOKEN: "" }; + const who = await meJson<{ identity: { kind: string } }>( + ["whoami"], + patEnv, + ); + expect(who.identity.kind).toBe("u"); + }); + test("8. `me claude import` backfills work that predates the hook", async () => { // The scenario: a user does a bunch of Claude Code work BEFORE installing // the capture hook (no hook fires for it), then installs the hook (which diff --git a/packages/claude-plugin/.claude-plugin/plugin.json b/packages/claude-plugin/.claude-plugin/plugin.json index 538c23b2..fe81db7b 100644 --- a/packages/claude-plugin/.claude-plugin/plugin.json +++ b/packages/claude-plugin/.claude-plugin/plugin.json @@ -13,7 +13,7 @@ "api_key": { "type": "string", "title": "API key (optional)", - "description": "Optional. Leave blank to use your `me login` session — the plugin falls back to it automatically. Set an API key to attribute captures to a dedicated agent instead: create one with `me apikey create` (or have an admin create one with restricted privileges).", + "description": "Optional. Leave blank to use your `me login` session — the plugin falls back to it automatically. Set an API key to attribute captures to a dedicated agent instead: create one with `me apikey create --agent ` (or have an admin create one with restricted privileges).", "sensitive": true, "required": false }, diff --git a/packages/claude-plugin/README.md b/packages/claude-plugin/README.md index 9979c33c..1c00941a 100644 --- a/packages/claude-plugin/README.md +++ b/packages/claude-plugin/README.md @@ -45,7 +45,7 @@ me agent add claude-code-agent me access grant claude-code-agent share.projects w # 3. Mint an API key for that agent -me apikey create claude-code-agent plugin-key +me apikey create --agent claude-code-agent plugin-key # → prints the raw key once; paste it into the plugin's api_key config ``` diff --git a/packages/cli/commands/access.ts b/packages/cli/commands/access.ts index 34f3272a..7ebbc4d0 100644 --- a/packages/cli/commands/access.ts +++ b/packages/cli/commands/access.ts @@ -201,7 +201,7 @@ function createAccessMineCommand(): Command { ); }); } catch (error) { - handleError(error, fmt, { sessionServer: creds.server }); + handleError(error, fmt, { creds }); } }); } diff --git a/packages/cli/commands/agent.ts b/packages/cli/commands/agent.ts index 77085868..56e1af2e 100644 --- a/packages/cli/commands/agent.ts +++ b/packages/cli/commands/agent.ts @@ -2,9 +2,8 @@ * me agent — manage your agents (global service accounts). * * Agents are owned by you and live across spaces; their lifecycle is on the - * user endpoint. Bringing an agent into the active space and minting its - * (space-bound) api key are space operations — see `me agent add` and - * `me apikey create`. + * user endpoint. Bringing an agent into the active space and minting its api + * key — see `me agent add` and `me apikey create --agent`. * * - me agent list: list your agents * - me agent create : create an agent @@ -74,7 +73,7 @@ function createAgentCreateCommand(): Command { output({ id, name }, fmt, () => { clack.log.success(`Created agent '${name}' (${id})`); clack.log.info( - "Add it to a space with 'me agent add', then mint a key with 'me apikey create'.", + `Add it to a space with 'me agent add', then mint a key with 'me apikey create --agent ${name}'.`, ); }); } catch (error) { @@ -151,7 +150,9 @@ function createAgentAddCommand(): Command { const result = await memory.principal.add({ principalId: id }); output({ agentId: id, ...result }, fmt, () => { clack.log.success(`Added agent ${agent} to the space.`); - clack.log.info("Mint a key with 'me apikey create'."); + clack.log.info( + `Mint a key with 'me apikey create --agent ${agent}'.`, + ); }); } catch (error) { handleError(error, fmt, { creds, scope: "space" }); diff --git a/packages/cli/commands/apikey.ts b/packages/cli/commands/apikey.ts index a3a6ea8d..ae945d93 100644 --- a/packages/cli/commands/apikey.ts +++ b/packages/cli/commands/apikey.ts @@ -1,19 +1,21 @@ /** - * me apikey — manage API keys (for your agents, or yourself via --self). + * me apikey — manage API keys, for yourself (a personal access token) or your + * agents (`--agent`). * * Keys are global: a key works in any space its principal has been admitted to. * The plaintext key is shown exactly once, by `create`. No revoke state — delete - * is the removal. A `--self` key is a personal access token (full access as you, - * headless); minting/revoking always requires a `me login` session. + * is the removal. A personal access token has full access as you (headless/CLI + * use); minting/revoking always requires a `me login` session. * - * - me apikey create [name] [--expires ]: mint an agent key - * - me apikey create --self [name]: mint a personal access token - * - me apikey list : list an agent's keys - * - me apikey get : key metadata - * - me apikey delete : delete (revoke) a key + * - me apikey create [name] [--expires ]: mint a personal access token (you) + * - me apikey create --agent [name]: mint a key for one of your agents + * - me apikey list [--agent ]: list your keys (or an agent's) + * - me apikey get : key metadata + * - me apikey delete : delete (revoke) a key * * is an agent id or name; is an api-key id. */ +import { randomBytes } from "node:crypto"; import * as clack from "@clack/prompts"; import { Command } from "commander"; import { resolveCredentials } from "../credentials.ts"; @@ -26,82 +28,73 @@ import { resolveAgentId, } from "../util.ts"; +/** + * Default name for an unnamed key. The random suffix keeps two unnamed keys + * minted for the same principal on the same day from colliding on the + * `unique (member_id, name)` constraint. + */ +function defaultKeyName(): string { + const date = new Date().toISOString().slice(0, 10); + return `cli-${date}-${randomBytes(2).toString("hex")}`; +} + function createApiKeyCreateCommand(): Command { return new Command("create") - .description("mint an API key for one of your agents") - .argument("[agent]", "agent id or name") + .description("mint a personal access token (or an agent key with --agent)") .argument("[name]", "key name (auto-generated if omitted)") - .option("--expires ", "expiration timestamp (ISO 8601)") .option( - "--self", - "mint a personal access token for YOURSELF (headless/CLI use) — full access as you; can't manage keys", + "--agent ", + "mint a key for one of your agents instead of yourself (agent id or name)", ) - .action( - async ( - agent: string | undefined, - name: string | undefined, - opts, - cmd, - ) => { - const globalOpts = cmd.optsWithGlobals(); - const creds = resolveCredentials(globalOpts.server); - const fmt = getOutputFormat(globalOpts); - requireSession(creds, fmt); - - // Minting a PAT must be explicit (`--self`) — it's a full-access - // credential, so it never happens implicitly. Require exactly one target. - if (opts.self && agent) { - handleError(new Error("Pass an agent or --self, not both."), fmt); - } - if (!opts.self && !agent) { - handleError( - new Error( - "Specify an agent (e.g. `me apikey create `), or --self to mint a personal access token for yourself.", - ), - fmt, - ); - } + .option("--expires ", "expiration timestamp (ISO 8601)") + .action(async (name: string | undefined, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const creds = resolveCredentials(globalOpts.server); + const fmt = getOutputFormat(globalOpts); + requireSession(creds, fmt); - const user = buildUserClient(creds); - const keyName = name ?? `cli-${new Date().toISOString().slice(0, 10)}`; + const user = buildUserClient(creds); + const keyName = name ?? defaultKeyName(); - try { - // --self → the caller's own user principal (resolved via whoami); - // else the named agent. - const memberId = opts.self - ? (await user.whoami()).id - : await resolveAgentId(user, agent as string, fmt); - const result = await user.apiKey.create({ - memberId, - name: keyName, - expiresAt: opts.expires ?? null, - }); - output(result, fmt, () => { - clack.log.success(`Created API key '${keyName}'`); - console.log(` ID: ${result.id}`); - clack.note( - result.key, - "API key — save it now; it won't be shown again", - ); - clack.log.info( - opts.self - ? "Personal access token — use it as ME_API_KEY for headless/CLI access as you (e.g. in a VM or over SSH). Managing keys (create/revoke) still requires `me login`." - : "Give it to the agent via ME_API_KEY or its MCP config. It works in any space the agent is a member of.", - ); - }); - } catch (error) { - handleError(error, fmt, { creds }); - } - }, - ); + try { + // No --agent → the caller's own user principal (a PAT, resolved via + // whoami); --agent → the named agent. + const memberId = opts.agent + ? await resolveAgentId(user, opts.agent, fmt) + : (await user.whoami()).id; + const result = await user.apiKey.create({ + memberId, + name: keyName, + expiresAt: opts.expires ?? null, + }); + output(result, fmt, () => { + clack.log.success(`Created API key '${keyName}'`); + console.log(` ID: ${result.id}`); + clack.note( + result.key, + "API key — save it now; it won't be shown again", + ); + clack.log.info( + opts.agent + ? "Give it to the agent via ME_API_KEY or its MCP config. It works in any space the agent is a member of." + : "Personal access token — use it as ME_API_KEY for headless/CLI access as you (e.g. in a VM or over SSH). It works in any space you're a member of. Managing keys (create/revoke) still requires `me login`.", + ); + }); + } catch (error) { + handleError(error, fmt, { creds }); + } + }); } function createApiKeyListCommand(): Command { return new Command("list") .alias("ls") - .description("list an agent's API keys") - .argument("", "agent id or name") - .action(async (agent: string, _opts, cmd) => { + .description("list your API keys (or an agent's with --agent)") + .option( + "--agent ", + "list one of your agents' keys instead of your own (agent id or name)", + ) + .action(async (opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); const creds = resolveCredentials(globalOpts.server); const fmt = getOutputFormat(globalOpts); @@ -109,8 +102,11 @@ function createApiKeyListCommand(): Command { const user = buildUserClient(creds); try { - const agentId = await resolveAgentId(user, agent, fmt); - const { apiKeys } = await user.apiKey.list({ memberId: agentId }); + // No --agent → your own keys (resolved via whoami); --agent → the agent. + const memberId = opts.agent + ? await resolveAgentId(user, opts.agent, fmt) + : (await user.whoami()).id; + const { apiKeys } = await user.apiKey.list({ memberId }); output({ apiKeys }, fmt, () => { if (apiKeys.length === 0) { console.log(" No API keys."); @@ -195,7 +191,7 @@ function createApiKeyDeleteCommand(): Command { export function createApiKeyCommand(): Command { const apikey = new Command("apikey").description( - "manage API keys (for your agents, or yourself via --self)", + "manage API keys (your own personal access token, or your agents' via --agent)", ); apikey.addCommand(createApiKeyCreateCommand()); apikey.addCommand(createApiKeyListCommand()); diff --git a/packages/cli/commands/mcp.ts b/packages/cli/commands/mcp.ts index 510c3242..b0415368 100644 --- a/packages/cli/commands/mcp.ts +++ b/packages/cli/commands/mcp.ts @@ -73,7 +73,7 @@ function createMcpRunAction() { // can take this shape; a session token never does.) if (apiKey && isLegacyApiKey(apiKey)) { console.error( - "Error: this API key uses the old space-scoped format (me...) and no longer works. Recreate it with 'me apikey create ', then update ME_API_KEY or your MCP config.", + "Error: this API key uses the old space-scoped format (me...) and no longer works. Recreate it with 'me apikey create --agent ', then update ME_API_KEY or your MCP config.", ); process.exit(1); } diff --git a/packages/server/middleware/authenticate-space.ts b/packages/server/middleware/authenticate-space.ts index ebf9f36d..4069b076 100644 --- a/packages/server/middleware/authenticate-space.ts +++ b/packages/server/middleware/authenticate-space.ts @@ -156,7 +156,7 @@ async function authenticateSpaceInner( return { ok: false, error: error( - "This API key uses the old space-scoped format (me...) and no longer works. Recreate it with `me apikey create `, then update ME_API_KEY or your MCP/plugin config.", + "This API key uses the old space-scoped format (me...) and no longer works. Recreate it with `me apikey create --agent `, then update ME_API_KEY or your MCP/plugin config.", 401, "LEGACY_API_KEY", ), diff --git a/scripts/integration-test.ts b/scripts/integration-test.ts index 1f38cfac..b42303ce 100644 --- a/scripts/integration-test.ts +++ b/scripts/integration-test.ts @@ -733,7 +733,7 @@ async function phase4_rbac(): Promise { const { json } = await runJson<{ apiKey: { id: string; name: string }; rawKey: string; - }>(["apikey", "create", "itest_user2", "itest-key"]); + }>(["apikey", "create", "--agent", "itest_user2", "itest-key"]); expect(typeof json.apiKey?.id === "string", "apikey.id"); expect(typeof json.rawKey === "string", "rawKey"); apiKeyId = json.apiKey.id; @@ -743,6 +743,7 @@ async function phase4_rbac(): Promise { const { json } = await runJson<{ apiKeys: { id: string }[] }>([ "apikey", "list", + "--agent", "itest_user2", ]); if (apiKeyId) { From eb0746f17b298dcd6d48931fe4499970e2589206 Mon Sep 17 00:00:00 2001 From: John Pruitt Date: Thu, 25 Jun 2026 14:06:07 -0500 Subject: [PATCH 2/3] fix: increase randomness in auto-generated api key names Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: John Pruitt --- packages/cli/commands/apikey.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/commands/apikey.ts b/packages/cli/commands/apikey.ts index ae945d93..c56638fd 100644 --- a/packages/cli/commands/apikey.ts +++ b/packages/cli/commands/apikey.ts @@ -35,7 +35,7 @@ import { */ function defaultKeyName(): string { const date = new Date().toISOString().slice(0, 10); - return `cli-${date}-${randomBytes(2).toString("hex")}`; + return `cli-${date}-${randomBytes(6).toString("hex")}`; } function createApiKeyCreateCommand(): Command { From 07a231e0575403711e413da1713002d74d48ca41 Mon Sep 17 00:00:00 2001 From: John Pruitt Date: Thu, 25 Jun 2026 14:06:25 -0500 Subject: [PATCH 3/3] chore: clarify comments Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: John Pruitt --- docs/cli/me-apikey.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/me-apikey.md b/docs/cli/me-apikey.md index 738bd655..1c1808b6 100644 --- a/docs/cli/me-apikey.md +++ b/docs/cli/me-apikey.md @@ -37,7 +37,7 @@ me apikey create [name] [--agent ] [--expires ] | `--agent ` | Mint a key for one of your agents (id or name) instead of yourself. | | `--expires ` | Expiration timestamp (ISO 8601). | -Names are unique per principal, so you can't mint two keys with the same name for the same target. The auto-generated default carries a random suffix, so repeated `me apikey create` calls never collide. +Names are unique per principal, so you can't mint two keys with the same name for the same target. The auto-generated default carries a random suffix, making repeated `me apikey create` calls extremely unlikely to collide. ```bash # A personal access token for yourself (e.g. to use headlessly in a VM)