@oh-my-pi/pi-coding-agent 15.1.2 → 15.1.3

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 (141) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/types/cli/auth-broker-cli.d.ts +25 -0
  3. package/dist/types/cli/auth-gateway-cli.d.ts +18 -0
  4. package/dist/types/cli/grievances-cli.d.ts +12 -0
  5. package/dist/types/commands/auth-broker.d.ts +54 -0
  6. package/dist/types/commands/auth-gateway.d.ts +32 -0
  7. package/dist/types/commands/grievances.d.ts +1 -1
  8. package/dist/types/commit/agentic/tools/propose-commit.d.ts +9 -1
  9. package/dist/types/commit/agentic/tools/schemas.d.ts +9 -1
  10. package/dist/types/commit/agentic/tools/split-commit.d.ts +9 -1
  11. package/dist/types/config/model-registry.d.ts +3 -0
  12. package/dist/types/config/models-config-schema.d.ts +1 -0
  13. package/dist/types/config/settings-schema.d.ts +46 -0
  14. package/dist/types/discovery/agents.d.ts +12 -1
  15. package/dist/types/edit/renderer.d.ts +3 -0
  16. package/dist/types/eval/index.d.ts +0 -2
  17. package/dist/types/goals/tools/goal-tool.d.ts +10 -2
  18. package/dist/types/index.d.ts +0 -1
  19. package/dist/types/internal-urls/index.d.ts +1 -1
  20. package/dist/types/internal-urls/{pi-protocol.d.ts → omp-protocol.d.ts} +3 -3
  21. package/dist/types/internal-urls/types.d.ts +1 -1
  22. package/dist/types/modes/acp/acp-agent.d.ts +1 -0
  23. package/dist/types/modes/emoji-autocomplete.d.ts +16 -0
  24. package/dist/types/modes/interactive-mode.d.ts +1 -1
  25. package/dist/types/modes/prompt-action-autocomplete.d.ts +4 -0
  26. package/dist/types/plan-mode/approved-plan.d.ts +4 -0
  27. package/dist/types/sdk.d.ts +10 -3
  28. package/dist/types/session/agent-session.d.ts +1 -1
  29. package/dist/types/session/auth-broker-config.d.ts +13 -0
  30. package/dist/types/session/auth-storage.d.ts +1 -1
  31. package/dist/types/tools/eval.d.ts +41 -7
  32. package/dist/types/tools/irc.d.ts +8 -2
  33. package/dist/types/tools/report-tool-issue.d.ts +118 -1
  34. package/dist/types/tools/resolve.d.ts +8 -2
  35. package/examples/custom-tools/README.md +3 -12
  36. package/examples/extensions/README.md +2 -15
  37. package/examples/extensions/api-demo.ts +1 -7
  38. package/package.json +7 -7
  39. package/src/autoresearch/tools/init-experiment.ts +11 -33
  40. package/src/autoresearch/tools/log-experiment.ts +10 -24
  41. package/src/autoresearch/tools/run-experiment.ts +1 -1
  42. package/src/autoresearch/tools/update-notes.ts +2 -9
  43. package/src/cli/auth-broker-cli.ts +746 -0
  44. package/src/cli/auth-gateway-cli.ts +342 -0
  45. package/src/cli/grievances-cli.ts +109 -16
  46. package/src/cli.ts +4 -2
  47. package/src/commands/auth-broker.ts +96 -0
  48. package/src/commands/auth-gateway.ts +61 -0
  49. package/src/commands/grievances.ts +13 -8
  50. package/src/commands/launch.ts +1 -1
  51. package/src/commit/agentic/agent.ts +2 -0
  52. package/src/commit/agentic/tools/analyze-file.ts +2 -2
  53. package/src/commit/agentic/tools/git-file-diff.ts +2 -2
  54. package/src/commit/agentic/tools/git-hunk.ts +3 -3
  55. package/src/commit/agentic/tools/git-overview.ts +2 -2
  56. package/src/commit/agentic/tools/propose-changelog.ts +1 -3
  57. package/src/commit/agentic/tools/recent-commits.ts +1 -1
  58. package/src/commit/agentic/tools/schemas.ts +1 -9
  59. package/src/config/model-equivalence.ts +279 -174
  60. package/src/config/model-registry.ts +37 -6
  61. package/src/config/model-resolver.ts +13 -8
  62. package/src/config/models-config-schema.ts +8 -0
  63. package/src/config/settings-schema.ts +52 -0
  64. package/src/cursor.ts +1 -1
  65. package/src/debug/log-formatting.ts +1 -1
  66. package/src/debug/log-viewer.ts +1 -1
  67. package/src/debug/profiler.ts +4 -0
  68. package/src/debug/raw-sse-buffer.ts +100 -59
  69. package/src/debug/raw-sse.ts +1 -1
  70. package/src/discovery/agents.ts +15 -4
  71. package/src/edit/modes/apply-patch.ts +1 -5
  72. package/src/edit/modes/patch.ts +5 -5
  73. package/src/edit/modes/replace.ts +5 -5
  74. package/src/edit/renderer.ts +2 -1
  75. package/src/edit/streaming.ts +1 -1
  76. package/src/eval/index.ts +0 -2
  77. package/src/eval/js/shared/runtime.ts +25 -0
  78. package/src/eval/py/kernel.ts +1 -1
  79. package/src/exa/researcher.ts +4 -4
  80. package/src/exa/search.ts +10 -22
  81. package/src/exa/websets.ts +33 -33
  82. package/src/goals/tools/goal-tool.ts +3 -3
  83. package/src/index.ts +0 -3
  84. package/src/internal-urls/docs-index.generated.ts +21 -18
  85. package/src/internal-urls/index.ts +1 -1
  86. package/src/internal-urls/{pi-protocol.ts → omp-protocol.ts} +10 -10
  87. package/src/internal-urls/router.ts +3 -3
  88. package/src/internal-urls/types.ts +1 -1
  89. package/src/lsp/types.ts +8 -11
  90. package/src/main.ts +3 -0
  91. package/src/mcp/tool-bridge.ts +3 -3
  92. package/src/modes/acp/acp-agent.ts +88 -25
  93. package/src/modes/components/bash-execution.ts +1 -1
  94. package/src/modes/components/diff.ts +1 -2
  95. package/src/modes/components/eval-execution.ts +1 -1
  96. package/src/modes/components/oauth-selector.ts +38 -2
  97. package/src/modes/components/tool-execution.ts +1 -2
  98. package/src/modes/controllers/command-controller.ts +95 -34
  99. package/src/modes/controllers/input-controller.ts +4 -3
  100. package/src/modes/data/emojis.json +1 -0
  101. package/src/modes/emoji-autocomplete.ts +285 -0
  102. package/src/modes/interactive-mode.ts +92 -19
  103. package/src/modes/print-mode.ts +3 -3
  104. package/src/modes/prompt-action-autocomplete.ts +14 -0
  105. package/src/plan-mode/approved-plan.ts +9 -0
  106. package/src/prompts/system/system-prompt.md +1 -1
  107. package/src/prompts/system/ttsr-tool-reminder.md +5 -0
  108. package/src/prompts/tools/eval.md +25 -26
  109. package/src/prompts/tools/read.md +1 -1
  110. package/src/prompts/tools/resolve.md +1 -1
  111. package/src/prompts/tools/search.md +1 -1
  112. package/src/prompts/tools/web-search.md +1 -1
  113. package/src/sdk.ts +78 -7
  114. package/src/session/agent-session.ts +176 -77
  115. package/src/session/agent-storage.ts +7 -2
  116. package/src/session/auth-broker-config.ts +102 -0
  117. package/src/session/auth-storage.ts +7 -1
  118. package/src/session/streaming-output.ts +1 -1
  119. package/src/task/types.ts +10 -35
  120. package/src/tools/bash-interactive.ts +4 -1
  121. package/src/tools/bash-pty-selection.ts +2 -2
  122. package/src/tools/browser.ts +12 -20
  123. package/src/tools/eval.ts +77 -100
  124. package/src/tools/gh.ts +21 -45
  125. package/src/tools/hindsight-recall.ts +1 -1
  126. package/src/tools/hindsight-reflect.ts +2 -2
  127. package/src/tools/hindsight-retain.ts +3 -7
  128. package/src/tools/index.ts +8 -1
  129. package/src/tools/inspect-image.ts +4 -1
  130. package/src/tools/irc.ts +4 -12
  131. package/src/tools/job.ts +3 -11
  132. package/src/tools/report-tool-issue.ts +462 -17
  133. package/src/tools/resolve.ts +2 -7
  134. package/src/tools/todo-write.ts +8 -15
  135. package/src/utils/title-generator.ts +3 -0
  136. package/src/web/search/index.ts +6 -6
  137. package/dist/types/eval/parse.d.ts +0 -28
  138. package/dist/types/eval/sniff.d.ts +0 -11
  139. package/src/eval/eval.lark +0 -36
  140. package/src/eval/parse.ts +0 -407
  141. package/src/eval/sniff.ts +0 -28
