@arrhq/crate-cli 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +21 -10
  2. package/package.json +5 -5
  3. package/src/cli.mjs +936 -26
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @arrhq/crate-cli
2
2
 
3
- Crate MCP をCLIで操作するための公開向けクライアントです。
3
+ Crate をCLIで操作するための公開向けクライアントです。
4
4
 
5
5
  ## Install
6
6
 
@@ -10,14 +10,31 @@ npm i -g @arrhq/crate-cli
10
10
 
11
11
  ## Configure
12
12
 
13
- 以下のいずれかで接続情報を設定します。
13
+ 推奨(一般ユーザー):
14
14
 
15
- - `CRATE_MCP_URL` / `CRATE_MCP_INGEST_TOKEN`
16
- - `~/.codex/config.toml` `mcp_servers` 設定
15
+ ```bash
16
+ crate auth login --crate-url https://crateio.com
17
+ crate auth status
18
+ ```
19
+
20
+ 認証トークン保存:
21
+
22
+ - macOS: Keychain(`security` コマンド)を優先
23
+ - 上記が使えない環境: `~/.config/crate-cli/auth.json`(`0600`)へフォールバック
24
+
25
+ CI/ヘッドレス向け(補助導線):
26
+
27
+ ```bash
28
+ export CRATE_URL="https://crateio.com"
29
+ export CRATE_TOKEN="<crate_mt_...>"
30
+ export CRATE_PROJECT_ID="<project_uuid>" # optional
31
+ ```
17
32
 
18
33
  ## Usage
19
34
 
20
35
  ```bash
36
+ crate auth login
37
+ crate auth status
21
38
  crate tool list
22
39
  crate tool call --name list_project_tasks --args-json '{"status":"open","limit":20}'
23
40
  crate tasks list --status in_progress --limit 50
@@ -26,9 +43,3 @@ crate tasks update --task-id <task_uuid> --status in_progress
26
43
  crate checkpoint status --client-event-id <checkpoint_uuid>
27
44
  crate tasks close --task-id <task_uuid> --client-event-id <checkpoint_uuid>
28
45
  ```
29
-
30
- ## Release flow
31
-
32
- 1. CLIの変更PRで `.changeset/*.md` を追加する(`pnpm changeset`)。
33
- 2. `main` へのマージ後、GitHub Actions `Crate CLI Release` が Release PR を作成する。
34
- 3. Release PR をマージすると npm publish が実行される(Trusted Publisher/OIDC 前提)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arrhq/crate-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Crate MCP CLI for fine-grained task/checkpoint/tool operations",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,11 +12,11 @@
12
12
  "src",
13
13
  "README.md"
14
14
  ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
15
18
  "scripts": {
16
19
  "crate": "node ./bin/crate.mjs",
17
20
  "test": "node --test ../../scripts/__tests__/crate-cli.test.mjs"
18
- },
19
- "engines": {
20
- "node": ">=20"
21
21
  }
22
- }
22
+ }
package/src/cli.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execFile } from "node:child_process";
4
- import { randomUUID } from "node:crypto";
5
- import { readFile } from "node:fs/promises";
4
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
5
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
6
+ import { createServer } from "node:http";
6
7
  import { homedir } from "node:os";
7
8
  import { join } from "node:path";
8
9
  import { pathToFileURL } from "node:url";
@@ -11,6 +12,15 @@ import { promisify } from "node:util";
11
12
  const DEFAULT_TIMEOUT_MS = 20_000;
12
13
  const DEFAULT_MAX_WAIT_SECONDS = 180;
13
14
  const DEFAULT_POLL_INTERVAL_SECONDS = 3;
15
+ const DEFAULT_CRATE_URL = "https://crateio.com";
16
+ const DEFAULT_OAUTH_CLIENT_ID = "crate-cli-public";
17
+ const DEFAULT_OAUTH_SCOPES = "snapshot:write tasks:write tasks:read";
18
+ const DEFAULT_AUTH_CALLBACK_TIMEOUT_SECONDS = 180;
19
+ const AUTH_STORE_PATH = join(homedir(), ".config", "crate-cli", "auth.json");
20
+ const AUTH_STORE_VERSION = 2;
21
+ const AUTH_KEYCHAIN_SERVICE = "arrhq.crate-cli";
22
+ const AUTH_KEYCHAIN_ACCOUNT_PREFIX = "oauth-session:";
23
+ const AUTH_CALLBACK_PATH = "/oauth/callback";
14
24
 
15
25
  const UUID_PATTERN =
16
26
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -27,6 +37,9 @@ const HELP_TEXT = `Usage:
27
37
  crate <command> [options]
28
38
 
29
39
  Commands:
40
+ auth login
41
+ auth status
42
+ auth logout
30
43
  tool list
31
44
  tool call
32
45
  tasks list
@@ -36,6 +49,8 @@ Commands:
36
49
  checkpoint status
37
50
 
38
51
  Examples:
52
+ crate auth login
53
+ crate auth status
39
54
  crate tool list
40
55
  crate tool call --name list_project_tasks --args-json '{"status":"open","limit":20}'
41
56
  crate tasks list --status in_progress --limit 50
@@ -45,29 +60,53 @@ Examples:
45
60
  crate checkpoint status --client-event-id <checkpoint_uuid>
46
61
 
47
62
  Global options:
48
- --mcp-url <url> MCP endpoint URL
49
- --mcp-ingest-token <token> MCP token (x-crate-ingest-token)
50
- --server-name <name> mcp server key in ~/.codex/config.toml
63
+ --crate-url <url> Crate URL (e.g. https://crateio.com)
64
+ --token <token> Crate API token
65
+ --project-id <uuid> Default project_id for endpoint resolution
66
+ --server-name <name> mcp server key in ~/.codex/config.toml (optional)
67
+ --mcp-url <url> Legacy: MCP endpoint URL
68
+ --mcp-ingest-token <token> Legacy: MCP token (x-crate-ingest-token)
51
69
  --timeout-ms <ms> RPC timeout (default: 20000)
52
70
  --json JSON output
53
71
  --help Show help`;
54
72
 
