Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/cli/me-apikey.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Minting and revoking keys authenticate with your **session** (`me login`); an AP
Mint a new API key. With no target option, mints a **personal access token** for yourself; with `--agent`, mints a key for that agent; with `--service`, mints a key for that service account in the active space. The raw key is shown only once — store it securely.

```
me apikey create [name] [--agent <agent> | --service <service>] [--expires <timestamp>]
me apikey create [name] [--agent <agent> | --service <service>] [--expires <timestamp> | --ttl <duration>]
```

| Argument | Required | Description |
Expand All @@ -38,13 +38,15 @@ me apikey create [name] [--agent <agent> | --service <service>] [--expires <time
| `--agent <agent>` | Mint a key for one of your agents (id or name) instead of yourself. |
| `--service <service>` | Mint a key for a service account (id or name) in the active space. |
| `--expires <timestamp>` | Expiration timestamp (ISO 8601). |
| `--ttl <duration>` | Expiration from now, e.g. `30d`, `24h`, `30m`. |

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)
me apikey create
me apikey create my-laptop # …with a name
me apikey create vm-key --ttl 30d # expires 30 days from now

# A key for one of your agents
me apikey create --agent claude-code-agent plugin-key
Expand Down
36 changes: 35 additions & 1 deletion e2e/cli.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1080,7 +1080,40 @@ describe.skipIf(
`pat-${rand()}`,
]);

// `me apikey list` (no --agent) lists the caller's OWN keys — all three.
const ttlStart = Date.now();
const ttl = await meJson<{ id: string }>([
"apikey",
"create",
`ttl-${rand()}`,
"--ttl",
"30d",
]);
const fetchedTtl = await meJson<{
apiKey: { id: string; expiresAt: string | null } | null;
}>(["apikey", "get", ttl.id]);
expect(fetchedTtl.apiKey?.id).toBe(ttl.id);
expect(fetchedTtl.apiKey?.expiresAt).toBeTruthy();
const expiresAt = Date.parse(fetchedTtl.apiKey?.expiresAt ?? "");
expect(expiresAt).toBeGreaterThanOrEqual(
ttlStart + 30 * 86_400_000 - 5_000,
);
expect(expiresAt).toBeLessThanOrEqual(Date.now() + 30 * 86_400_000 + 5_000);

const conflictingExpiry = await me([
"apikey",
"create",
`bad-expiry-${rand()}`,
"--ttl",
"30d",
"--expires",
"2099-01-01T00:00:00.000Z",
]);
expect(conflictingExpiry.code).not.toBe(0);
expect(`${conflictingExpiry.stdout}${conflictingExpiry.stderr}`).toContain(
"Use only one expiration option",
);

// `me apikey list` (no --agent) lists the caller's OWN keys.
const { apiKeys } = await meJson<{ apiKeys: { id: string }[] }>([
"apikey",
"list",
Expand All @@ -1089,6 +1122,7 @@ describe.skipIf(
expect(ids.has(pat1.id)).toBe(true);
expect(ids.has(pat2.id)).toBe(true);
expect(ids.has(named.id)).toBe(true);
expect(ids.has(ttl.id)).toBe(true);

// The PAT authenticates as the user themselves (kind "u"), no session.
const patEnv = { ME_API_KEY: pat1.key, ME_SESSION_TOKEN: "" };
Expand Down
54 changes: 46 additions & 8 deletions packages/cli/commands/apikey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* 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 <ts>]: mint a personal access token (you)
* - me apikey create [name] [--expires <ts>|--ttl <duration>]: mint a PAT (you)
* - me apikey create --agent <agent> [name]: mint a key for one of your agents
* - me apikey create --service <svc> [name]: mint a service-account key
* - me apikey list [--agent <agent>|--service <svc>]
Expand All @@ -20,7 +20,12 @@ import { randomBytes } from "node:crypto";
import * as clack from "@clack/prompts";
import { Command } from "commander";
import { resolveCredentials } from "../credentials.ts";
import { getOutputFormat, output, table } from "../output.ts";
import {
getOutputFormat,
type OutputFormat,
output,
table,
} from "../output.ts";
import {
buildUserClient,
handleError,
Expand All @@ -45,19 +50,50 @@ function defaultKeyName(): string {
function assertSingleTarget(
agent: string | undefined,
service: string | undefined,
fmt: ReturnType<typeof getOutputFormat>,
fmt: OutputFormat,
): void {
if (agent === undefined || service === undefined) return;
const msg = "Use only one key target: --agent or --service, not both.";
if (fmt === "text") clack.log.error(msg);
else output({ error: msg }, fmt, () => {});
failWith("Use only one key target: --agent or --service, not both.", fmt);
}

function failWith(message: string, fmt: OutputFormat): never {
if (fmt === "text") clack.log.error(message);
else output({ error: message }, fmt, () => {});
process.exit(1);
}

/** Parse a duration like "30d" / "24h" / "30m" into an ISO expiry timestamp. */
function parseTtl(raw: string, fmt: OutputFormat): string {
const m = /^(\d+)([dhm])$/.exec(raw.trim());
if (!m) {
failWith(`Invalid --ttl '${raw}'. Use <n>d | <n>h | <n>m (e.g. 30d).`, fmt);
}
const n = Number(m?.[1]);
if (!Number.isSafeInteger(n) || n <= 0) {
failWith(`Invalid --ttl '${raw}'. Use a positive duration like 30d.`, fmt);
}
const unitMs = { d: 86_400_000, h: 3_600_000, m: 60_000 }[m?.[2] ?? "d"] ?? 0;
return new Date(Date.now() + n * unitMs).toISOString();
}

function resolveExpiresAt(
opts: { expires?: string; ttl?: string },
fmt: OutputFormat,
): string | null {
if (opts.expires !== undefined && opts.ttl !== undefined) {
failWith(
"Use only one expiration option: --expires or --ttl, not both.",
fmt,
);
}
if (opts.ttl !== undefined) return parseTtl(opts.ttl, fmt);
return opts.expires ?? null;
}

async function resolveApiKeyTarget(
user: ReturnType<typeof buildUserClient>,
creds: ReturnType<typeof resolveCredentials>,
fmt: ReturnType<typeof getOutputFormat>,
fmt: OutputFormat,
opts: { agent?: string; service?: string },
): Promise<{ memberId: string; targetKind: "user" | "agent" | "service" }> {
assertSingleTarget(opts.agent, opts.service, fmt);
Expand Down Expand Up @@ -98,6 +134,7 @@ function createApiKeyCreateCommand(): Command {
"mint a key for a service account in the active space (id or name)",
)
.option("--expires <timestamp>", "expiration timestamp (ISO 8601)")
.option("--ttl <duration>", "expiration from now, e.g. 30d | 24h | 30m")
.action(async (name: string | undefined, opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const creds = resolveCredentials(globalOpts.server);
Expand All @@ -108,6 +145,7 @@ function createApiKeyCreateCommand(): Command {
const keyName = name ?? defaultKeyName();

try {
const expiresAt = resolveExpiresAt(opts, fmt);
const { memberId, targetKind } = await resolveApiKeyTarget(
user,
creds,
Expand All @@ -117,7 +155,7 @@ function createApiKeyCreateCommand(): Command {
const result = await user.apiKey.create({
memberId,
name: keyName,
expiresAt: opts.expires ?? null,
expiresAt,
});
output(result, fmt, () => {
clack.log.success(`Created API key '${keyName}'`);
Expand Down