@@ -0,0 +1,746 @@
1
+ /**
2
+ * `omp auth-broker` command handlers.
3
+ *
4
+ * Sub-verbs:
5
+ * - `serve [--bind=…]` — boots the broker against the local SQLite store.
6
+ * - `token` / `token --regenerate` — manages the bearer token file.
7
+ * - `login <provider> [--via=user@host]` — logs into a provider locally, or
8
+ * via SSH tunnel into a remote broker host.
9
+ * - `import <file|dir>` — imports CLIProxyAPI-style JSON credentials into
10
+ * the local SQLite store (typical use: `import ~/.cliproxy/auth`).
11
+ * - `migrate --from-local [--include-env] [--include-oauth] [--dry-run]` —
12
+ * uploads local SQLite + env API keys to the broker, skipping anything
13
+ * the broker already has.
14
+ * - `status` — health-pings the configured remote broker.
15
+ */
16
+ import * as crypto from "node:crypto";
17
+ import * as fs from "node:fs/promises";
18
+ import * as os from "node:os";
19
+ import * as path from "node:path";
20
+ import {
21
+ AuthBrokerClient,
22
+ type AuthCredential,
23
+ AuthStorage,
24
+ type CredentialDisabledEvent,
25
+ DEFAULT_AUTH_BROKER_BIND,
26
+ getEnvApiKey,
27
+ getOAuthProviders,
28
+ listProvidersWithEnvKey,
29
+ type OAuthCredential,
30
+ type OAuthProvider,
31
+ SqliteAuthCredentialStore,
32
+ startAuthBroker,
33
+ } from "@oh-my-pi/pi-ai";
34
+ import { $which, APP_NAME, getAgentDbPath, getConfigRootDir, isEnoent, logger, VERSION } from "@oh-my-pi/pi-utils";
35
+ import { $ } from "bun";
36
+ import chalk from "chalk";
37
+ import { resolveAuthBrokerConfig } from "../session/auth-broker-config";
38
+
39
+ export type AuthBrokerAction = "serve" | "token" | "login" | "logout" | "status" | "import" | "migrate";
40
+
41
+ export interface AuthBrokerCommandArgs {
42
+ action: AuthBrokerAction;
43
+ flags: {
44
+ json?: boolean;
45
+ bind?: string;
46
+ regenerate?: boolean;
47
+ via?: string;
48
+ provider?: string;
49
+ dryRun?: boolean;
50
+ /** `login`/`logout`: provider id. `import`: filesystem path. */
51
+ source?: string;
52
+ /** `import`: keep credentials whose JSON had `disabled: true`. */
53
+ includeDisabled?: boolean;
54
+ /** `migrate`: also upload local OAuth (default: api_key only, since OAuth is via cliproxy import). */
55
+ includeOauth?: boolean;
56
+ /** `migrate`: also capture env-var API keys for providers not yet on broker. */
57
+ includeEnv?: boolean;
58
+ /** `migrate`: required `--from-local` source. Reserved for future sources. */
59
+ fromLocal?: boolean;
60
+ };
61
+ }
62
+
63
+ const ACTIONS: readonly AuthBrokerAction[] = ["serve", "token", "login", "logout", "import", "migrate", "status"];
64
+
65
+ /** Callback ports baked from the per-provider OAuth flow modules. */
66
+ const CALLBACK_PORTS: Record<string, number> = {
67
+ anthropic: 54545,
68
+ "openai-codex": 1455,
69
+ "google-gemini-cli": 8085,
70
+ "google-antigravity": 51121,
71
+ "gitlab-duo": 8080,
72
+ };
73
+
74
+ function getTokenFilePath(): string {
75
+ return path.join(getConfigRootDir(), "auth-broker.token");
76
+ }
77
+
78
+ async function readToken(): Promise<string | null> {
79
+ try {
80
+ const raw = await Bun.file(getTokenFilePath()).text();
81
+ const trimmed = raw.trim();
82
+ return trimmed.length > 0 ? trimmed : null;
83
+ } catch (err) {
84
+ if (isEnoent(err)) return null;
85
+ throw err;
86
+ }
87
+ }
88
+
89
+ async function writeToken(token: string): Promise<void> {
90
+ const file = getTokenFilePath();
91
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
92
+ await Bun.write(file, token);
93
+ try {
94
+ await fs.chmod(file, 0o600);
95
+ } catch {
96
+ // Best-effort (e.g. Windows).
97
+ }
98
+ }
99
+
100
+ function generateToken(): string {
101
+ return crypto.randomBytes(32).toString("base64url");
102
+ }
103
+
104
+ async function ensureToken(): Promise<string> {
105
+ const existing = await readToken();
106
+ if (existing) return existing;
107
+ const token = generateToken();
108
+ await writeToken(token);
109
+ return token;
110
+ }
111
+
112
+ async function runServe(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
113
+ // The broker is a long-running headless service: route structured logs to
114
+ // stdout so a process supervisor (pm2, journald, k8s) captures them, and
115
+ // skip the rotating ~/.omp/logs/ file the TUI default would have used.
116
+ logger.setTransports({ console: true, file: false });
117
+
118
+ const bind = flags.bind ?? DEFAULT_AUTH_BROKER_BIND;
119
+ const token = await ensureToken();
120
+ const dbPath = getAgentDbPath();
121
+ const store = await SqliteAuthCredentialStore.open(dbPath);
122
+ const storage = new AuthStorage(store);
123
+ await storage.reload();
124
+ const handle = startAuthBroker({
125
+ storage,
126
+ bind,
127
+ bearerTokens: [token],
128
+ version: VERSION,
129
+ });
130
+ logger.info("auth-broker listening", { url: handle.url });
131
+ logger.info("auth-broker bearer token loaded", { path: getTokenFilePath(), mode: "0600" });
132
+
133
+ const credentialDisabledUnsub = storage.onCredentialDisabled((event: CredentialDisabledEvent) => {
134
+ logger.warn("auth-broker credential disabled", { ...event });
135
+ });
136
+
137
+ const shutdown = async (signal: NodeJS.Signals): Promise<void> => {
138
+ logger.info("auth-broker shutting down", { signal });
139
+ credentialDisabledUnsub();
140
+ await handle.close();
141
+ storage.close();
142
+ process.exit(0);
143
+ };
144
+ process.once("SIGINT", () => void shutdown("SIGINT"));
145
+ process.once("SIGTERM", () => void shutdown("SIGTERM"));
146
+
147
+ // Block forever; lifecycle is signal-driven.
148
+ await new Promise<never>(() => {});
149
+ }
150
+
151
+ async function runToken(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
152
+ if (flags.regenerate) {
153
+ const next = generateToken();
154
+ await writeToken(next);
155
+ if (flags.json) {
156
+ process.stdout.write(`${JSON.stringify({ token: next, path: getTokenFilePath() })}\n`);
157
+ } else {
158
+ process.stdout.write(`${next}\n`);
159
+ }
160
+ return;
161
+ }
162
+ const token = await ensureToken();
163
+ if (flags.json) {
164
+ process.stdout.write(`${JSON.stringify({ token, path: getTokenFilePath() })}\n`);
165
+ } else {
166
+ process.stdout.write(`${token}\n`);
167
+ }
168
+ }
169
+
170
+ async function runLogin(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
171
+ const providerArg = flags.provider;
172
+ if (!providerArg) {
173
+ throw new Error("Usage: omp auth-broker login <provider> [--via=user@host]");
174
+ }
175
+ const oauthProviders = new Set<string>(getOAuthProviders().map(p => p.id));
176
+ if (!oauthProviders.has(providerArg)) {
177
+ throw new Error(`Unknown OAuth provider '${providerArg}'. Known: ${[...oauthProviders].sort().join(", ")}`);
178
+ }
179
+ if (flags.via) {
180
+ await runRemoteLogin(providerArg, flags.via, flags.dryRun ?? false);
181
+ return;
182
+ }
183
+ await runLocalLogin(providerArg as OAuthProvider);
184
+ }
185
+
186
+ async function runLocalLogin(provider: OAuthProvider): Promise<void> {
187
+ // Spawn the pi-ai CLI in-process — it handles the per-provider OAuth dance
188
+ // and persists into the same SQLite store the broker uses.
189
+ const piAiCli = Bun.fileURLToPath(import.meta.resolve("@oh-my-pi/pi-ai/cli"));
190
+ const proc = Bun.spawn({
191
+ cmd: [process.execPath, piAiCli, "login", provider],
192
+ stdin: "inherit",
193
+ stdout: "inherit",
194
+ stderr: "inherit",
195
+ });
196
+ const exitCode = await proc.exited;
197
+ if (exitCode !== 0) {
198
+ throw new Error(`pi-ai login exited with code ${exitCode}`);
199
+ }
200
+ }
201
+
202
+ async function runRemoteLogin(provider: string, via: string, dryRun: boolean): Promise<void> {
203
+ const port = CALLBACK_PORTS[provider];
204
+ if (port === undefined) {
205
+ throw new Error(
206
+ `No known OAuth callback port for '${provider}'. Use device-code flow on the broker host directly.`,
207
+ );
208
+ }
209
+ const sshArgs = [
210
+ "-L",
211
+ `${port}:127.0.0.1:${port}`,
212
+ "-o",
213
+ "ExitOnForwardFailure=yes",
214
+ via,
215
+ `${APP_NAME} auth-broker login ${provider}`,
216
+ ];
217
+ if (dryRun) {
218
+ process.stdout.write(`ssh ${sshArgs.map(a => (a.includes(" ") ? `'${a}'` : a)).join(" ")}\n`);
219
+ return;
220
+ }
221
+ const sshBin = $which("ssh");
222
+ if (!sshBin) {
223
+ throw new Error("ssh binary not found in PATH");
224
+ }
225
+ const proc = Bun.spawn({
226
+ cmd: [sshBin, ...sshArgs],
227
+ stdin: "inherit",
228
+ stdout: "inherit",
229
+ stderr: "inherit",
230
+ });
231
+ const exitCode = await proc.exited;
232
+ if (exitCode !== 0) {
233
+ throw new Error(`ssh exited with code ${exitCode}`);
234
+ }
235
+ }
236
+
237
+ async function runLogout(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
238
+ const providerArg = flags.provider;
239
+ if (!providerArg) {
240
+ throw new Error("Usage: omp auth-broker logout <provider>");
241
+ }
242
+ const store = await SqliteAuthCredentialStore.open(getAgentDbPath());
243
+ try {
244
+ store.deleteAuthCredentialsForProvider(providerArg, "logged out by user");
245
+ process.stdout.write(`Logged out of ${providerArg}\n`);
246
+ } finally {
247
+ store.close();
248
+ }
249
+ }
250
+
251
+ // ─── CLIProxyAPI import ─────────────────────────────────────────────────
252
+
253
+ /**
254
+ * Maps the `type` field of a CLIProxyAPI credential JSON to the omp provider id.
255
+ * The filename also encodes the type (e.g. `claude-foo@bar.json`), but the
256
+ * in-file `type` is authoritative — we only fall back to filename if absent.
257
+ */
258
+ const CLIPROXY_TYPE_TO_PROVIDER: Record<string, string> = {
259
+ claude: "anthropic",
260
+ codex: "openai-codex",
261
+ gemini: "google-gemini-cli",
262
+ antigravity: "google-antigravity",
263
+ "gemini-cli": "google-gemini-cli",
264
+ };
265
+
266
+ interface CliProxyCredentialJson {
267
+ type?: string;
268
+ access_token?: string;
269
+ refresh_token?: string;
270
+ id_token?: string;
271
+ expired?: string;
272
+ last_refresh?: string;
273
+ email?: string;
274
+ account_id?: string;
275
+ disabled?: boolean;
276
+ }
277
+
278
+ interface ImportPlanEntry {
279
+ sourceFile: string;
280
+ provider: string;
281
+ email: string | null;
282
+ accountId: string | null;
283
+ expiresAt: number;
284
+ disabled: boolean;
285
+ credential: OAuthCredential;
286
+ }
287
+
288
+ function resolveCliProxyProvider(json: CliProxyCredentialJson, filename: string, overrideId?: string): string | null {
289
+ if (overrideId && overrideId.length > 0) return overrideId;
290
+ const typeField = json.type?.trim().toLowerCase();
291
+ if (typeField && CLIPROXY_TYPE_TO_PROVIDER[typeField]) return CLIPROXY_TYPE_TO_PROVIDER[typeField];
292
+ // Fall back to filename prefix: `<type>-<email>.json`
293
+ const base = path.basename(filename, ".json").toLowerCase();
294
+ for (const prefix in CLIPROXY_TYPE_TO_PROVIDER) {
295
+ const providerId = CLIPROXY_TYPE_TO_PROVIDER[prefix];
296
+ if (base.startsWith(`${prefix}-`) || base === prefix) return providerId;
297
+ }
298
+ return null;
299
+ }
300
+
301
+ function parseCliProxyExpiry(raw: string | undefined): number | null {
302
+ if (!raw) return null;
303
+ // CLIProxyAPI writes RFC3339-ish dates. `Date.parse` handles both `Z` and offsets.
304
+ const ms = Date.parse(raw);
305
+ if (!Number.isFinite(ms)) return null;
306
+ return ms;
307
+ }
308
+
309
+ async function collectImportSources(target: string): Promise<string[]> {
310
+ const stat = await fs.stat(target);
311
+ if (stat.isFile()) return [target];
312
+ if (!stat.isDirectory()) {
313
+ throw new Error(`Import source is neither file nor directory: ${target}`);
314
+ }
315
+ const entries = await fs.readdir(target, { withFileTypes: true });
316
+ const files: string[] = [];
317
+ for (const entry of entries) {
318
+ if (!entry.isFile()) continue;
319
+ if (!entry.name.endsWith(".json")) continue;
320
+ files.push(path.join(target, entry.name));
321
+ }
322
+ files.sort();
323
+ return files;
324
+ }
325
+
326
+ async function loadImportPlan(
327
+ target: string,
328
+ overrideProvider: string | undefined,
329
+ includeDisabled: boolean,
330
+ ): Promise<{ entries: ImportPlanEntry[]; skipped: Array<{ file: string; reason: string }> }> {
331
+ const files = await collectImportSources(target);
332
+ const entries: ImportPlanEntry[] = [];
333
+ const skipped: Array<{ file: string; reason: string }> = [];
334
+ for (const file of files) {
335
+ let json: CliProxyCredentialJson;
336
+ try {
337
+ json = (await Bun.file(file).json()) as CliProxyCredentialJson;
338
+ } catch (err) {
339
+ skipped.push({ file, reason: `unreadable JSON: ${String(err)}` });
340
+ continue;
341
+ }
342
+ if (json.disabled === true && !includeDisabled) {
343
+ skipped.push({ file, reason: "credential marked disabled (use --include-disabled to import anyway)" });
344
+ continue;
345
+ }
346
+ const provider = resolveCliProxyProvider(json, file, overrideProvider);
347
+ if (!provider) {
348
+ skipped.push({
349
+ file,
350
+ reason: `cannot determine omp provider from type=${json.type ?? "?"} (pass --provider to override)`,
351
+ });
352
+ continue;
353
+ }
354
+ if (!json.access_token || !json.refresh_token) {
355
+ skipped.push({ file, reason: "missing access_token or refresh_token" });
356
+ continue;
357
+ }
358
+ const expiresAt = parseCliProxyExpiry(json.expired);
359
+ if (expiresAt === null) {
360
+ skipped.push({ file, reason: `cannot parse expired=${json.expired ?? "?"}` });
361
+ continue;
362
+ }
363
+ const email = typeof json.email === "string" && json.email.length > 0 ? json.email : null;
364
+ const accountId = typeof json.account_id === "string" && json.account_id.length > 0 ? json.account_id : null;
365
+ const credential: OAuthCredential = {
366
+ type: "oauth",
367
+ access: json.access_token,
368
+ refresh: json.refresh_token,
369
+ expires: expiresAt,
370
+ ...(email !== null ? { email } : {}),
371
+ ...(accountId !== null ? { accountId } : {}),
372
+ };
373
+ entries.push({
374
+ sourceFile: file,
375
+ provider,
376
+ email,
377
+ accountId,
378
+ expiresAt,
379
+ disabled: json.disabled === true,
380
+ credential,
381
+ });
382
+ }
383
+ return { entries, skipped };
384
+ }
385
+
386
+ function describeImportEntry(entry: ImportPlanEntry): string {
387
+ const ident = entry.email ?? entry.accountId ?? "(no identity)";
388
+ const stale = entry.expiresAt < Date.now() ? " [expired]" : "";
389
+ const disabled = entry.disabled ? " [disabled]" : "";
390
+ return `${entry.provider}: ${ident}${stale}${disabled} from ${entry.sourceFile}`;
391
+ }
392
+
393
+ async function runImport(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
394
+ const target = flags.source;
395
+ if (!target) {
396
+ throw new Error("Usage: omp auth-broker import <file|dir> [--provider=<id>] [--include-disabled] [--dry-run]");
397
+ }
398
+ const resolvedTarget = path.resolve(target.startsWith("~") ? target.replace(/^~/, os.homedir()) : target);
399
+ const { entries, skipped } = await loadImportPlan(resolvedTarget, flags.provider, flags.includeDisabled === true);
400
+
401
+ if (flags.json) {
402
+ process.stdout.write(
403
+ `${JSON.stringify({
404
+ dryRun: flags.dryRun === true,
405
+ imported: flags.dryRun
406
+ ? []
407
+ : entries.map(e => ({ provider: e.provider, email: e.email, file: e.sourceFile })),
408
+ plan: entries.map(e => ({
409
+ provider: e.provider,
410
+ email: e.email,
411
+ accountId: e.accountId,
412
+ expiresAt: e.expiresAt,
413
+ disabled: e.disabled,
414
+ file: e.sourceFile,
415
+ })),
416
+ skipped,
417
+ })}\n`,
418
+ );
419
+ }
420
+
421
+ if (!flags.json) {
422
+ for (const skip of skipped) {
423
+ process.stdout.write(`${chalk.yellow("skip")} ${skip.file}: ${skip.reason}\n`);
424
+ }
425
+ }
426
+
427
+ if (entries.length === 0) {
428
+ if (!flags.json) process.stdout.write(`No importable credentials in ${resolvedTarget}.\n`);
429
+ return;
430
+ }
431
+
432
+ if (flags.dryRun === true) {
433
+ if (!flags.json) {
434
+ process.stdout.write(`Dry run — would import ${entries.length} credential(s):\n`);
435
+ for (const entry of entries) process.stdout.write(` ${describeImportEntry(entry)}\n`);
436
+ }
437
+ return;
438
+ }
439
+
440
+ const brokerConfig = await resolveAuthBrokerConfig();
441
+ if (brokerConfig) {
442
+ const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
443
+ for (const entry of entries) {
444
+ try {
445
+ await client.uploadCredential(entry.provider, entry.credential);
446
+ if (!flags.json) {
447
+ process.stdout.write(`${chalk.green("uploaded")} ${describeImportEntry(entry)} → ${brokerConfig.url}\n`);
448
+ }
449
+ } catch (error) {
450
+ const message = error instanceof Error ? error.message : String(error);
451
+ if (flags.json) {
452
+ process.stdout.write(`${JSON.stringify({ error: message, file: entry.sourceFile })}\n`);
453
+ } else {
454
+ process.stdout.write(`${chalk.red("failed")} ${describeImportEntry(entry)}: ${message}\n`);
455
+ }
456
+ process.exitCode = 1;
457
+ }
458
+ }
459
+ return;
460
+ }
461
+
462
+ const store = await SqliteAuthCredentialStore.open(getAgentDbPath());
463
+ try {
464
+ for (const entry of entries) {
465
+ store.upsertAuthCredentialForProvider(entry.provider, entry.credential);
466
+ if (!flags.json) process.stdout.write(`${chalk.green("imported")} ${describeImportEntry(entry)}\n`);
467
+ }
468
+ } finally {
469
+ store.close();
470
+ }
471
+ }
472
+
473
+ // ─── Migrate: local SQLite + env → broker ──────────────────────────────
474
+
475
+ interface MigratePlanEntry {
476
+ source: "local-sqlite" | "env";
477
+ provider: string;
478
+ credential: AuthCredential;
479
+ identity: string;
480
+ }
481
+
482
+ interface MigrateSkip {
483
+ source: "local-sqlite" | "env";
484
+ provider: string;
485
+ identity: string;
486
+ reason: string;
487
+ }
488
+
489
+ function credentialIdentity(provider: string, credential: AuthCredential): string {
490
+ if (credential.type === "api_key") return "(api key)";
491
+ return credential.email ?? credential.accountId ?? credential.projectId ?? `<${provider} oauth>`;
492
+ }
493
+
494
+ /**
495
+ * Build the set of "identities already on the broker" so re-runs are idempotent.
496
+ * For OAuth, identity = email|accountId|projectId. For api_key, we collapse
497
+ * to a single marker per provider (broker has no concept of "multiple api keys
498
+ * per provider with different identities"; upsert would coalesce them).
499
+ */
500
+ function indexBrokerSnapshot(snapshot: {
501
+ credentials: Array<{
502
+ provider: string;
503
+ credential: { type: string; email?: string; accountId?: string; projectId?: string };
504
+ }>;
505
+ }): Map<string, Set<string>> {
506
+ const out = new Map<string, Set<string>>();
507
+ for (const entry of snapshot.credentials) {
508
+ const ids = out.get(entry.provider) ?? new Set<string>();
509
+ if (entry.credential.type === "api_key") {
510
+ ids.add("@api_key");
511
+ } else {
512
+ if (entry.credential.email) ids.add(`email:${entry.credential.email}`);
513
+ if (entry.credential.accountId) ids.add(`accountId:${entry.credential.accountId}`);
514
+ if (entry.credential.projectId) ids.add(`projectId:${entry.credential.projectId}`);
515
+ }
516
+ out.set(entry.provider, ids);
517
+ }
518
+ return out;
519
+ }
520
+
521
+ function brokerAlreadyHas(existing: Map<string, Set<string>>, provider: string, credential: AuthCredential): boolean {
522
+ const ids = existing.get(provider);
523
+ if (!ids) return false;
524
+ if (credential.type === "api_key") return ids.has("@api_key");
525
+ if (credential.email && ids.has(`email:${credential.email}`)) return true;
526
+ if (credential.accountId && ids.has(`accountId:${credential.accountId}`)) return true;
527
+ if (credential.projectId && ids.has(`projectId:${credential.projectId}`)) return true;
528
+ return false;
529
+ }
530
+
531
+ async function runMigrate(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
532
+ const brokerConfig = await resolveAuthBrokerConfig();
533
+ if (!brokerConfig) {
534
+ throw new Error(
535
+ "OMP_AUTH_BROKER_URL must be set (or `auth.broker.url` in config.yml). `migrate` uploads local credentials to a configured broker.",
536
+ );
537
+ }
538
+ if (flags.fromLocal !== true) {
539
+ throw new Error(
540
+ "`omp auth-broker migrate` requires an explicit source. Pass `--from-local` to migrate from the local SQLite store and env vars.",
541
+ );
542
+ }
543
+
544
+ const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
545
+ const snapshotResult = await client.fetchSnapshot();
546
+ if (snapshotResult.status !== 200) throw new Error("Auth broker returned no snapshot");
547
+ const existing = indexBrokerSnapshot(snapshotResult.snapshot);
548
+
549
+ const plan: MigratePlanEntry[] = [];
550
+ const skipped: MigrateSkip[] = [];
551
+
552
+ // 1. Local SQLite rows.
553
+ const localDbPath = getAgentDbPath();
554
+ const localStore = await SqliteAuthCredentialStore.open(localDbPath);
555
+ const plannedApiKeyProviders = new Set<string>();
556
+ try {
557
+ for (const row of localStore.listAuthCredentials()) {
558
+ // Skip placeholder sentinels that pi-ai treats as "authenticated via
559
+ // out-of-band mechanism" (Bedrock/Vertex `<authenticated>`). They
560
+ // aren't real keys and uploading them would store garbage on the
561
+ // broker. Mirrors the env-var path's guard below.
562
+ if (row.credential.type === "api_key" && row.credential.key === "<authenticated>") {
563
+ skipped.push({
564
+ source: "local-sqlite",
565
+ provider: row.provider,
566
+ identity: "(api key)",
567
+ reason: "placeholder sentinel '<authenticated>' is not a real key",
568
+ });
569
+ continue;
570
+ }
571
+ const identity = credentialIdentity(row.provider, row.credential);
572
+ if (row.credential.type === "oauth" && flags.includeOauth !== true) {
573
+ skipped.push({
574
+ source: "local-sqlite",
575
+ provider: row.provider,
576
+ identity,
577
+ reason: "OAuth from local SQLite skipped by default (use --include-oauth)",
578
+ });
579
+ continue;
580
+ }
581
+ if (brokerAlreadyHas(existing, row.provider, row.credential)) {
582
+ skipped.push({
583
+ source: "local-sqlite",
584
+ provider: row.provider,
585
+ identity,
586
+ reason: "already on broker",
587
+ });
588
+ continue;
589
+ }
590
+ if (row.credential.type === "api_key" && plannedApiKeyProviders.has(row.provider)) {
591
+ skipped.push({
592
+ source: "local-sqlite",
593
+ provider: row.provider,
594
+ identity,
595
+ reason: "another local api_key for this provider already planned",
596
+ });
597
+ continue;
598
+ }
599
+ if (row.credential.type === "api_key") plannedApiKeyProviders.add(row.provider);
600
+ plan.push({ source: "local-sqlite", provider: row.provider, credential: row.credential, identity });
601
+ }
602
+ } finally {
603
+ localStore.close();
604
+ }
605
+
606
+ // 2. Env-var API keys (opt-in).
607
+ if (flags.includeEnv === true) {
608
+ for (const provider of listProvidersWithEnvKey()) {
609
+ const envValue = getEnvApiKey(provider);
610
+ if (!envValue) continue;
611
+ if (envValue === "<authenticated>") continue; // Bedrock/Vertex sentinels — not literal keys.
612
+ const credential: AuthCredential = { type: "api_key", key: envValue };
613
+ if (brokerAlreadyHas(existing, provider, credential)) {
614
+ skipped.push({
615
+ source: "env",
616
+ provider,
617
+ identity: "(api key)",
618
+ reason: "already on broker (provider has an api_key)",
619
+ });
620
+ continue;
621
+ }
622
+ // Also skip if local SQLite already produced an entry for this provider in this batch.
623
+ if (plan.some(p => p.provider === provider && p.credential.type === "api_key")) {
624
+ skipped.push({
625
+ source: "env",
626
+ provider,
627
+ identity: "(api key)",
628
+ reason: "local SQLite already supplied an api_key for this provider",
629
+ });
630
+ continue;
631
+ }
632
+ plan.push({ source: "env", provider, credential, identity: "(api key)" });
633
+ }
634
+ }
635
+
636
+ if (flags.json) {
637
+ process.stdout.write(
638
+ `${JSON.stringify({
639
+ dryRun: flags.dryRun === true,
640
+ plan: plan.map(p => ({ source: p.source, provider: p.provider, identity: p.identity })),
641
+ skipped,
642
+ })}\n`,
643
+ );
644
+ } else {
645
+ for (const skip of skipped) {
646
+ process.stdout.write(
647
+ `${chalk.yellow("skip")} [${skip.source}] ${skip.provider} ${skip.identity}: ${skip.reason}\n`,
648
+ );
649
+ }
650
+ }
651
+
652
+ if (plan.length === 0) {
653
+ if (!flags.json) process.stdout.write("Nothing to migrate.\n");
654
+ return;
655
+ }
656
+
657
+ if (flags.dryRun === true) {
658
+ if (!flags.json) {
659
+ process.stdout.write(`Dry run — would upload ${plan.length} credential(s):\n`);
660
+ for (const entry of plan) {
661
+ process.stdout.write(` [${entry.source}] ${entry.provider} ${entry.identity}\n`);
662
+ }
663
+ }
664
+ return;
665
+ }
666
+
667
+ for (const entry of plan) {
668
+ try {
669
+ await client.uploadCredential(entry.provider, entry.credential);
670
+ if (!flags.json) {
671
+ process.stdout.write(`${chalk.green("uploaded")} [${entry.source}] ${entry.provider} ${entry.identity}\n`);
672
+ }
673
+ } catch (error) {
674
+ const message = error instanceof Error ? error.message : String(error);
675
+ if (flags.json) {
676
+ process.stdout.write(`${JSON.stringify({ error: message, provider: entry.provider })}\n`);
677
+ } else {
678
+ process.stdout.write(`${chalk.red("failed")} [${entry.source}] ${entry.provider}: ${message}\n`);
679
+ }
680
+ process.exitCode = 1;
681
+ }
682
+ }
683
+ }
684
+
685
+ async function runStatus(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
686
+ const cfg = await resolveAuthBrokerConfig();
687
+ if (!cfg) {
688
+ const message = "No auth-broker configured (set OMP_AUTH_BROKER_URL to enable).";
689
+ if (flags.json) process.stdout.write(`${JSON.stringify({ ok: false, reason: "not_configured" })}\n`);
690
+ else process.stdout.write(`${chalk.yellow(message)}\n`);
691
+ return;
692
+ }
693
+ const client = new AuthBrokerClient({ url: cfg.url, token: cfg.token });
694
+ try {
695
+ const health = await client.healthz();
696
+ if (flags.json) {
697
+ process.stdout.write(`${JSON.stringify({ url: cfg.url, ...health })}\n`);
698
+ } else {
699
+ process.stdout.write(`${chalk.green("OK")} ${cfg.url} (version=${health.version ?? "unknown"})\n`);
700
+ }
701
+ } catch (error) {
702
+ const message = error instanceof Error ? error.message : String(error);
703
+ if (flags.json) {
704
+ process.stdout.write(`${JSON.stringify({ ok: false, url: cfg.url, error: message })}\n`);
705
+ } else {
706
+ process.stdout.write(`${chalk.red("FAILED")} ${cfg.url}: ${message}\n`);
707
+ }
708
+ process.exitCode = 1;
709
+ }
710
+ }
711
+
712
+ export async function runAuthBrokerCommand(cmd: AuthBrokerCommandArgs): Promise<void> {
713
+ switch (cmd.action) {
714
+ case "serve":
715
+ await runServe(cmd.flags);
716
+ return;
717
+ case "token":
718
+ await runToken(cmd.flags);
719
+ return;
720
+ case "login":
721
+ await runLogin(cmd.flags);
722
+ return;
723
+ case "logout":
724
+ await runLogout(cmd.flags);
725
+ return;
726
+ case "import":
727
+ await runImport(cmd.flags);
728
+ return;
729
+ case "migrate":
730
+ await runMigrate(cmd.flags);
731
+ return;
732
+ case "status":
733
+ await runStatus(cmd.flags);
734
+ return;
735
+ default: {
736
+ // Exhaustive check.
737
+ const _exhaustive: never = cmd.action;
738
+ throw new Error(`Unknown auth-broker action: ${String(_exhaustive)}`);
739
+ }
740
+ }
741
+ }
742
+
743
+ export { ACTIONS as AUTH_BROKER_ACTIONS };
744
+
745
+ // Touch `$` so Bun's tree-shaker keeps the shell helper imported (used by future verbs).
746
+ void $;