55
73
  const COMMAND_HELP = {
74
+ "auth login": `Usage: crate auth login [options]
75
+ Options:
76
+ --crate-url <url> default: https://crateio.com
77
+ --project-id <uuid> optional project hint
78
+ --scope "<scopes>" default: "snapshot:write tasks:write tasks:read"
79
+ --client-id <id> default: crate-cli-public
80
+ --callback-timeout-seconds <n> default: 180
81
+ --no-browser print URL without launching browser
82
+ --json`,
83
+ "auth status": `Usage: crate auth status [options]
84
+ Options:
85
+ --json`,
86
+ "auth logout": `Usage: crate auth logout [options]
87
+ Options:
88
+ --json`,
56
89
  "tool list": `Usage: crate tool list [options]
57
90
  Options:
91
+ --crate-url <url>
92
+ --token <token>
93
+ --project-id <uuid>
94
+ --server-name <name>
58
95
  --mcp-url <url>
59
96
  --mcp-ingest-token <token>
60
- --server-name <name>
61
97
  --timeout-ms <ms>
62
98
  --json`,
63
99
  "tool call": `Usage: crate tool call --name <tool> [options]
64
100
  Options:
65
- --name <tool_name> MCP tool name (required)
101
+ --name <tool_name> Crate tool name (required)
66
102
  --args-json <json> Tool arguments as JSON object (default: {})
67
103
  --args-file <path> Path to JSON file for tool arguments
104
+ --crate-url <url>
105
+ --token <token>
106
+ --project-id <uuid>
107
+ --server-name <name>
68
108
  --mcp-url <url>
69
109
  --mcp-ingest-token <token>
70
- --server-name <name>
71
110
  --timeout-ms <ms>
72
111
  --json`,
73
112
  "tasks list": `Usage: crate tasks list [options]
@@ -75,9 +114,11 @@ Options:
75
114
  --project-id <uuid>
76
115
  --status <open|in_progress|close|canceled>
77
116
  --limit <n>
117
+ --crate-url <url>
118
+ --token <token>
119
+ --server-name <name>
78
120
  --mcp-url <url>
79
121
  --mcp-ingest-token <token>
80
- --server-name <name>
81
122
  --timeout-ms <ms>
82
123
  --json`,
83
124
  "tasks create": `Usage: crate tasks create --title <text> [options]
@@ -93,9 +134,11 @@ Options:
93
134
  --context-label <label>
94
135
  --actor-context <text>
95
136
  --source-context <text>
137
+ --crate-url <url>
138
+ --token <token>
139
+ --server-name <name>
96
140
  --mcp-url <url>
97
141
  --mcp-ingest-token <token>
98
- --server-name <name>
99
142
  --timeout-ms <ms>
100
143
  --json`,
101
144
  "tasks update": `Usage: crate tasks update --task-id <uuid> [options]
@@ -113,9 +156,11 @@ Options:
113
156
  --context-label <label> required fallback when status=close and client_event_id omitted
114
157
  --actor-context <text>
115
158
  --source-context <text>
159
+ --crate-url <url>
160
+ --token <token>
161
+ --server-name <name>
116
162
  --mcp-url <url>
117
163
  --mcp-ingest-token <token>
118
- --server-name <name>
119
164
  --timeout-ms <ms>
120
165
  --json`,
121
166
  "tasks close": `Usage: crate tasks close --task-id <uuid> [options]
@@ -130,9 +175,11 @@ Options:
130
175
  --github-repo-full-name <owner/repo>
131
176
  --max-wait-seconds <n> default: 180
132
177
  --poll-interval-seconds <n> default: 3
178
+ --crate-url <url>
179
+ --token <token>
180
+ --server-name <name>
133
181
  --mcp-url <url>
134
182
  --mcp-ingest-token <token>
135
- --server-name <name>
136
183
  --timeout-ms <ms>
137
184
  --json`,
138
185
  "checkpoint status": `Usage: crate checkpoint status [options]
@@ -141,9 +188,11 @@ Options:
141
188
  --session-id <id> Required when client_event_id omitted
142
189
  --context-label <label> Required when client_event_id omitted
143
190
  --project-id <uuid>
191
+ --crate-url <url>
192
+ --token <token>
193
+ --server-name <name>
144
194
  --mcp-url <url>
145
195
  --mcp-ingest-token <token>
146
- --server-name <name>
147
196
  --timeout-ms <ms>
148
197
  --json`,
149
198
  };
@@ -289,7 +338,540 @@ const parseFlags = (argv, schema, defaults = {}) => {
289
338
  return { options, seenFlags };
290
339
  };
291
340
 
