@bitkyc08/opencodex 2.7.28 → 2.7.29

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.
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-TZysP4q4.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-CMKZnkG9.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-DyBPh28A.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.7.28",
3
+ "version": "2.7.29",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Data-access layer for `ocx account` (issue #180) — live-proxy HTTP client and
3
+ * per-family account readers. Kept separate from account.ts (command handlers)
4
+ * per the 400-line module budget.
5
+ */
6
+ import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
7
+ import { runningProxyUpdateHeaders } from "../oauth/login-cli";
8
+ import { isPublicOAuthProvider } from "../oauth/index";
9
+ import { getProviderRegistryEntry, providerCodexAccountMode } from "../providers/registry";
10
+ import type { OcxConfig } from "../types";
11
+
12
+ export type AccountType = "codex" | "oauth" | "api-key";
13
+
14
+ export interface AccountRow {
15
+ provider: string;
16
+ type: AccountType;
17
+ id: string;
18
+ label?: string;
19
+ email?: string;
20
+ plan?: string;
21
+ masked?: string;
22
+ active: boolean;
23
+ needsReauth?: boolean;
24
+ quota?: CodexQuotaDto | null;
25
+ }
26
+
27
+ export type ClassifyResult = { type: AccountType } | { error: string };
28
+
29
+ export type AccountStdin = NodeJS.ReadableStream & { isTTY?: boolean };
30
+
31
+ export interface AccountDeps {
32
+ /** Test injection: skip findLiveProxy and call the API at this base URL. */
33
+ baseUrl?: string;
34
+ fetchImpl?: typeof fetch;
35
+ loadConfigImpl?: () => OcxConfig;
36
+ stdinImpl?: AccountStdin;
37
+ stdinTimeoutMs?: number;
38
+ }
39
+
40
+ export function classifyAccount(config: OcxConfig, name: string): ClassifyResult {
41
+ const provider = config.providers?.[name];
42
+ if (providerCodexAccountMode(name, provider)) return { type: "codex" };
43
+ const entry = getProviderRegistryEntry(name);
44
+ if (entry?.authKind === "local") {
45
+ return { error: `provider "${name}" is a local provider and has no credentials` };
46
+ }
47
+ if (provider?.authMode === "forward") {
48
+ return { error: `provider "${name}" uses forward auth and has no switchable credentials` };
49
+ }
50
+ if (provider?.authMode === "key") return { type: "api-key" };
51
+ if (provider && !provider.authMode && (provider.apiKey || (provider.apiKeyPool?.length ?? 0) > 0)) {
52
+ return { type: "api-key" };
53
+ }
54
+ if (isPublicOAuthProvider(name)) return { type: "oauth" };
55
+ if (provider) return { type: "api-key" };
56
+ return { error: `unknown provider "${name}"` };
57
+ }
58
+
59
+ export interface ApiResult {
60
+ /** 0 = network-level failure (proxy unreachable). */
61
+ status: number;
62
+ json: Record<string, unknown>;
63
+ }
64
+
65
+ export async function apiJson(
66
+ deps: AccountDeps,
67
+ baseUrl: string,
68
+ method: "GET" | "PUT" | "POST" | "DELETE",
69
+ path: string,
70
+ body?: unknown,
71
+ ): Promise<ApiResult> {
72
+ const fetchImpl = deps.fetchImpl ?? fetch;
73
+ try {
74
+ const res = await fetchImpl(`${baseUrl}${path}`, {
75
+ method,
76
+ headers: runningProxyUpdateHeaders(),
77
+ body: body === undefined ? undefined : JSON.stringify(body),
78
+ });
79
+ const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
80
+ return { status: res.status, json };
81
+ } catch {
82
+ return { status: 0, json: {} };
83
+ }
84
+ }
85
+
86
+ export async function resolveBaseUrl(deps: AccountDeps): Promise<string | null> {
87
+ if (deps.baseUrl) return deps.baseUrl;
88
+ const live = await findLiveProxy();
89
+ if (!live) return null;
90
+ return `http://${probeHostname(live.hostname)}:${live.port}`;
91
+ }
92
+
93
+ export function proxyUnreachable(): number {
94
+ console.error("Proxy not reachable. Start it with 'ocx start' or 'ocx ensure'.");
95
+ return 1;
96
+ }
97
+
98
+ export function apiError(json: Record<string, unknown>, fallback: string): number {
99
+ const message = typeof json.error === "string" ? json.error : fallback;
100
+ console.error(`Error: ${message}`);
101
+ return 1;
102
+ }
103
+
104
+ export interface FamilyRows {
105
+ rows: AccountRow[];
106
+ activeId: string | null;
107
+ autoSwitchThreshold?: number;
108
+ /** HTTP status for a completed family read, including failures. */
109
+ status?: number;
110
+ /** Set when the family endpoint returned an error. */
111
+ errorJson?: Record<string, unknown>;
112
+ networkDown?: boolean;
113
+ }
114
+
115
+ export interface CodexQuotaDto {
116
+ weeklyPercent?: number;
117
+ monthlyPercent?: number;
118
+ weeklyResetAt?: number;
119
+ monthlyResetAt?: number;
120
+ }
121
+
122
+ export interface ProviderQuotaWindowDto {
123
+ label: string;
124
+ percent: number;
125
+ resetAt?: number;
126
+ }
127
+
128
+ export interface ProviderQuotaDto extends CodexQuotaDto {
129
+ fiveHourPercent?: number;
130
+ fiveHourResetAt?: number;
131
+ customWindows?: ProviderQuotaWindowDto[];
132
+ updatedAt?: number;
133
+ }
134
+
135
+ export interface ProviderQuotaReportDto {
136
+ provider: string;
137
+ label?: string;
138
+ source?: string;
139
+ quota: ProviderQuotaDto;
140
+ updatedAt?: number;
141
+ reverseEngineered?: boolean;
142
+ }
143
+
144
+ interface CodexAccountDto {
145
+ id: string;
146
+ email?: string;
147
+ plan?: string;
148
+ isMain?: boolean;
149
+ needsReauth?: boolean;
150
+ quota?: CodexQuotaDto | null;
151
+ }
152
+
153
+ function projectQuota(quota: CodexQuotaDto | null | undefined): CodexQuotaDto | null {
154
+ if (!quota) return null;
155
+ const projected: CodexQuotaDto = {};
156
+ for (const key of ["weeklyPercent", "monthlyPercent", "weeklyResetAt", "monthlyResetAt"] as const) {
157
+ if (typeof quota[key] === "number" && Number.isFinite(quota[key])) projected[key] = quota[key];
158
+ }
159
+ return projected;
160
+ }
161
+
162
+ export async function fetchCodexRows(
163
+ deps: AccountDeps,
164
+ baseUrl: string,
165
+ forceRefresh = false,
166
+ ): Promise<FamilyRows> {
167
+ const accountsPath = `/api/codex-auth/accounts${forceRefresh ? "?refresh=1" : ""}`;
168
+ const [accountsRes, activeRes] = await Promise.all([
169
+ apiJson(deps, baseUrl, "GET", accountsPath),
170
+ apiJson(deps, baseUrl, "GET", "/api/codex-auth/active"),
171
+ ]);
172
+ if (accountsRes.status !== 0 && accountsRes.status !== 200) {
173
+ return { rows: [], activeId: null, status: accountsRes.status, errorJson: accountsRes.json };
174
+ }
175
+ if (activeRes.status !== 0 && activeRes.status !== 200) {
176
+ return { rows: [], activeId: null, status: activeRes.status, errorJson: activeRes.json };
177
+ }
178
+ if (accountsRes.status === 0 || activeRes.status === 0) {
179
+ return { rows: [], activeId: null, status: 0, networkDown: true };
180
+ }
181
+ const activeId = typeof activeRes.json.activeCodexAccountId === "string"
182
+ ? activeRes.json.activeCodexAccountId
183
+ : null;
184
+ const autoSwitchThreshold = typeof activeRes.json.autoSwitchThreshold === "number"
185
+ ? activeRes.json.autoSwitchThreshold
186
+ : undefined;
187
+ const accounts = Array.isArray(accountsRes.json.accounts) ? accountsRes.json.accounts as CodexAccountDto[] : [];
188
+ const rows = accounts.map(a => ({
189
+ provider: "openai",
190
+ type: "codex" as const,
191
+ id: a.id,
192
+ label: a.plan ?? a.email,
193
+ email: a.email,
194
+ plan: a.plan,
195
+ active: a.id === activeId,
196
+ needsReauth: a.needsReauth,
197
+ ...(forceRefresh ? { quota: projectQuota(a.quota) } : {}),
198
+ }));
199
+ return { rows, activeId, autoSwitchThreshold, status: 200 };
200
+ }
201
+
202
+ interface OAuthAccountDto {
203
+ id: string;
204
+ email?: string;
205
+ active?: boolean;
206
+ needsReauth?: boolean;
207
+ }
208
+
209
+ async function fetchOAuthRows(deps: AccountDeps, baseUrl: string, name: string): Promise<FamilyRows> {
210
+ const res = await apiJson(deps, baseUrl, "GET", `/api/oauth/accounts?provider=${encodeURIComponent(name)}`);
211
+ if (res.status === 0) return { rows: [], activeId: null, status: 0, networkDown: true };
212
+ if (res.status !== 200) return { rows: [], activeId: null, status: res.status, errorJson: res.json };
213
+ const activeId = typeof res.json.activeAccountId === "string" ? res.json.activeAccountId : null;
214
+ const accounts = Array.isArray(res.json.accounts) ? res.json.accounts as OAuthAccountDto[] : [];
215
+ const rows = accounts.map((a, i) => ({
216
+ provider: name,
217
+ type: "oauth" as const,
218
+ id: a.id,
219
+ label: a.email ?? `Account ${i + 1}`,
220
+ email: a.email,
221
+ active: a.active ?? a.id === activeId,
222
+ needsReauth: a.needsReauth,
223
+ }));
224
+ return { rows, activeId, status: 200 };
225
+ }
226
+
227
+ interface ApiKeyDto {
228
+ id: string;
229
+ label?: string;
230
+ masked?: string;
231
+ active?: boolean;
232
+ }
233
+
234
+ async function fetchKeyRows(deps: AccountDeps, baseUrl: string, name: string): Promise<FamilyRows> {
235
+ const res = await apiJson(deps, baseUrl, "GET", `/api/providers/keys?name=${encodeURIComponent(name)}`);
236
+ if (res.status === 0) return { rows: [], activeId: null, status: 0, networkDown: true };
237
+ if (res.status !== 200) return { rows: [], activeId: null, status: res.status, errorJson: res.json };
238
+ const activeId = typeof res.json.activeId === "string" ? res.json.activeId : null;
239
+ const keys = Array.isArray(res.json.keys) ? res.json.keys as ApiKeyDto[] : [];
240
+ const rows = keys.map(k => ({
241
+ provider: name,
242
+ type: "api-key" as const,
243
+ id: k.id,
244
+ label: k.label ?? k.masked,
245
+ masked: k.masked,
246
+ active: k.active ?? k.id === activeId,
247
+ }));
248
+ return { rows, activeId, status: 200 };
249
+ }
250
+
251
+ export function fetchRows(deps: AccountDeps, baseUrl: string, name: string, type: AccountType): Promise<FamilyRows> {
252
+ if (type === "codex") return fetchCodexRows(deps, baseUrl);
253
+ if (type === "oauth") return fetchOAuthRows(deps, baseUrl, name);
254
+ return fetchKeyRows(deps, baseUrl, name);
255
+ }
256
+
257
+ export async function fetchProviderQuotaReport(
258
+ deps: AccountDeps,
259
+ baseUrl: string,
260
+ name: string,
261
+ ): Promise<{ status: number; report: ProviderQuotaReportDto | null; errorJson?: Record<string, unknown> }> {
262
+ const res = await apiJson(deps, baseUrl, "GET", "/api/provider-quotas?refresh=1");
263
+ if (res.status !== 200) return { status: res.status, report: null, errorJson: res.json };
264
+ const reports = Array.isArray(res.json.reports) ? res.json.reports as ProviderQuotaReportDto[] : [];
265
+ return { status: 200, report: reports.find(report => report?.provider === name) ?? null };
266
+ }
@@ -0,0 +1,282 @@
1
+ import { loadConfig } from "../config";
2
+ import {
3
+ apiError,
4
+ apiJson,
5
+ classifyAccount,
6
+ fetchCodexRows,
7
+ fetchProviderQuotaReport,
8
+ fetchRows,
9
+ proxyUnreachable,
10
+ resolveBaseUrl,
11
+ type AccountDeps, type AccountStdin, type FamilyRows,
12
+ type ProviderQuotaDto, type ProviderQuotaReportDto,
13
+ } from "./account-api";
14
+
15
+ const MAIN_ID = "__main__";
16
+ const AUTO_NOTE = "auto (no pin — lowest-usage account is selected per request)";
17
+ const EXTENDED_USAGE = `Usage:
18
+ ocx account refresh <provider> [--json]
19
+ ocx account auto-switch <provider> <on|off|status|threshold <0-100>> [--json]
20
+ ocx account remove <provider> <id|main> --yes [--json]
21
+ ocx account add-key <provider> [--label <label>] [--json]`;
22
+ const PIPE_GUIDANCE = `Pipe the API key on stdin, for example:
23
+ ocx account add-key <provider> <<< "$MY_KEY"
24
+ security find-generic-password -w <item> | ocx account add-key <provider>`;
25
+
26
+ function flag(args: string[], value: string): boolean {
27
+ const index = args.indexOf(value);
28
+ if (index < 0) return false;
29
+ args.splice(index, 1);
30
+ return true;
31
+ }
32
+
33
+ function flagValue(args: string[], value: string): { found: boolean; value?: string } {
34
+ const index = args.indexOf(value);
35
+ if (index < 0) return { found: false };
36
+ if (index + 1 >= args.length) return { found: true };
37
+ const result = args[index + 1];
38
+ args.splice(index, 2);
39
+ return { found: true, value: result };
40
+ }
41
+
42
+ function usage(message?: string): number {
43
+ if (message) console.error(message);
44
+ console.error(EXTENDED_USAGE);
45
+ return 1;
46
+ }
47
+
48
+ function configAndType(deps: AccountDeps, name: string) {
49
+ return classifyAccount(deps.loadConfigImpl?.() ?? loadConfig(), name);
50
+ }
51
+
52
+ function familyFailure(result: FamilyRows, fallback: string): number | null {
53
+ if (result.networkDown) return proxyUnreachable();
54
+ if (result.errorJson) return apiError(result.errorJson, fallback);
55
+ return null;
56
+ }
57
+
58
+ function errorText(json: Record<string, unknown> | undefined, fallback: string): string {
59
+ return typeof json?.error === "string" ? json.error : fallback;
60
+ }
61
+
62
+ function resetIso(value: number | undefined): string | null {
63
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
64
+ const date = new Date(value < 10_000_000_000 ? value * 1000 : value);
65
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
66
+ }
67
+
68
+ function refreshLine(row: FamilyRows["rows"][number]): string {
69
+ const parts = [row.id === MAIN_ID ? "main" : row.id, row.email, row.plan];
70
+ const quota = row.quota;
71
+ if (!quota || (quota.weeklyPercent === undefined && quota.monthlyPercent === undefined)) {
72
+ parts.push("quota: unknown");
73
+ } else {
74
+ if (quota.weeklyPercent !== undefined) parts.push(`weekly ${quota.weeklyPercent}%`);
75
+ const weeklyReset = resetIso(quota.weeklyResetAt);
76
+ if (weeklyReset) parts.push(`resets ${weeklyReset}`);
77
+ if (quota.monthlyPercent !== undefined) parts.push(`monthly ${quota.monthlyPercent}%`);
78
+ const monthlyReset = resetIso(quota.monthlyResetAt);
79
+ if (monthlyReset) parts.push(`resets ${monthlyReset}`);
80
+ }
81
+ if (row.needsReauth) parts.push("needs-reauth");
82
+ return parts.filter(Boolean).join(" ");
83
+ }
84
+
85
+ function quotaParts(quota: ProviderQuotaDto): string[] {
86
+ const parts: string[] = [];
87
+ const add = (label: string, percent: number | undefined, resetAt?: number) => {
88
+ if (percent === undefined) return;
89
+ parts.push(`${label} ${percent}%`);
90
+ const reset = resetIso(resetAt);
91
+ if (reset) parts.push(`resets ${reset}`);
92
+ };
93
+ add("5h", quota.fiveHourPercent, quota.fiveHourResetAt);
94
+ add("weekly", quota.weeklyPercent, quota.weeklyResetAt);
95
+ add("monthly", quota.monthlyPercent, quota.monthlyResetAt);
96
+ for (const window of quota.customWindows ?? []) add(window.label, window.percent, window.resetAt);
97
+ return parts;
98
+ }
99
+
100
+ function providerQuotaLine(name: string, report: ProviderQuotaReportDto): string {
101
+ return [name, ...quotaParts(report.quota)].join(" ");
102
+ }
103
+
104
+ export async function readStdinLine(deps: AccountDeps): Promise<string> {
105
+ const input: AccountStdin = deps.stdinImpl ?? process.stdin;
106
+ const timeoutMs = deps.stdinTimeoutMs ?? 15_000;
107
+ return await new Promise<string>((resolve, reject) => {
108
+ let buffer = "";
109
+ let settled = false;
110
+ const cleanup = () => {
111
+ clearTimeout(timer);
112
+ input.removeListener("data", onData);
113
+ input.removeListener("end", onEnd);
114
+ input.removeListener("error", onError);
115
+ };
116
+ const finish = (fn: () => void) => {
117
+ if (settled) return;
118
+ settled = true;
119
+ cleanup();
120
+ fn();
121
+ };
122
+ const onData = (chunk: unknown) => {
123
+ buffer += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
124
+ const newline = buffer.search(/[\r\n]/);
125
+ if (newline >= 0) finish(() => resolve(buffer.slice(0, newline).trim()));
126
+ };
127
+ const onEnd = () => finish(() => resolve(buffer.trim()));
128
+ const onError = (error: Error) => finish(() => reject(error));
129
+ const timer = setTimeout(() => finish(() => reject(new Error("timed out waiting for API key on stdin"))), timeoutMs);
130
+ input.on("data", onData);
131
+ input.on("end", onEnd);
132
+ input.on("error", onError);
133
+ });
134
+ }
135
+
136
+ export async function cmdRefresh(args: string[], deps: AccountDeps): Promise<number> {
137
+ const wantsJson = flag(args, "--json");
138
+ const name = args.shift();
139
+ if (!name || args.length) return usage();
140
+ const classified = configAndType(deps, name);
141
+ if ("error" in classified) return usage(`Error: ${classified.error}`);
142
+ const baseUrl = await resolveBaseUrl(deps);
143
+ if (!baseUrl) return proxyUnreachable();
144
+ if (classified.type !== "codex") {
145
+ const result = await fetchProviderQuotaReport(deps, baseUrl, name);
146
+ if (result.status === 0) return proxyUnreachable();
147
+ if (result.status !== 200) return apiError(result.errorJson ?? {}, `failed to refresh ${name}`);
148
+ if (wantsJson) console.log(JSON.stringify({ provider: name, report: result.report }, null, 2));
149
+ else console.log(result.report ? providerQuotaLine(name, result.report) : `no quota report available for ${name}`);
150
+ return 0;
151
+ }
152
+ const result = await fetchCodexRows(deps, baseUrl, true);
153
+ const failed = familyFailure(result, `failed to refresh ${name}`);
154
+ if (failed !== null) return failed;
155
+ if (wantsJson) console.log(JSON.stringify({ accounts: result.rows }, null, 2));
156
+ else for (const row of result.rows) console.log(refreshLine(row));
157
+ return 0;
158
+ }
159
+
160
+ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise<number> {
161
+ const wantsJson = flag(args, "--json");
162
+ const name = args.shift();
163
+ const action = args.shift();
164
+ if (!name || !action) return usage();
165
+ const classified = configAndType(deps, name);
166
+ if ("error" in classified || classified.type !== "codex") {
167
+ return usage("Error: auto-switch only applies to the openai Codex account pool");
168
+ }
169
+ let threshold: number | undefined;
170
+ if (action === "on" && args.length === 0) threshold = 80;
171
+ else if (action === "off" && args.length === 0) threshold = 0;
172
+ else if (action === "threshold" && args.length === 1 && /^\d+$/.test(args[0]!)) threshold = Number(args[0]);
173
+ else if (action !== "status" || args.length !== 0) return usage();
174
+ if (threshold !== undefined && (!Number.isInteger(threshold) || threshold < 0 || threshold > 100)) {
175
+ return usage("Error: threshold must be an integer 0-100");
176
+ }
177
+ const baseUrl = await resolveBaseUrl(deps);
178
+ if (!baseUrl) return proxyUnreachable();
179
+ if (action === "status") {
180
+ const response = await apiJson(deps, baseUrl, "GET", "/api/codex-auth/active");
181
+ if (response.status === 0) return proxyUnreachable();
182
+ if (response.status !== 200 || typeof response.json.autoSwitchThreshold !== "number") {
183
+ return apiError(response.json, "failed to read auto-switch status");
184
+ }
185
+ threshold = response.json.autoSwitchThreshold;
186
+ } else {
187
+ const response = await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/auto-switch", { threshold });
188
+ if (response.status === 0) return proxyUnreachable();
189
+ if (response.status !== 200) return apiError(response.json, "failed to update auto-switch");
190
+ }
191
+ const enabled = threshold! > 0;
192
+ if (wantsJson) console.log(JSON.stringify({ provider: name, autoSwitchThreshold: threshold, enabled }, null, 2));
193
+ else console.log(enabled ? `auto-switch: on (threshold ${threshold}%)` : "auto-switch: off");
194
+ return 0;
195
+ }
196
+
197
+ function deletePath(type: "codex" | "oauth" | "api-key", name: string, id: string): string {
198
+ if (type === "codex") return `/api/codex-auth/accounts?id=${encodeURIComponent(id)}`;
199
+ if (type === "oauth") return `/api/oauth/accounts?provider=${encodeURIComponent(name)}&id=${encodeURIComponent(id)}`;
200
+ return `/api/providers/keys?name=${encodeURIComponent(name)}&id=${encodeURIComponent(id)}`;
201
+ }
202
+
203
+ export async function cmdRemove(args: string[], deps: AccountDeps): Promise<number> {
204
+ const wantsJson = flag(args, "--json");
205
+ const fail = (message: string): number => {
206
+ if (wantsJson) console.error(JSON.stringify({ error: message }));
207
+ else console.error(`Error: ${message}`);
208
+ return 1;
209
+ };
210
+ const confirmed = flag(args, "--yes");
211
+ const name = args.shift();
212
+ const requestedId = args.shift();
213
+ if (!name || !requestedId || args.length) return wantsJson ? fail("provider and account id are required") : usage();
214
+ if (!confirmed) {
215
+ const message = `Confirmation required. Re-run: ocx account remove ${name} ${requestedId} --yes`;
216
+ return wantsJson ? fail(message) : usage(message);
217
+ }
218
+ const classified = configAndType(deps, name);
219
+ if ("error" in classified) return wantsJson ? fail(classified.error) : usage(`Error: ${classified.error}`);
220
+ const id = classified.type === "codex" && requestedId === "main" ? MAIN_ID : requestedId;
221
+ if (classified.type === "codex" && id === MAIN_ID) return wantsJson
222
+ ? fail("the main Codex App login cannot be removed")
223
+ : usage("Error: the main Codex App login cannot be removed");
224
+ const baseUrl = await resolveBaseUrl(deps);
225
+ if (!baseUrl) return fail("Proxy not reachable. Start it with 'ocx start' or 'ocx ensure'.");
226
+ const before = await fetchRows(deps, baseUrl, name, classified.type);
227
+ if (before.networkDown) return fail("Proxy not reachable. Start it with 'ocx start' or 'ocx ensure'.");
228
+ if (before.errorJson) return fail(errorText(before.errorJson, `failed to verify ${name} before removal`));
229
+ if (!before.rows.some(row => row.id === id)) return wantsJson
230
+ ? fail(`account or key "${requestedId}" was not found`)
231
+ : usage(`Error: account or key "${requestedId}" was not found`);
232
+ const response = await apiJson(deps, baseUrl, "DELETE", deletePath(classified.type, name, id));
233
+ if (response.status === 0) return fail("Proxy not reachable. Start it with 'ocx start' or 'ocx ensure'.");
234
+ if (response.status !== 200) return fail(errorText(response.json, `failed to remove ${requestedId}`));
235
+ const after = await fetchRows(deps, baseUrl, name, classified.type);
236
+ if (after.networkDown || after.errorJson) {
237
+ const detail = after.networkDown ? "proxy not reachable" : typeof after.errorJson?.error === "string" ? after.errorJson.error : "unknown error";
238
+ return fail(`post-delete verification failed; delete may have succeeded: ${detail}`);
239
+ }
240
+ const removedActive = before.activeId === id;
241
+ const result = { ok: true, provider: name, id, removedActive, promotedActiveId: after.activeId };
242
+ if (wantsJson) console.log(JSON.stringify(result, null, 2));
243
+ else if (classified.type === "codex" && removedActive && after.activeId === null) console.log(`openai: ${AUTO_NOTE}`);
244
+ else if (classified.type === "oauth") console.log(after.rows.length ? `${name}: active account is now ${after.activeId}` : `${name}: no accounts remaining`);
245
+ else if (classified.type === "api-key") console.log(after.rows.length ? `${name}: active key is now ${after.activeId}` : `${name}: no keys remaining`);
246
+ else console.log(`${name}: removed account ${requestedId}`);
247
+ return 0;
248
+ }
249
+
250
+ export async function cmdAddKey(args: string[], deps: AccountDeps): Promise<number> {
251
+ const wantsJson = flag(args, "--json");
252
+ const labelArg = flagValue(args, "--label");
253
+ const name = args.shift();
254
+ if (!name || args.length || (labelArg.found && labelArg.value === undefined)) return usage();
255
+ const classified = configAndType(deps, name);
256
+ if ("error" in classified || classified.type !== "api-key") return usage("Error: add-key only applies to API-key providers");
257
+ const input: AccountStdin = deps.stdinImpl ?? process.stdin;
258
+ if (input.isTTY) return usage(PIPE_GUIDANCE);
259
+ let key: string;
260
+ try {
261
+ key = await readStdinLine(deps);
262
+ } catch (error) {
263
+ return usage(`Error: ${error instanceof Error ? error.message : String(error)}\n${PIPE_GUIDANCE}`);
264
+ }
265
+ if (!key) return usage(`Error: API key input was empty\n${PIPE_GUIDANCE}`);
266
+ const label = labelArg.value?.trim();
267
+ const baseUrl = await resolveBaseUrl(deps);
268
+ if (!baseUrl) return proxyUnreachable();
269
+ const response = await apiJson(deps, baseUrl, "POST", "/api/providers/keys", { name, key, ...(label ? { label } : {}) });
270
+ if (response.status === 0) return proxyUnreachable();
271
+ if (response.status !== 201) return apiError(response.json, `failed to add a key for ${name}`);
272
+ const id = typeof response.json.id === "string" ? response.json.id : null;
273
+ // Redact the key inside the label BEFORE serialization — a key containing
274
+ // JSON-escaped characters (" or \) would otherwise survive the whole-output
275
+ // pass in escaped form (audit finding, Carver WP3-C).
276
+ const safeLabel = label ? label.replaceAll(key, "[redacted]") : undefined;
277
+ const result = { ok: true, id, ...(safeLabel ? { label: safeLabel } : {}) };
278
+ const output = wantsJson ? JSON.stringify(result, null, 2)
279
+ : `${name}: added API key ${id ?? ""}${safeLabel ? ` (${safeLabel})` : ""}`.trim();
280
+ console.log(output.replaceAll(key, "[redacted]"));
281
+ return 0;
282
+ }