341
+ const parseAuthStore = (raw) => {
342
+ if (typeof raw !== "string" || !raw.trim()) return null;
343
+ try {
344
+ const parsed = JSON.parse(raw);
345
+ if (!parsed || typeof parsed !== "object") return null;
346
+ const current = parsed.current;
347
+ if (!current || typeof current !== "object") return null;
348
+ return {
349
+ current: {
350
+ crate_url: toTrimmed(current.crate_url),
351
+ project_id: toTrimmed(current.project_id),
352
+ client_id: toTrimmed(current.client_id),
353
+ token_endpoint: toTrimmed(current.token_endpoint),
354
+ authorization_endpoint: toTrimmed(current.authorization_endpoint),
355
+ access_token: toTrimmed(current.access_token),
356
+ refresh_token: toTrimmed(current.refresh_token),
357
+ token_type: toTrimmed(current.token_type),
358
+ scope: toTrimmed(current.scope),
359
+ expires_at: toTrimmed(current.expires_at),
360
+ created_at: toTrimmed(current.created_at),
361
+ updated_at: toTrimmed(current.updated_at),
362
+ credential_key: toTrimmed(current.credential_key),
363
+ storage: toTrimmed(current.storage),
364
+ access_token: toTrimmed(current.access_token),
365
+ refresh_token: toTrimmed(current.refresh_token),
366
+ },
367
+ };
368
+ } catch {
369
+ return null;
370
+ }
371
+ };
372
+
373
+ const buildAuthCredentialKey = (session) => {
374
+ const crateUrl = normalizeCrateUrl(session?.crate_url);
375
+ const origin = toOriginFromUrl(crateUrl || session?.crate_url || "");
376
+ const projectId = toTrimmed(session?.project_id) || "-";
377
+ const clientId = toTrimmed(session?.client_id) || DEFAULT_OAUTH_CLIENT_ID;
378
+ if (!origin) return "";
379
+ return `${origin}|${projectId}|${clientId}`;
380
+ };
381
+
382
+ const buildAuthKeychainAccount = (credentialKey) => {
383
+ const normalized = toTrimmed(credentialKey);
384
+ if (!normalized) return "";
385
+ const digest = createHash("sha256").update(normalized).digest("hex");
386
+ return `${AUTH_KEYCHAIN_ACCOUNT_PREFIX}${digest}`;
387
+ };
388
+
389
+ const parseKeychainSecret = (raw) => {
390
+ if (typeof raw !== "string" || raw.trim() === "") return null;
391
+ try {
392
+ const parsed = JSON.parse(raw);
393
+ if (!parsed || typeof parsed !== "object") return null;
394
+ const accessToken = toTrimmed(parsed.access_token);
395
+ const refreshToken = toTrimmed(parsed.refresh_token);
396
+ if (!accessToken) return null;
397
+ return {
398
+ access_token: accessToken,
399
+ refresh_token: refreshToken,
400
+ };
401
+ } catch {
402
+ return null;
403
+ }
404
+ };
405
+
406
+ const isDarwinKeychainAvailable = async () => {
407
+ if (process.platform !== "darwin") return false;
408
+ try {
409
+ await execFileAsync("security", ["help"], { timeout: 5_000, maxBuffer: 64 * 1024 });
410
+ return true;
411
+ } catch {
412
+ return false;
413
+ }
414
+ };
415
+
416
+ const readDarwinKeychainSecret = async (account) => {
417
+ if (!account) return null;
418
+ try {
419
+ const { stdout } = await execFileAsync(
420
+ "security",
421
+ ["find-generic-password", "-s", AUTH_KEYCHAIN_SERVICE, "-a", account, "-w"],
422
+ { timeout: 10_000, maxBuffer: 128 * 1024 },
423
+ );
424
+ return parseKeychainSecret(stdout);
425
+ } catch {
426
+ return null;
427
+ }
428
+ };
429
+
430
+ const writeDarwinKeychainSecret = async (account, secretText) => {
431
+ if (!account || !secretText) return false;
432
+ try {
433
+ await execFileAsync(
434
+ "security",
435
+ [
436
+ "add-generic-password",
437
+ "-U",
438
+ "-s",
439
+ AUTH_KEYCHAIN_SERVICE,
440
+ "-a",
441
+ account,
442
+ "-w",
443
+ secretText,
444
+ ],
445
+ { timeout: 10_000, maxBuffer: 64 * 1024 },
446
+ );
447
+ return true;
448
+ } catch {
449
+ return false;
450
+ }
451
+ };
452
+
453
+ const deleteDarwinKeychainSecret = async (account) => {
454
+ if (!account) return;
455
+ try {
456
+ await execFileAsync(
457
+ "security",
458
+ ["delete-generic-password", "-s", AUTH_KEYCHAIN_SERVICE, "-a", account],
459
+ { timeout: 10_000, maxBuffer: 64 * 1024 },
460
+ );
461
+ } catch {
462
+ // Ignore missing keychain entry errors.
463
+ }
464
+ };
465
+
466
+ const readAuthStore = async () => {
467
+ try {
468
+ const raw = await readFile(AUTH_STORE_PATH, "utf8");
469
+ const parsed = parseAuthStore(raw);
470
+ if (!parsed?.current) return null;
471
+
472
+ const current = parsed.current;
473
+ const credentialKey = current.credential_key || buildAuthCredentialKey(current);
474
+ const account = buildAuthKeychainAccount(credentialKey);
475
+ const keychainEnabled = await isDarwinKeychainAvailable();
476
+
477
+ let tokens = null;
478
+ if (keychainEnabled) {
479
+ tokens = await readDarwinKeychainSecret(account);
480
+ }
481
+
482
+ // Backward-compatibility for previously file-stored secrets.
483
+ const legacyAccessToken = toTrimmed(current.access_token);
484
+ const legacyRefreshToken = toTrimmed(current.refresh_token);
485
+ if (!tokens && legacyAccessToken) {
486
+ tokens = {
487
+ access_token: legacyAccessToken,
488
+ refresh_token: legacyRefreshToken,
489
+ };
490
+
491
+ if (keychainEnabled) {
492
+ const migrated = await writeDarwinKeychainSecret(
493
+ account,
494
+ JSON.stringify(tokens),
495
+ );
496
+ if (migrated) {
497
+ await writeAuthStore({
498
+ current: {
499
+ ...current,
500
+ credential_key: credentialKey,
501
+ access_token: tokens.access_token,
502
+ refresh_token: tokens.refresh_token,
503
+ },
504
+ });
505
+ }
506
+ }
507
+ }
508
+
509
+ return {
510
+ current: {
511
+ crate_url: current.crate_url,
512
+ project_id: current.project_id,
513
+ client_id: current.client_id,
514
+ token_endpoint: current.token_endpoint,
515
+ authorization_endpoint: current.authorization_endpoint,
516
+ token_type: current.token_type,
517
+ scope: current.scope,
518
+ expires_at: current.expires_at,
519
+ created_at: current.created_at,
520
+ updated_at: current.updated_at,
521
+ credential_key: credentialKey,
522
+ storage: keychainEnabled ? "keychain" : "file",
523
+ access_token: tokens?.access_token ?? "",
524
+ refresh_token: tokens?.refresh_token ?? "",
525
+ },
526
+ };
527
+ } catch {
528
+ return null;
529
+ }
530
+ };
531
+
532
+ const writeAuthStore = async (store) => {
533
+ const current = store?.current;
534
+ if (!current || typeof current !== "object") {
535
+ throw new Error("Invalid auth store payload.");
536
+ }
537
+
538
+ const accessToken = toTrimmed(current.access_token);
539
+ const refreshToken = toTrimmed(current.refresh_token);
540
+ const credentialKey = current.credential_key || buildAuthCredentialKey(current);
541
+ const account = buildAuthKeychainAccount(credentialKey);
542
+ const keychainEnabled = await isDarwinKeychainAvailable();
543
+
544
+ let storage = "file";
545
+ if (keychainEnabled && accessToken) {
546
+ const saved = await writeDarwinKeychainSecret(
547
+ account,
548
+ JSON.stringify({
549
+ access_token: accessToken,
550
+ refresh_token: refreshToken,
551
+ }),
552
+ );
553
+ if (saved) {
554
+ storage = "keychain";
555
+ }
556
+ }
557
+
558
+ const dir = join(homedir(), ".config", "crate-cli");
559
+ await mkdir(dir, { recursive: true });
560
+
561
+ const sanitized = {
562
+ version: AUTH_STORE_VERSION,
563
+ current: {
564
+ crate_url: toTrimmed(current.crate_url),
565
+ project_id: toTrimmed(current.project_id),
566
+ client_id: toTrimmed(current.client_id),
567
+ token_endpoint: toTrimmed(current.token_endpoint),
568
+ authorization_endpoint: toTrimmed(current.authorization_endpoint),
569
+ token_type: toTrimmed(current.token_type),
570
+ scope: toTrimmed(current.scope),
571
+ expires_at: toTrimmed(current.expires_at),
572
+ created_at: toTrimmed(current.created_at),
573
+ updated_at: toTrimmed(current.updated_at),
574
+ credential_key: credentialKey,
575
+ storage,
576
+ },
577
+ };
578
+
579
+ if (storage !== "keychain") {
580
+ sanitized.current.access_token = accessToken;
581
+ sanitized.current.refresh_token = refreshToken;
582
+ }
583
+
584
+ const body = `${JSON.stringify(sanitized, null, 2)}\n`;
585
+ await writeFile(AUTH_STORE_PATH, body, { mode: 0o600 });
586
+ return { storage };
587
+ };
588
+
589
+ const clearAuthStore = async () => {
590
+ try {
591
+ const raw = await readFile(AUTH_STORE_PATH, "utf8");
592
+ const parsed = parseAuthStore(raw);
593
+ const current = parsed?.current;
594
+ if (current) {
595
+ const credentialKey = current.credential_key || buildAuthCredentialKey(current);
596
+ const account = buildAuthKeychainAccount(credentialKey);
597
+ if (await isDarwinKeychainAvailable()) {
598
+ await deleteDarwinKeychainSecret(account);
599
+ }
600
+ }
601
+ } catch {
602
+ // Ignore parse/read errors and continue clearing local file.
603
+ }
604
+ try {
605
+ await unlink(AUTH_STORE_PATH);
606
+ } catch {
607
+ // Ignore missing file errors.
608
+ }
609
+ };
610
+
611
+ const normalizeCrateUrl = (value) => {
612
+ const raw = toTrimmed(value);
613
+ if (!raw) return "";
614
+ let parsed;
615
+ try {
616
+ parsed = new URL(raw);
617
+ } catch {
618
+ return "";
619
+ }
620
+ parsed.hash = "";
621
+ const path = (parsed.pathname || "/").replace(/\/+$/g, "") || "/";
622
+ parsed.pathname = path;
623
+ return parsed.toString();
624
+ };
625
+
626
+ const toOriginFromUrl = (value) => {
627
+ try {
628
+ return new URL(value).origin;
629
+ } catch {
630
+ return "";
631
+ }
632
+ };
633
+
634
+ const toIsoTimestampFromExpiresIn = (value) => {
635
+ const seconds = Number.parseInt(String(value), 10);
636
+ if (!Number.isFinite(seconds) || seconds <= 0) return "";
637
+ return new Date(Date.now() + seconds * 1000).toISOString();
638
+ };
639
+
640
+ const isExpiredOrNearExpiry = (iso, leewaySeconds = 60) => {
641
+ const ts = Date.parse(toTrimmed(iso));
642
+ if (Number.isNaN(ts)) return true;
643
+ return ts - Date.now() <= leewaySeconds * 1000;
644
+ };
645
+
646
+ const generatePkceCodeVerifier = () => randomBytes(64).toString("base64url");
647
+
648
+ const createPkceS256Challenge = (verifier) =>
649
+ createHash("sha256").update(verifier).digest("base64url");
650
+
651
+ const createAuthStateToken = () => randomBytes(24).toString("base64url");
652
+
653
+ const buildAuthorizationEndpoint = (crateUrl, projectId) => {
654
+ const endpoint = new URL("/oauth/authorize", crateUrl);
655
+ if (projectId) endpoint.searchParams.set("project_id", projectId);
656
+ return endpoint.toString();
657
+ };
658
+
659
+ const buildTokenEndpoint = (crateUrl) => new URL("/api/oauth/token", crateUrl).toString();
660
+
661
+ const fetchJson = async (url, timeoutMs = DEFAULT_TIMEOUT_MS) => {
662
+ const controller = new AbortController();
663
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
664
+ try {
665
+ const response = await fetch(url, {
666
+ method: "GET",
667
+ headers: {
668
+ Accept: "application/json",
669
+ },
670
+ signal: controller.signal,
671
+ });
672
+ const payload = await response.json().catch(() => null);
673
+ return { ok: response.ok, status: response.status, payload };
674
+ } finally {
675
+ clearTimeout(timer);
676
+ }
677
+ };
678
+
679
+ const postForm = async (url, body, timeoutMs = DEFAULT_TIMEOUT_MS) => {
680
+ const controller = new AbortController();
681
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
682
+ try {
683
+ const response = await fetch(url, {
684
+ method: "POST",
685
+ headers: {
686
+ "Content-Type": "application/x-www-form-urlencoded",
687
+ Accept: "application/json",
688
+ },
689
+ body: new URLSearchParams(body).toString(),
690
+ signal: controller.signal,
691
+ });
692
+ const payload = await response.json().catch(() => null);
693
+ return { ok: response.ok, status: response.status, payload };
694
+ } finally {
695
+ clearTimeout(timer);
696
+ }
697
+ };
698
+
699
+ const resolveOAuthEndpoints = async (crateUrl, projectId, timeoutMs) => {
700
+ const metadataUrl = new URL("/.well-known/oauth-authorization-server", crateUrl);
701
+ if (projectId) metadataUrl.searchParams.set("project_id", projectId);
702
+
703
+ const fallback = {
704
+ authorization_endpoint: buildAuthorizationEndpoint(crateUrl, projectId),
705
+ token_endpoint: buildTokenEndpoint(crateUrl),
706
+ };
707
+
708
+ const metadata = await fetchJson(metadataUrl.toString(), timeoutMs);
709
+ if (!metadata.ok || !metadata.payload || typeof metadata.payload !== "object") {
710
+ return fallback;
711
+ }
712
+
713
+ const authorizationEndpoint = toTrimmed(metadata.payload.authorization_endpoint);
714
+ const tokenEndpoint = toTrimmed(metadata.payload.token_endpoint);
715
+ return {
716
+ authorization_endpoint: authorizationEndpoint || fallback.authorization_endpoint,
717
+ token_endpoint: tokenEndpoint || fallback.token_endpoint,
718
+ };
719
+ };
720
+
721
+ const openExternalBrowser = async (targetUrl) => {
722
+ const platform = process.platform;
723
+ if (platform === "darwin") {
724
+ await execFileAsync("open", [targetUrl], { timeout: 10_000, maxBuffer: 64 * 1024 });
725
+ return;
726
+ }
727
+ if (platform === "win32") {
728
+ await execFileAsync("cmd", ["/c", "start", "", targetUrl], {
729
+ timeout: 10_000,
730
+ maxBuffer: 64 * 1024,
731
+ });
732
+ return;
733
+ }
734
+ await execFileAsync("xdg-open", [targetUrl], { timeout: 10_000, maxBuffer: 64 * 1024 });
735
+ };
736
+
737
+ const startLoopbackCallbackServer = async ({
738
+ callbackPath,
739
+ timeoutSeconds,
740
+ }) => {
741
+ const normalizedPath = callbackPath.startsWith("/") ? callbackPath : `/${callbackPath}`;
742
+
743
+ let resolved = false;
744
+ let resolveResult;
745
+ let rejectResult;
746
+ const resultPromise = new Promise((resolve, reject) => {
747
+ resolveResult = resolve;
748
+ rejectResult = reject;
749
+ });
750
+
751
+ const server = createServer((request, response) => {
752
+ let requestUrl;
753
+ try {
754
+ requestUrl = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
755
+ } catch {
756
+ response.statusCode = 400;
757
+ response.end("Invalid callback URL");
758
+ return;
759
+ }
760
+
761
+ if (requestUrl.pathname !== normalizedPath) {
762
+ response.statusCode = 404;
763
+ response.end("Not found");
764
+ return;
765
+ }
766
+
767
+ const code = toTrimmed(requestUrl.searchParams.get("code"));
768
+ const state = toTrimmed(requestUrl.searchParams.get("state"));
769
+ const error = toTrimmed(requestUrl.searchParams.get("error"));
770
+ const errorDescription = toTrimmed(requestUrl.searchParams.get("error_description"));
771
+
772
+ response.statusCode = 200;
773
+ response.setHeader("Content-Type", "text/html; charset=utf-8");
774
+ response.end(
775
+ "<!doctype html><html><body><h1>Crate CLI</h1><p>認証が完了しました。このタブを閉じてください。</p></body></html>",
776
+ );
777
+
778
+ if (resolved) return;
779
+ resolved = true;
780
+ resolveResult({ code, state, error, error_description: errorDescription });
781
+ });
782
+
783
+ const timeoutMs = timeoutSeconds * 1000;
784
+ const timeout = setTimeout(() => {
785
+ if (resolved) return;
786
+ resolved = true;
787
+ rejectResult(new Error("OAuth callback timed out. Please run `crate auth login` again."));
788
+ server.close();
789
+ }, timeoutMs);
790
+
791
+ await new Promise((resolve, reject) => {
792
+ server.once("error", reject);
793
+ server.listen(0, "127.0.0.1", () => {
794
+ server.off("error", reject);
795
+ resolve();
796
+ });
797
+ });
798
+
799
+ const address = server.address();
800
+ if (!address || typeof address === "string") {
801
+ clearTimeout(timeout);
802
+ server.close();
803
+ throw new Error("Failed to bind local callback server.");
804
+ }
805
+
806
+ const redirectUri = new URL(`http://127.0.0.1:${address.port}${normalizedPath}`).toString();
807
+
808
+ const close = async () =>
809
+ new Promise((resolve) => {
810
+ clearTimeout(timeout);
811
+ server.close(() => resolve());
812
+ });
813
+
814
+ return {
815
+ redirectUri,
816
+ waitForCallback: () => resultPromise,
817
+ close,
818
+ };
819
+ };
820
+
821
+ const buildMcpResourceUrl = (crateUrl, projectId) => {
822
+ const url = new URL("/api/mcp", crateUrl);
823
+ if (projectId) url.searchParams.set("project_id", projectId);
824
+ return url.toString();
825
+ };
826
+
827
+ const normalizeAuthSession = (store) => {
828
+ if (!store?.current) return null;
829
+ const current = store.current;
830
+ if (!current.crate_url || !current.access_token) return null;
831
+ return current;
832
+ };
833
+
834
+ const refreshOAuthSession = async (session, timeoutMs) => {
835
+ if (!session.refresh_token || !session.token_endpoint || !session.client_id) {
836
+ return null;
837
+ }
838
+
839
+ const response = await postForm(
840
+ session.token_endpoint,
841
+ {
842
+ grant_type: "refresh_token",
843
+ refresh_token: session.refresh_token,
844
+ client_id: session.client_id,
845
+ },
846
+ timeoutMs,
847
+ );
848
+ if (!response.ok || !response.payload || typeof response.payload !== "object") {
849
+ return null;
850
+ }
851
+
852
+ const accessToken = toTrimmed(response.payload.access_token);
853
+ const refreshToken = toTrimmed(response.payload.refresh_token) || session.refresh_token;
854
+ const expiresAt = toIsoTimestampFromExpiresIn(response.payload.expires_in);
855
+ if (!accessToken || !refreshToken || !expiresAt) {
856
+ return null;
857
+ }
858
+
859
+ const now = new Date().toISOString();
860
+ return {
861
+ ...session,
862
+ access_token: accessToken,
863
+ refresh_token: refreshToken,
864
+ token_type: toTrimmed(response.payload.token_type) || session.token_type,
865
+ scope: toTrimmed(response.payload.scope) || session.scope,
866
+ expires_at: expiresAt,
867
+ updated_at: now,
868
+ };
869
+ };
870
+
292
871
  const getCommonDefaults = () => ({
872
+ crateUrl: process.env.CRATE_URL?.trim() ?? "",
873
+ apiToken: process.env.CRATE_TOKEN?.trim() ?? "",
874
+ projectId: process.env.CRATE_PROJECT_ID?.trim() ?? "",
293
875
  mcpUrl: process.env.CRATE_MCP_URL?.trim() ?? "",
294
876
  mcpIngestToken: process.env.CRATE_MCP_INGEST_TOKEN?.trim() ?? "",
295
877
  serverName: process.env.CRATE_MCP_SERVER_NAME?.trim() ?? "",
@@ -307,6 +889,10 @@ const getWriteDefaults = () => ({
307
889
  });
308
890
 
309
891
  const COMMON_SCHEMA = {
892
+ "--crate-url": { key: "crateUrl", type: "string" },
893
+ "--api-url": { key: "crateUrl", type: "string" },
894
+ "--token": { key: "apiToken", type: "string" },
895
+ "--project-id": { key: "projectId", type: "string" },
310
896
  "--mcp-url": { key: "mcpUrl", type: "string" },
311
897
  "--mcp-ingest-token": { key: "mcpIngestToken", type: "string" },
312
898
  "--server-name": { key: "serverName", type: "string" },
@@ -328,6 +914,9 @@ const WRITE_SCHEMA = {
328
914
  };
329
915
 
330
916
  const normalizeCommonOptions = (options) => {
917
+ options.crateUrl = toTrimmed(options.crateUrl);
918
+ options.apiToken = toTrimmed(options.apiToken);
919
+ options.projectId = toTrimmed(options.projectId);
331
920
  options.mcpUrl = toTrimmed(options.mcpUrl);
332
921
  options.mcpIngestToken = toTrimmed(options.mcpIngestToken);
333
922
  options.serverName = toTrimmed(options.serverName);
@@ -470,6 +1059,33 @@ const parseGithubRepoFromRemoteUrl = (remoteUrl) => {
470
1059
  return normalizeGithubRepoFullName(raw);
471
1060
  };
472
1061
 
1062
+ const buildMcpUrlFromCrateUrl = (crateUrl, projectId = "") => {
1063
+ const normalizedBaseUrl = toTrimmed(crateUrl);
1064
+ if (!normalizedBaseUrl) return "";
1065
+
1066
+ let parsed;
1067
+ try {
1068
+ parsed = new URL(normalizedBaseUrl);
1069
+ } catch {
1070
+ throw new Error("--crate-url must be a valid URL.");
1071
+ }
1072
+
1073
+ const pathname = (parsed.pathname || "/").replace(/\/+$/g, "") || "/";
1074
+ if (!pathname.endsWith("/api/mcp")) {
1075
+ const prefix = pathname === "/" ? "" : pathname;
1076
+ parsed.pathname = `${prefix}/api/mcp`;
1077
+ } else {
1078
+ parsed.pathname = pathname;
1079
+ }
1080
+
1081
+ const normalizedProjectId = toTrimmed(projectId);
1082
+ if (normalizedProjectId && !parsed.searchParams.get("project_id")) {
1083
+ parsed.searchParams.set("project_id", normalizedProjectId);
1084
+ }
1085
+
1086
+ return parsed.toString();
1087
+ };
1088
+
473
1089
  const inferGithubRepoFromGitRemote = async () => {
474
1090
  try {
475
1091
  const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], {
@@ -483,18 +1099,28 @@ const inferGithubRepoFromGitRemote = async () => {
483
1099
  };
484
1100
 
485
1101
  const resolveMcpConnection = async (options) => {
486
- if (options.mcpUrl && options.mcpIngestToken) {
1102
+ const hasCrateInput = Boolean(options.crateUrl || options.apiToken);
1103
+ const hasLegacyInput = Boolean(options.mcpUrl || options.mcpIngestToken);
1104
+ const hasCrateComplete = Boolean(options.crateUrl && options.apiToken);
1105
+ const hasLegacyComplete = Boolean(options.mcpUrl && options.mcpIngestToken);
1106
+ if (hasCrateComplete && hasLegacyComplete) {
1107
+ throw new Error(
1108
+ "Do not provide both --crate-url/--token and --mcp-url/--mcp-ingest-token.",
1109
+ );
1110
+ }
1111
+
1112
+ if (hasCrateComplete) {
487
1113
  return {
488
- mcpUrl: options.mcpUrl,
489
- mcpIngestToken: options.mcpIngestToken,
1114
+ mcpUrl: buildMcpUrlFromCrateUrl(options.crateUrl, options.projectId),
1115
+ mcpIngestToken: options.apiToken,
490
1116
  };
491
1117
  }
492
1118
 
493
- if (options.mcpUrl && !options.mcpIngestToken) {
494
- throw new Error("MCP token is missing. Provide --mcp-ingest-token.");
495
- }
496
- if (!options.mcpUrl && options.mcpIngestToken) {
497
- throw new Error("MCP URL is missing. Provide --mcp-url.");
1119
+ if (hasLegacyComplete) {
1120
+ return {
1121
+ mcpUrl: options.mcpUrl,
1122
+ mcpIngestToken: options.mcpIngestToken,
1123
+ };
498
1124
  }
499
1125
 
500
1126
  const servers = await readCodexServerConfigs();
@@ -522,8 +1148,44 @@ const resolveMcpConnection = async (options) => {
522
1148
  };
523
1149
  }
524
1150
 
1151
+ const authStore = await readAuthStore();
1152
+ let authSession = normalizeAuthSession(authStore);
1153
+ if (authSession && isExpiredOrNearExpiry(authSession.expires_at)) {
1154
+ const refreshed = await refreshOAuthSession(authSession, options.timeoutMs);
1155
+ if (refreshed) {
1156
+ authSession = refreshed;
1157
+ await writeAuthStore({ current: refreshed });
1158
+ }
1159
+ }
1160
+ if (authSession) {
1161
+ const effectiveCrateUrl = normalizeCrateUrl(options.crateUrl) || authSession.crate_url;
1162
+ const effectiveProjectId = toTrimmed(options.projectId) || authSession.project_id || "";
1163
+ const effectiveToken = options.apiToken || authSession.access_token;
1164
+ if (effectiveCrateUrl && effectiveToken) {
1165
+ return {
1166
+ mcpUrl: buildMcpUrlFromCrateUrl(effectiveCrateUrl, effectiveProjectId),
1167
+ mcpIngestToken: effectiveToken,
1168
+ };
1169
+ }
1170
+ }
1171
+
1172
+ if (options.crateUrl && !options.apiToken) {
1173
+ throw new Error("Crate API token is missing. Provide --token or run `crate auth login`.");
1174
+ }
1175
+ if (!options.crateUrl && options.apiToken) {
1176
+ throw new Error("Crate URL is missing. Provide --crate-url.");
1177
+ }
1178
+ if (options.mcpUrl && !options.mcpIngestToken) {
1179
+ throw new Error("MCP token is missing. Provide --mcp-ingest-token.");
1180
+ }
1181
+ if (!options.mcpUrl && options.mcpIngestToken) {
1182
+ throw new Error("MCP URL is missing. Provide --mcp-url.");
1183
+ }
1184
+
525
1185
  throw new Error(
526
- "Unable to resolve MCP server automatically. Provide --server-name, or --mcp-url and --mcp-ingest-token.",
1186
+ hasCrateInput || hasLegacyInput
1187
+ ? "Unable to resolve connection with current options."
1188
+ : "Unable to resolve connection automatically. Run `crate auth login`, or provide --crate-url and --token, --server-name, or --mcp-url and --mcp-ingest-token.",
527
1189
  );
528
1190
  };
529
1191
 
@@ -846,6 +1508,9 @@ const parseCommand = (argv) => {
846
1508
 
847
1509
  const compound = `${first} ${second}`;
848
1510
  const knownCompound = new Set([
1511
+ "auth login",
1512
+ "auth status",
1513
+ "auth logout",
849
1514
  "tool list",
850
1515
  "tool call",
851
1516
  "tasks list",
@@ -1002,7 +1667,6 @@ const handleTasksList = async (argv, runtime) => {
1002
1667
 
1003
1668
  const { options } = parseFlags(argv, schema, {
1004
1669
  ...getCommonDefaults(),
1005
- projectId: "",
1006
1670
  status: "",
1007
1671
  limit: 50,
1008
1672
  });
@@ -1054,7 +1718,6 @@ const handleTasksCreate = async (argv, runtime) => {
1054
1718
  description: "",
1055
1719
  actorContext: "agent",
1056
1720
  sourceContext: "crate-cli",
1057
- projectId: "",
1058
1721
  githubRepoFullName: process.env.CRATE_GITHUB_REPO_FULL_NAME?.trim() ?? "",
1059
1722
  });
1060
1723
 
@@ -1129,7 +1792,6 @@ const handleTasksUpdate = async (argv, runtime) => {
1129
1792
  status: "",
1130
1793
  actorContext: "agent",
1131
1794
  sourceContext: "crate-cli",
1132
- projectId: "",
1133
1795
  githubRepoFullName: process.env.CRATE_GITHUB_REPO_FULL_NAME?.trim() ?? "",
1134
1796
  });
1135
1797
 
@@ -1210,7 +1872,6 @@ const handleCheckpointStatus = async (argv, runtime) => {
1210
1872
 
1211
1873
  const { options, seenFlags } = parseFlags(argv, schema, {
1212
1874
  ...getCommonDefaults(),
1213
- projectId: "",
1214
1875
  clientEventId: process.env.CRATE_EVENT_CLIENT_EVENT_ID?.trim() ?? "",
1215
1876
  sessionId: process.env.CODEX_THREAD_ID?.trim() ?? "",
1216
1877
  contextLabel: process.env.CRATE_EVENT_CONTEXT_LABEL?.trim() ?? "",
@@ -1395,8 +2056,256 @@ const handleTasksClose = async (argv, runtime) => {
1395
2056
  return 0;
1396
2057
  };
1397
2058
 
2059
+ const parseAuthLoginOptions = (argv) => {
2060
+ const schema = {
2061
+ ...COMMON_SCHEMA,
2062
+ "--scope": { key: "scope", type: "string" },
2063
+ "--client-id": { key: "clientId", type: "string" },
2064
+ "--callback-timeout-seconds": { key: "callbackTimeoutSeconds", type: "integer" },
2065
+ "--no-browser": { key: "noBrowser", type: "boolean" },
2066
+ };
2067
+
2068
+ const { options } = parseFlags(argv, schema, {
2069
+ ...getCommonDefaults(),
2070
+ scope: DEFAULT_OAUTH_SCOPES,
2071
+ clientId: DEFAULT_OAUTH_CLIENT_ID,
2072
+ callbackTimeoutSeconds: DEFAULT_AUTH_CALLBACK_TIMEOUT_SECONDS,
2073
+ noBrowser: false,
2074
+ });
2075
+ normalizeCommonOptions(options);
2076
+ options.scope = toTrimmed(options.scope);
2077
+ options.clientId = toTrimmed(options.clientId);
2078
+ options.crateUrl = normalizeCrateUrl(options.crateUrl || DEFAULT_CRATE_URL);
2079
+ options.projectId = toTrimmed(options.projectId);
2080
+ options.callbackTimeoutSeconds = parsePositiveInteger(
2081
+ options.callbackTimeoutSeconds,
2082
+ "--callback-timeout-seconds",
2083
+ );
2084
+ if (!options.crateUrl) {
2085
+ throw new Error("--crate-url must be a valid URL.");
2086
+ }
2087
+ if (!options.clientId) {
2088
+ throw new Error("--client-id is required.");
2089
+ }
2090
+ return options;
2091
+ };
2092
+
2093
+ const handleAuthLogin = async (argv) => {
2094
+ const options = parseAuthLoginOptions(argv);
2095
+ if (options.help) {
2096
+ process.stdout.write(`${COMMAND_HELP["auth login"]}\n`);
2097
+ return 0;
2098
+ }
2099
+
2100
+ const endpoints = await resolveOAuthEndpoints(
2101
+ options.crateUrl,
2102
+ options.projectId,
2103
+ options.timeoutMs,
2104
+ );
2105
+ const callback = await startLoopbackCallbackServer({
2106
+ callbackPath: AUTH_CALLBACK_PATH,
2107
+ timeoutSeconds: options.callbackTimeoutSeconds,
2108
+ });
2109
+
2110
+ const state = createAuthStateToken();
2111
+ const codeVerifier = generatePkceCodeVerifier();
2112
+ const codeChallenge = createPkceS256Challenge(codeVerifier);
2113
+ const resourceUrl = buildMcpResourceUrl(options.crateUrl, options.projectId);
2114
+ const authorizeUrl = new URL(endpoints.authorization_endpoint);
2115
+ authorizeUrl.searchParams.set("response_type", "code");
2116
+ authorizeUrl.searchParams.set("client_id", options.clientId);
2117
+ authorizeUrl.searchParams.set("redirect_uri", callback.redirectUri);
2118
+ authorizeUrl.searchParams.set("state", state);
2119
+ authorizeUrl.searchParams.set("code_challenge", codeChallenge);
2120
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
2121
+ authorizeUrl.searchParams.set("resource", resourceUrl);
2122
+ authorizeUrl.searchParams.set("mcp_url", resourceUrl);
2123
+ if (options.projectId) {
2124
+ authorizeUrl.searchParams.set("project_id", options.projectId);
2125
+ }
2126
+ if (options.scope) {
2127
+ authorizeUrl.searchParams.set("scope", options.scope);
2128
+ }
2129
+
2130
+ let browserOpened = false;
2131
+ if (!options.noBrowser) {
2132
+ try {
2133
+ await openExternalBrowser(authorizeUrl.toString());
2134
+ browserOpened = true;
2135
+ } catch {
2136
+ browserOpened = false;
2137
+ }
2138
+ }
2139
+
2140
+ if (!options.outputJson) {
2141
+ process.stdout.write(`OAuth authorize URL:\n${authorizeUrl.toString()}\n`);
2142
+ if (browserOpened) {
2143
+ process.stdout.write("ブラウザを開きました。認証完了を待機します。\n");
2144
+ } else {
2145
+ process.stdout.write("上記URLをブラウザで開いて認証してください。\n");
2146
+ }
2147
+ }
2148
+
2149
+ let callbackResult;
2150
+ try {
2151
+ callbackResult = await callback.waitForCallback();
2152
+ } finally {
2153
+ await callback.close();
2154
+ }
2155
+
2156
+ if (callbackResult.error) {
2157
+ const description = toTrimmed(callbackResult.error_description);
2158
+ throw new Error(
2159
+ description
2160
+ ? `OAuth authorization failed: ${callbackResult.error} (${description})`
2161
+ : `OAuth authorization failed: ${callbackResult.error}`,
2162
+ );
2163
+ }
2164
+ if (!callbackResult.code) {
2165
+ throw new Error("OAuth authorization code was not returned.");
2166
+ }
2167
+ if (callbackResult.state !== state) {
2168
+ throw new Error("OAuth state mismatch. Please retry `crate auth login`.");
2169
+ }
2170
+
2171
+ const tokenResponse = await postForm(
2172
+ endpoints.token_endpoint,
2173
+ {
2174
+ grant_type: "authorization_code",
2175
+ code: callbackResult.code,
2176
+ client_id: options.clientId,
2177
+ redirect_uri: callback.redirectUri,
2178
+ code_verifier: codeVerifier,
2179
+ },
2180
+ options.timeoutMs,
2181
+ );
2182
+ if (!tokenResponse.ok || !tokenResponse.payload || typeof tokenResponse.payload !== "object") {
2183
+ const error = toTrimmed(tokenResponse.payload?.error) || `status=${tokenResponse.status}`;
2184
+ const desc = toTrimmed(tokenResponse.payload?.error_description);
2185
+ throw new Error(desc ? `OAuth token exchange failed: ${error} (${desc})` : `OAuth token exchange failed: ${error}`);
2186
+ }
2187
+
2188
+ const accessToken = toTrimmed(tokenResponse.payload.access_token);
2189
+ const refreshToken = toTrimmed(tokenResponse.payload.refresh_token);
2190
+ const tokenType = toTrimmed(tokenResponse.payload.token_type) || "Bearer";
2191
+ const scope = toTrimmed(tokenResponse.payload.scope) || options.scope;
2192
+ const expiresAt = toIsoTimestampFromExpiresIn(tokenResponse.payload.expires_in);
2193
+
2194
+ if (!accessToken || !refreshToken || !expiresAt) {
2195
+ throw new Error("OAuth token response is missing required fields.");
2196
+ }
2197
+
2198
+ const now = new Date().toISOString();
2199
+ const store = {
2200
+ current: {
2201
+ crate_url: options.crateUrl,
2202
+ project_id: options.projectId,
2203
+ client_id: options.clientId,
2204
+ token_endpoint: endpoints.token_endpoint,
2205
+ authorization_endpoint: endpoints.authorization_endpoint,
2206
+ access_token: accessToken,
2207
+ refresh_token: refreshToken,
2208
+ token_type: tokenType,
2209
+ scope,
2210
+ expires_at: expiresAt,
2211
+ created_at: now,
2212
+ updated_at: now,
2213
+ },
2214
+ };
2215
+ await writeAuthStore(store);
2216
+
2217
+ outputMaybeJson(
2218
+ options,
2219
+ {
2220
+ status: "authenticated",
2221
+ crate_url: options.crateUrl,
2222
+ project_id: options.projectId || null,
2223
+ expires_at: expiresAt,
2224
+ scope,
2225
+ browser_opened: browserOpened,
2226
+ },
2227
+ (payload) => {
2228
+ const lines = ["authentication succeeded"];
2229
+ lines.push(`- crate_url: ${payload.crate_url}`);
2230
+ lines.push(`- project_id: ${payload.project_id ?? "(auto)"}`);
2231
+ lines.push(`- expires_at: ${payload.expires_at}`);
2232
+ lines.push(`- scope: ${payload.scope}`);
2233
+ return `${lines.join("\n")}\n`;
2234
+ },
2235
+ );
2236
+ return 0;
2237
+ };
2238
+
2239
+ const handleAuthStatus = async (argv) => {
2240
+ const { options } = parseFlags(argv, COMMON_SCHEMA, getCommonDefaults());
2241
+ normalizeCommonOptions(options);
2242
+ if (options.help) {
2243
+ process.stdout.write(`${COMMAND_HELP["auth status"]}\n`);
2244
+ return 0;
2245
+ }
2246
+
2247
+ const store = await readAuthStore();
2248
+ let session = normalizeAuthSession(store);
2249
+ if (session && isExpiredOrNearExpiry(session.expires_at)) {
2250
+ const refreshed = await refreshOAuthSession(session, options.timeoutMs);
2251
+ if (refreshed) {
2252
+ session = refreshed;
2253
+ await writeAuthStore({ current: refreshed });
2254
+ }
2255
+ }
2256
+
2257
+ if (!session) {
2258
+ outputMaybeJson(
2259
+ options,
2260
+ { authenticated: false },
2261
+ () => "not authenticated. run `crate auth login`.\n",
2262
+ );
2263
+ return 0;
2264
+ }
2265
+
2266
+ outputMaybeJson(
2267
+ options,
2268
+ {
2269
+ authenticated: true,
2270
+ crate_url: session.crate_url,
2271
+ project_id: session.project_id || null,
2272
+ scope: session.scope,
2273
+ expires_at: session.expires_at,
2274
+ token_type: session.token_type,
2275
+ },
2276
+ (payload) => {
2277
+ const lines = ["authenticated"];
2278
+ lines.push(`- crate_url: ${payload.crate_url}`);
2279
+ lines.push(`- project_id: ${payload.project_id ?? "(auto)"}`);
2280
+ lines.push(`- expires_at: ${payload.expires_at}`);
2281
+ lines.push(`- scope: ${payload.scope || "(default)"}`);
2282
+ return `${lines.join("\n")}\n`;
2283
+ },
2284
+ );
2285
+ return 0;
2286
+ };
2287
+
2288
+ const handleAuthLogout = async (argv) => {
2289
+ const { options } = parseFlags(argv, COMMON_SCHEMA, getCommonDefaults());
2290
+ normalizeCommonOptions(options);
2291
+ if (options.help) {
2292
+ process.stdout.write(`${COMMAND_HELP["auth logout"]}\n`);
2293
+ return 0;
2294
+ }
2295
+
2296
+ await clearAuthStore();
2297
+ outputMaybeJson(options, { status: "logged_out" }, () => "logged out.\n");
2298
+ return 0;
2299
+ };
2300
+
1398
2301
  const runWithCommand = async (command, args, runtime) => {
1399
2302
  switch (command) {
2303
+ case "auth login":
2304
+ return handleAuthLogin(args, runtime);
2305
+ case "auth status":
2306
+ return handleAuthStatus(args, runtime);
2307
+ case "auth logout":
2308
+ return handleAuthLogout(args, runtime);
1400
2309
  case "tool list":
1401
2310
  return handleToolList(args, runtime);
1402
2311
  case "tool call":
@@ -1441,6 +2350,7 @@ export const runCrateCli = async (argv = process.argv.slice(2), runtime = {}) =>
1441
2350
  };
1442
2351
 
1443
2352
  export const __testables = {
2353
+ buildMcpUrlFromCrateUrl,
1444
2354
  classifyTaskCloseError,
1445
2355
  normalizeContextLabel,
1446
2356
  normalizeClientEventId,