@vacbo/opencode-anthropic-fix 0.1.7 → 0.1.9

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 (107) hide show
  1. package/README.md +88 -88
  2. package/dist/opencode-anthropic-auth-cli.mjs +804 -507
  3. package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
  4. package/package.json +67 -59
  5. package/src/__tests__/billing-edge-cases.test.ts +59 -59
  6. package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
  7. package/src/__tests__/cc-comparison.test.ts +87 -87
  8. package/src/__tests__/cc-credentials.test.ts +254 -250
  9. package/src/__tests__/cch-drift-checker.test.ts +51 -51
  10. package/src/__tests__/cch-native-style.test.ts +56 -56
  11. package/src/__tests__/debug-gating.test.ts +42 -42
  12. package/src/__tests__/decomposition-smoke.test.ts +68 -68
  13. package/src/__tests__/fingerprint-regression.test.ts +575 -566
  14. package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
  15. package/src/__tests__/helpers/conversation-history.ts +119 -119
  16. package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
  17. package/src/__tests__/helpers/deferred.ts +69 -69
  18. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
  19. package/src/__tests__/helpers/in-memory-storage.ts +88 -88
  20. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
  21. package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
  22. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
  23. package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
  24. package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
  25. package/src/__tests__/helpers/sse.ts +209 -209
  26. package/src/__tests__/index.parallel.test.ts +605 -595
  27. package/src/__tests__/sanitization-regex.test.ts +112 -112
  28. package/src/__tests__/state-bounds.test.ts +90 -90
  29. package/src/account-identity.test.ts +197 -192
  30. package/src/account-identity.ts +69 -67
  31. package/src/account-state.test.ts +86 -86
  32. package/src/account-state.ts +25 -25
  33. package/src/accounts/matching.test.ts +335 -0
  34. package/src/accounts/matching.ts +167 -0
  35. package/src/accounts/persistence.test.ts +345 -0
  36. package/src/accounts/persistence.ts +432 -0
  37. package/src/accounts/repair.test.ts +276 -0
  38. package/src/accounts/repair.ts +407 -0
  39. package/src/accounts.dedup.test.ts +621 -621
  40. package/src/accounts.test.ts +933 -929
  41. package/src/accounts.ts +633 -989
  42. package/src/backoff.test.ts +345 -345
  43. package/src/backoff.ts +219 -219
  44. package/src/betas.ts +124 -124
  45. package/src/bun-fetch.test.ts +345 -342
  46. package/src/bun-fetch.ts +424 -424
  47. package/src/bun-proxy.test.ts +25 -25
  48. package/src/bun-proxy.ts +209 -209
  49. package/src/cc-credentials.ts +111 -111
  50. package/src/circuit-breaker.test.ts +184 -184
  51. package/src/circuit-breaker.ts +169 -169
  52. package/src/cli/commands/auth.ts +963 -0
  53. package/src/cli/commands/config.ts +547 -0
  54. package/src/cli/formatting.test.ts +406 -0
  55. package/src/cli/formatting.ts +219 -0
  56. package/src/cli.ts +255 -2022
  57. package/src/commands/handlers/betas.ts +100 -0
  58. package/src/commands/handlers/config.ts +99 -0
  59. package/src/commands/handlers/files.ts +375 -0
  60. package/src/commands/oauth-flow.ts +181 -166
  61. package/src/commands/prompts.ts +61 -61
  62. package/src/commands/router.test.ts +421 -0
  63. package/src/commands/router.ts +143 -635
  64. package/src/config.test.ts +482 -482
  65. package/src/config.ts +412 -404
  66. package/src/constants.ts +48 -48
  67. package/src/drift/cch-constants.ts +95 -95
  68. package/src/env.ts +111 -105
  69. package/src/headers/billing.ts +33 -33
  70. package/src/headers/builder.ts +130 -130
  71. package/src/headers/cch.ts +75 -75
  72. package/src/headers/stainless.ts +25 -25
  73. package/src/headers/user-agent.ts +23 -23
  74. package/src/index.ts +436 -828
  75. package/src/models.ts +27 -27
  76. package/src/oauth.test.ts +102 -102
  77. package/src/oauth.ts +178 -178
  78. package/src/parent-pid-watcher.test.ts +148 -148
  79. package/src/parent-pid-watcher.ts +69 -69
  80. package/src/plugin-helpers.ts +82 -82
  81. package/src/refresh-helpers.ts +145 -139
  82. package/src/refresh-lock.test.ts +94 -94
  83. package/src/refresh-lock.ts +93 -93
  84. package/src/request/body.history.test.ts +579 -571
  85. package/src/request/body.ts +255 -255
  86. package/src/request/metadata.ts +65 -65
  87. package/src/request/retry.test.ts +156 -156
  88. package/src/request/retry.ts +67 -67
  89. package/src/request/url.ts +21 -21
  90. package/src/request-orchestration-helpers.ts +648 -0
  91. package/src/response/index.ts +5 -5
  92. package/src/response/mcp.ts +58 -58
  93. package/src/response/streaming.test.ts +313 -311
  94. package/src/response/streaming.ts +412 -410
  95. package/src/rotation.test.ts +304 -301
  96. package/src/rotation.ts +205 -205
  97. package/src/storage.test.ts +547 -547
  98. package/src/storage.ts +315 -291
  99. package/src/system-prompt/builder.ts +38 -38
  100. package/src/system-prompt/index.ts +5 -5
  101. package/src/system-prompt/normalize.ts +60 -60
  102. package/src/system-prompt/sanitize.ts +30 -30
  103. package/src/thinking.ts +21 -20
  104. package/src/token-refresh.test.ts +265 -265
  105. package/src/token-refresh.ts +219 -214
  106. package/src/types.ts +30 -30
  107. package/dist/bun-proxy.mjs +0 -291
@@ -2,64 +2,45 @@
2
2
  // Slash-command router for /anthropic commands
3
3
  // ---------------------------------------------------------------------------
4
4
 
5
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
- import { basename, resolve } from "node:path";
7
5
  import type { AccountManager } from "../accounts.js";
8
- import { resolveBetaShortcut } from "../betas.js";
9
6
  import type { AnthropicAuthConfig } from "../config.js";
10
- import { loadConfigFresh, saveConfig } from "../config.js";
11
- import { isTruthyEnv } from "../env.js";
12
7
  import { loadAccounts } from "../storage.js";
13
8
  import type { ManagedAccount } from "../token-refresh.js";
9
+ import { handleBetasCommand } from "./handlers/betas.js";
10
+ import { handleConfigCommand, handleSetCommand } from "./handlers/config.js";
11
+ import { handleFilesCommand } from "./handlers/files.js";
14
12
  import { completeSlashOAuth, startSlashOAuth, type OAuthFlowDeps, type PendingOAuthEntry } from "./oauth-flow.js";
15
13
 
16
- export const ANTHROPIC_COMMAND_HANDLED = "__ANTHROPIC_COMMAND_HANDLED__";
17
-
18
- /**
19
- * Maximum number of file-to-account pinning entries retained in memory.
20
- * Bounded to prevent unbounded growth across long sessions that touch many
21
- * Files API uploads. Eviction is FIFO: when the cap is hit, the oldest entry
22
- * (Maps preserve insertion order) is dropped before inserting the new one.
23
- */
24
- export const FILE_ACCOUNT_MAP_MAX_SIZE = 1000;
14
+ // Re-export files utilities so existing imports from "./router.js" continue to work
15
+ export { capFileAccountMap, FILE_ACCOUNT_MAP_MAX_SIZE } from "./handlers/files.js";
25
16
 
26
- /**
27
- * Insert a fileId→accountIndex binding with FIFO eviction when the cap is reached.
28
- * See {@link FILE_ACCOUNT_MAP_MAX_SIZE} for the rationale.
29
- */
30
- export function capFileAccountMap(fileAccountMap: Map<string, number>, fileId: string, accountIndex: number): void {
31
- if (fileAccountMap.size >= FILE_ACCOUNT_MAP_MAX_SIZE) {
32
- const oldestKey = fileAccountMap.keys().next().value;
33
- if (oldestKey !== undefined) fileAccountMap.delete(oldestKey);
34
- }
35
- fileAccountMap.set(fileId, accountIndex);
36
- }
17
+ export const ANTHROPIC_COMMAND_HANDLED = "__ANTHROPIC_COMMAND_HANDLED__";
37
18
 
38
19
  export interface CliResult {
39
- code: number;
40
- stdout: string;
41
- stderr: string;
20
+ code: number;
21
+ stdout: string;
22
+ stderr: string;
42
23
  }
43
24
 
44
25
  export interface CommandDeps {
45
- sendCommandMessage: (sessionID: string, message: string) => Promise<void>;
46
- accountManager: AccountManager | null;
47
- runCliCommand: (args: string[]) => Promise<CliResult>;
48
- config: AnthropicAuthConfig;
49
- fileAccountMap: Map<string, number>;
50
- initialAccountPinned: boolean;
51
- pendingSlashOAuth: Map<string, PendingOAuthEntry>;
52
- reloadAccountManagerFromDisk: () => Promise<void>;
53
- persistOpenCodeAuth: (refresh: string, access: string | undefined, expires: number | undefined) => Promise<void>;
54
- refreshAccountTokenSingleFlight: (account: ManagedAccount) => Promise<string>;
26
+ sendCommandMessage: (sessionID: string, message: string) => Promise<void>;
27
+ accountManager: AccountManager | null;
28
+ runCliCommand: (args: string[]) => Promise<CliResult>;
29
+ config: AnthropicAuthConfig;
30
+ fileAccountMap: Map<string, number>;
31
+ initialAccountPinned: boolean;
32
+ pendingSlashOAuth: Map<string, PendingOAuthEntry>;
33
+ reloadAccountManagerFromDisk: () => Promise<void>;
34
+ persistOpenCodeAuth: (refresh: string, access: string | undefined, expires: number | undefined) => Promise<void>;
35
+ refreshAccountTokenSingleFlight: (account: ManagedAccount) => Promise<string>;
55
36
  }
56
37
 
57
38
  /**
58
39
  * Remove ANSI color/control codes from output text.
59
40
  */
60
41
  export function stripAnsi(value: string): string {
61
- // eslint-disable-next-line no-control-regex -- ANSI escape sequences start with \x1b which is a control char
62
- return value.replace(/\x1b\[[0-9;]*m/g, "");
42
+ // eslint-disable-next-line no-control-regex -- ANSI escape sequences start with \x1b which is a control char
43
+ return value.replace(/\x1b\[[0-9;]*m/g, "");
63
44
  }
64
45
 
65
46
  /**
@@ -70,641 +51,168 @@ export function stripAnsi(value: string): string {
70
51
  * a 'c d' -> ["a", "c d"]
71
52
  */
72
53
  export function parseCommandArgs(raw: string): string[] {
73
- if (!raw || !raw.trim()) return [];
74
- const parts: string[] = [];
75
- const re = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
76
- let match;
77
- while ((match = re.exec(raw)) !== null) {
78
- const token = match[1] ?? match[2] ?? match[3] ?? "";
79
- parts.push(token.replace(/\\(["'\\])/g, "$1"));
80
- }
81
- return parts;
54
+ if (!raw || !raw.trim()) return [];
55
+ const parts: string[] = [];
56
+ const re = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
57
+ let match;
58
+ while ((match = re.exec(raw)) !== null) {
59
+ const token = match[1] ?? match[2] ?? match[3] ?? "";
60
+ parts.push(token.replace(/\\(["'\\])/g, "$1"));
61
+ }
62
+ return parts;
82
63
  }
83
64
 
84
65
  /**
85
66
  * Handle /anthropic slash commands.
86
67
  */
87
68
  export async function handleAnthropicSlashCommand(
88
- input: { command: string; arguments?: string; sessionID: string },
89
- deps: CommandDeps,
69
+ input: { command: string; arguments?: string; sessionID: string },
70
+ deps: CommandDeps,
90
71
  ): Promise<void> {
91
- const {
92
- sendCommandMessage,
93
- accountManager,
94
- runCliCommand,
95
- config,
96
- fileAccountMap,
97
- initialAccountPinned,
98
- pendingSlashOAuth,
99
- reloadAccountManagerFromDisk,
100
- persistOpenCodeAuth,
101
- refreshAccountTokenSingleFlight,
102
- } = deps;
103
-
104
- const oauthFlowDeps: OAuthFlowDeps = {
105
- pendingSlashOAuth,
106
- sendCommandMessage,
107
- reloadAccountManagerFromDisk,
108
- persistOpenCodeAuth,
109
- };
110
-
111
- const args = parseCommandArgs(input.arguments || "");
112
- const primary = (args[0] || "list").toLowerCase();
113
-
114
- // Friendly alias: /anthropic usage -> list
115
- if (primary === "usage") {
116
- const result = await runCliCommand(["list"]);
117
- const heading = result.code === 0 ? "▣ Anthropic" : "▣ Anthropic (error)";
118
- const body = result.stdout || result.stderr || "No output.";
119
- await sendCommandMessage(input.sessionID, [heading, "", body].join("\n"));
120
- await reloadAccountManagerFromDisk();
121
- return;
122
- }
123
-
124
- // Two-step login flow
125
- if (primary === "login") {
126
- if ((args[1] || "").toLowerCase() === "complete") {
127
- const code = args.slice(2).join(" ").trim();
128
- if (!code) {
129
- await sendCommandMessage(
130
- input.sessionID,
131
- "▣ Anthropic OAuth\n\nMissing code. Use: /anthropic login complete <code#state>",
132
- );
133
- return;
134
- }
135
- const result = await completeSlashOAuth(input.sessionID, code, oauthFlowDeps);
136
- const heading = result.ok ? "▣ Anthropic OAuth" : "▣ Anthropic OAuth (error)";
137
- await sendCommandMessage(input.sessionID, `${heading}\n\n${result.message}`);
138
- return;
139
- }
140
- await startSlashOAuth(input.sessionID, "login", undefined, oauthFlowDeps);
141
- return;
142
- }
143
-
144
- // Two-step reauth flow
145
- if (primary === "reauth") {
146
- if ((args[1] || "").toLowerCase() === "complete") {
147
- const code = args.slice(2).join(" ").trim();
148
- if (!code) {
149
- await sendCommandMessage(
150
- input.sessionID,
151
- "▣ Anthropic OAuth\n\nMissing code. Use: /anthropic reauth complete <code#state>",
152
- );
153
- return;
154
- }
155
- const result = await completeSlashOAuth(input.sessionID, code, oauthFlowDeps);
156
- const heading = result.ok ? "▣ Anthropic OAuth" : "▣ Anthropic OAuth (error)";
157
- await sendCommandMessage(input.sessionID, `${heading}\n\n${result.message}`);
158
- return;
159
- }
160
- const n = parseInt(args[1], 10);
161
- if (Number.isNaN(n) || n < 1) {
162
- await sendCommandMessage(
163
- input.sessionID,
164
- "▣ Anthropic OAuth\n\nProvide an account number. Example: /anthropic reauth 1",
165
- );
166
- return;
167
- }
168
- const stored = await loadAccounts();
169
- if (!stored || stored.accounts.length === 0) {
170
- await sendCommandMessage(input.sessionID, "▣ Anthropic OAuth (error)\n\nNo accounts configured.");
171
- return;
172
- }
173
- const idx = n - 1;
174
- if (idx >= stored.accounts.length) {
175
- await sendCommandMessage(
176
- input.sessionID,
177
- `▣ Anthropic OAuth (error)\n\nAccount ${n} does not exist. You have ${stored.accounts.length} account(s).`,
178
- );
179
- return;
180
- }
181
- await startSlashOAuth(input.sessionID, "reauth", idx, oauthFlowDeps);
182
- return;
183
- }
184
-
185
- // /anthropic config
186
- if (primary === "config") {
187
- const fresh = loadConfigFresh();
188
- const lines = [
189
- "▣ Anthropic Config",
190
- "",
191
- `strategy: ${fresh.account_selection_strategy}`,
192
- `emulation: ${fresh.signature_emulation.enabled ? "on" : "off"}`,
193
- `compaction: ${fresh.signature_emulation.prompt_compaction}`,
194
- `1m-context: ${fresh.override_model_limits.enabled ? "on" : "off"}`,
195
- `idle-refresh: ${fresh.idle_refresh.enabled ? "on" : "off"}`,
196
- `debug: ${fresh.debug ? "on" : "off"}`,
197
- `quiet: ${fresh.toasts.quiet ? "on" : "off"}`,
198
- `custom_betas: ${fresh.custom_betas.length ? fresh.custom_betas.join(", ") : "(none)"}`,
199
- ];
200
- await sendCommandMessage(input.sessionID, lines.join("\n"));
201
- return;
202
- }
203
-
204
- // /anthropic set <key> <value>
205
- if (primary === "set") {
206
- const key = (args[1] || "").toLowerCase();
207
- const value = (args[2] || "").toLowerCase();
208
- const setters: Record<string, () => void> = {
209
- emulation: () =>
210
- saveConfig({
211
- signature_emulation: {
212
- enabled: value === "on" || value === "1" || value === "true",
213
- },
214
- }),
215
- compaction: () =>
216
- saveConfig({
217
- signature_emulation: {
218
- prompt_compaction: value === "off" ? "off" : "minimal",
219
- },
220
- }),
221
- "1m-context": () =>
222
- saveConfig({
223
- override_model_limits: {
224
- enabled: value === "on" || value === "1" || value === "true",
225
- },
226
- }),
227
- "idle-refresh": () =>
228
- saveConfig({
229
- idle_refresh: {
230
- enabled: value === "on" || value === "1" || value === "true",
231
- },
232
- }),
233
- debug: () =>
234
- saveConfig({
235
- debug: value === "on" || value === "1" || value === "true",
236
- }),
237
- quiet: () =>
238
- saveConfig({
239
- toasts: {
240
- quiet: value === "on" || value === "1" || value === "true",
241
- },
242
- }),
243
- strategy: () => {
244
- const valid = ["sticky", "round-robin", "hybrid"];
245
- if (valid.includes(value))
246
- saveConfig({ account_selection_strategy: value as "sticky" | "round-robin" | "hybrid" });
247
- else throw new Error(`Invalid strategy. Valid: ${valid.join(", ")}`);
248
- },
72
+ const {
73
+ sendCommandMessage,
74
+ accountManager,
75
+ runCliCommand,
76
+ config,
77
+ fileAccountMap,
78
+ initialAccountPinned,
79
+ pendingSlashOAuth,
80
+ reloadAccountManagerFromDisk,
81
+ persistOpenCodeAuth,
82
+ refreshAccountTokenSingleFlight,
83
+ } = deps;
84
+
85
+ const oauthFlowDeps: OAuthFlowDeps = {
86
+ pendingSlashOAuth,
87
+ sendCommandMessage,
88
+ reloadAccountManagerFromDisk,
89
+ persistOpenCodeAuth,
249
90
  };
250
91
 
251
- if (!key || !setters[key]) {
252
- const keys = Object.keys(setters).join(", ");
253
- await sendCommandMessage(
254
- input.sessionID,
255
- `▣ Anthropic Set\n\nUsage: /anthropic set <key> <value>\nKeys: ${keys}\nValues: on/off (or specific values for strategy/compaction)`,
256
- );
257
- return;
258
- }
259
- if (!value) {
260
- await sendCommandMessage(input.sessionID, `▣ Anthropic Set\n\nMissing value for "${key}".`);
261
- return;
262
- }
263
- setters[key]();
264
- Object.assign(config, loadConfigFresh());
265
- await sendCommandMessage(input.sessionID, `▣ Anthropic Set\n\n${key} = ${value}`);
266
- return;
267
- }
268
-
269
- // /anthropic betas [add|remove <beta>]
270
- if (primary === "betas") {
271
- const action = (args[1] || "").toLowerCase();
272
-
273
- if (!action || action === "list") {
274
- const fresh = loadConfigFresh();
275
- const strategy = fresh.account_selection_strategy || config.account_selection_strategy;
276
- const lines = [
277
- "▣ Anthropic Betas",
278
- "",
279
- "Preset betas (auto-computed per model/provider):",
280
- " oauth-2025-04-20, claude-code-20250219,",
281
- " advanced-tool-use-2025-11-20, fast-mode-2026-02-01,",
282
- " interleaved-thinking-2025-05-14 (non-Opus 4.6) OR effort-2025-11-24 (Opus 4.6),",
283
- " files-api-2025-04-14 (only /v1/files and requests with file_id),",
284
- " token-counting-2024-11-01 (only /v1/messages/count_tokens),",
285
- ` prompt-caching-scope-2026-01-05 (non-interactive${strategy === "round-robin" ? ", skipped in round-robin" : ""})`,
286
- "",
287
- `Experimental betas: ${isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS) ? "disabled (CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1)" : "enabled"}`,
288
- `Strategy: ${strategy}${initialAccountPinned ? " (pinned via OPENCODE_ANTHROPIC_INITIAL_ACCOUNT)" : ""}`,
289
- `Custom betas: ${fresh.custom_betas.length ? fresh.custom_betas.join(", ") : "(none)"}`,
290
- "",
291
- "Toggleable presets:",
292
- " /anthropic betas add structured-outputs-2025-12-15",
293
- " /anthropic betas add context-management-2025-06-27",
294
- " /anthropic betas add task-budgets-2026-03-13",
295
- " /anthropic betas add web-search-2025-03-05",
296
- " /anthropic betas add compact-2026-01-12",
297
- " /anthropic betas add mcp-servers-2025-12-04",
298
- " /anthropic betas add redact-thinking-2026-02-12",
299
- " /anthropic betas add 1m (shortcut for context-1m-2025-08-07)",
300
- "",
301
- "Remove: /anthropic betas remove <beta>",
302
- ];
303
- await sendCommandMessage(input.sessionID, lines.join("\n"));
304
- return;
305
- }
92
+ const args = parseCommandArgs(input.arguments || "");
93
+ const primary = (args[0] || "list").toLowerCase();
306
94
 
307
- if (action === "add") {
308
- const betaInput = args[2]?.trim();
309
- if (!betaInput) {
310
- await sendCommandMessage(input.sessionID, "▣ Anthropic Betas\n\nUsage: /anthropic betas add <beta-name>");
311
- return;
312
- }
313
- const beta = resolveBetaShortcut(betaInput);
314
- const fresh = loadConfigFresh();
315
- const current = fresh.custom_betas || [];
316
- if (current.includes(beta)) {
317
- await sendCommandMessage(input.sessionID, `▣ Anthropic Betas\n\n"${beta}" already added.`);
95
+ // Friendly alias: /anthropic usage -> list
96
+ if (primary === "usage") {
97
+ const result = await runCliCommand(["list"]);
98
+ const heading = result.code === 0 ? "▣ Anthropic" : "▣ Anthropic (error)";
99
+ const body = result.stdout || result.stderr || "No output.";
100
+ await sendCommandMessage(input.sessionID, [heading, "", body].join("\n"));
101
+ await reloadAccountManagerFromDisk();
318
102
  return;
319
- }
320
- saveConfig({ custom_betas: [...current, beta] });
321
- Object.assign(config, loadConfigFresh());
322
- const fromShortcut = beta !== betaInput;
323
- await sendCommandMessage(
324
- input.sessionID,
325
- `▣ Anthropic Betas\n\nAdded: ${beta}${fromShortcut ? ` (from shortcut: ${betaInput})` : ""}`,
326
- );
327
- return;
328
103
  }
329
104
 
330
- if (action === "remove" || action === "rm") {
331
- const betaInput = args[2]?.trim();
332
- if (!betaInput) {
333
- await sendCommandMessage(input.sessionID, " Anthropic Betas\n\nUsage: /anthropic betas remove <beta-name>");
334
- return;
335
- }
336
- const beta = resolveBetaShortcut(betaInput);
337
- const fresh = loadConfigFresh();
338
- const current = fresh.custom_betas || [];
339
- if (!current.includes(beta)) {
340
- await sendCommandMessage(input.sessionID, `▣ Anthropic Betas\n\n"${beta}" not in custom betas.`);
105
+ // Two-step login flow
106
+ if (primary === "login") {
107
+ if ((args[1] || "").toLowerCase() === "complete") {
108
+ const code = args.slice(2).join(" ").trim();
109
+ if (!code) {
110
+ await sendCommandMessage(
111
+ input.sessionID,
112
+ "▣ Anthropic OAuth\n\nMissing code. Use: /anthropic login complete <code#state>",
113
+ );
114
+ return;
115
+ }
116
+ const result = await completeSlashOAuth(input.sessionID, code, oauthFlowDeps);
117
+ const heading = result.ok ? "▣ Anthropic OAuth" : "▣ Anthropic OAuth (error)";
118
+ await sendCommandMessage(input.sessionID, `${heading}\n\n${result.message}`);
119
+ return;
120
+ }
121
+ await startSlashOAuth(input.sessionID, "login", undefined, oauthFlowDeps);
341
122
  return;
342
- }
343
- saveConfig({ custom_betas: current.filter((b) => b !== beta) });
344
- Object.assign(config, loadConfigFresh());
345
- await sendCommandMessage(input.sessionID, `▣ Anthropic Betas\n\nRemoved: ${beta}`);
346
- return;
347
- }
348
-
349
- await sendCommandMessage(input.sessionID, "▣ Anthropic Betas\n\nUsage: /anthropic betas [add|remove <beta>]");
350
- return;
351
- }
352
-
353
- // /anthropic files [list|upload|get|delete|download]
354
- if (primary === "files") {
355
- let targetAccountId: string | null = null;
356
- const filteredArgs: string[] = [];
357
- for (let i = 0; i < args.length; i++) {
358
- if (args[i] === "--account" && i + 1 < args.length) {
359
- targetAccountId = args[i + 1];
360
- i++;
361
- } else {
362
- filteredArgs.push(args[i]);
363
- }
364
123
  }
365
- const action = (filteredArgs[1] || "").toLowerCase();
366
124
 
367
- if (!accountManager || accountManager.getAccountCount() === 0) {
368
- await sendCommandMessage(
369
- input.sessionID,
370
- "▣ Anthropic Files (error)\n\nNo accounts configured. Use /anthropic login first.",
371
- );
372
- return;
373
- }
374
-
375
- type ResolvedAccount = { account: ManagedAccount; label: string };
376
-
377
- function resolveTargetAccount(identifier: string | null): ResolvedAccount | null {
378
- const accounts = accountManager!.getEnabledAccounts();
379
- if (identifier) {
380
- const byEmail = accounts.find((a) => a.email === identifier);
381
- if (byEmail) return { account: byEmail, label: byEmail.email || `Account ${byEmail.index + 1}` };
382
- const idx = parseInt(identifier, 10);
383
- if (!isNaN(idx) && idx >= 1) {
384
- const byIdx = accounts.find((a) => a.index === idx - 1);
385
- if (byIdx) return { account: byIdx, label: byIdx.email || `Account ${byIdx.index + 1}` };
125
+ // Two-step reauth flow
126
+ if (primary === "reauth") {
127
+ if ((args[1] || "").toLowerCase() === "complete") {
128
+ const code = args.slice(2).join(" ").trim();
129
+ if (!code) {
130
+ await sendCommandMessage(
131
+ input.sessionID,
132
+ "▣ Anthropic OAuth\n\nMissing code. Use: /anthropic reauth complete <code#state>",
133
+ );
134
+ return;
135
+ }
136
+ const result = await completeSlashOAuth(input.sessionID, code, oauthFlowDeps);
137
+ const heading = result.ok ? "▣ Anthropic OAuth" : "▣ Anthropic OAuth (error)";
138
+ await sendCommandMessage(input.sessionID, `${heading}\n\n${result.message}`);
139
+ return;
386
140
  }
387
- return null;
388
- }
389
- const current = accountManager!.getCurrentAccount();
390
- if (!current) return null;
391
- return { account: current, label: current.email || `Account ${current.index + 1}` };
392
- }
393
-
394
- async function getFilesAuth(acct: ManagedAccount) {
395
- let tok = acct.access;
396
- if (!tok || !acct.expires || acct.expires < Date.now()) {
397
- tok = await refreshAccountTokenSingleFlight(acct);
398
- }
399
- return {
400
- authorization: `Bearer ${tok}`,
401
- "anthropic-beta": "oauth-2025-04-20,files-api-2025-04-14",
402
- };
403
- }
404
-
405
- const apiBase = "https://api.anthropic.com";
406
-
407
- try {
408
- if (!action || action === "list") {
409
- if (targetAccountId) {
410
- const resolved = resolveTargetAccount(targetAccountId);
411
- if (!resolved) {
141
+ const n = parseInt(args[1], 10);
142
+ if (Number.isNaN(n) || n < 1) {
412
143
  await sendCommandMessage(
413
- input.sessionID,
414
- `▣ Anthropic Files (error)\n\nAccount not found: ${targetAccountId}`,
144
+ input.sessionID,
145
+ "▣ Anthropic OAuth\n\nProvide an account number. Example: /anthropic reauth 1",
415
146
  );
416
147
  return;
417
- }
418
- const { account, label } = resolved;
419
- const headers = await getFilesAuth(account);
420
- const res = await fetch(`${apiBase}/v1/files`, { headers });
421
- if (!res.ok) {
422
- const errBody = await res.text();
148
+ }
149
+ const stored = await loadAccounts();
150
+ if (!stored || stored.accounts.length === 0) {
151
+ await sendCommandMessage(input.sessionID, "▣ Anthropic OAuth (error)\n\nNo accounts configured.");
152
+ return;
153
+ }
154
+ const idx = n - 1;
155
+ if (idx >= stored.accounts.length) {
423
156
  await sendCommandMessage(
424
- input.sessionID,
425
- `▣ Anthropic Files (error) [${label}]\n\nHTTP ${res.status}: ${errBody}`,
157
+ input.sessionID,
158
+ `▣ Anthropic OAuth (error)\n\nAccount ${n} does not exist. You have ${stored.accounts.length} account(s).`,
426
159
  );
427
160
  return;
428
- }
429
- const data = (await res.json()) as {
430
- data?: Array<{ id: string; filename: string; size: number; purpose: string }>;
431
- };
432
- const files = data.data || [];
433
- for (const f of files) capFileAccountMap(fileAccountMap, f.id, account.index);
434
- if (files.length === 0) {
435
- await sendCommandMessage(input.sessionID, `▣ Anthropic Files [${label}]\n\nNo files uploaded.`);
436
- return;
437
- }
438
- const lines = [`▣ Anthropic Files [${label}]`, "", `${files.length} file(s):`, ""];
439
- for (const f of files) {
440
- const sizeKB = (f.size / 1024).toFixed(1);
441
- lines.push(` ${f.id} ${f.filename} (${sizeKB} KB, ${f.purpose})`);
442
- }
443
- await sendCommandMessage(input.sessionID, lines.join("\n"));
444
- return;
445
- }
446
-
447
- const accounts = accountManager.getEnabledAccounts();
448
- const allLines = ["▣ Anthropic Files (all accounts)", ""];
449
- let totalFiles = 0;
450
- for (const acct of accounts) {
451
- const label = acct.email || `Account ${acct.index + 1}`;
452
- try {
453
- const headers = await getFilesAuth(acct);
454
- const res = await fetch(`${apiBase}/v1/files`, { headers });
455
- if (!res.ok) {
456
- allLines.push(`[${label}] Error: HTTP ${res.status}`);
457
- allLines.push("");
458
- continue;
459
- }
460
- const data = (await res.json()) as {
461
- data?: Array<{ id: string; filename: string; size: number; purpose: string }>;
462
- };
463
- const files = data.data || [];
464
- for (const f of files) capFileAccountMap(fileAccountMap, f.id, acct.index);
465
- totalFiles += files.length;
466
- if (files.length === 0) {
467
- allLines.push(`[${label}] No files`);
468
- } else {
469
- allLines.push(`[${label}] ${files.length} file(s):`);
470
- for (const f of files) {
471
- const sizeKB = (f.size / 1024).toFixed(1);
472
- allLines.push(` ${f.id} ${f.filename} (${sizeKB} KB, ${f.purpose})`);
473
- }
474
- }
475
- allLines.push("");
476
- } catch (err) {
477
- allLines.push(`[${label}] Error: ${(err as Error).message}`);
478
- allLines.push("");
479
- }
480
- }
481
- if (totalFiles === 0 && accounts.length > 0) {
482
- allLines.push(`Total: No files across ${accounts.length} account(s).`);
483
- } else {
484
- allLines.push(`Total: ${totalFiles} file(s) across ${accounts.length} account(s).`);
485
161
  }
486
- if (accounts.length > 1) {
487
- allLines.push("", "Tip: Use --account <email> to target a specific account.");
488
- }
489
- await sendCommandMessage(input.sessionID, allLines.join("\n"));
162
+ await startSlashOAuth(input.sessionID, "reauth", idx, oauthFlowDeps);
490
163
  return;
491
- }
164
+ }
492
165
 
493
- const resolved = resolveTargetAccount(targetAccountId);
494
- if (!resolved) {
495
- const errMsg = targetAccountId ? `Account not found: ${targetAccountId}` : "No accounts available.";
496
- await sendCommandMessage(input.sessionID, `▣ Anthropic Files (error)\n\n${errMsg}`);
166
+ // Delegate to focused handlers
167
+ if (primary === "config") {
168
+ await handleConfigCommand(input.sessionID, { sendCommandMessage, config });
497
169
  return;
498
- }
499
- const { account, label } = resolved;
500
- const authHeaders = await getFilesAuth(account);
170
+ }
501
171
 
502
- if (action === "upload") {
503
- const filePath = filteredArgs.slice(2).join(" ").trim();
504
- if (!filePath) {
505
- await sendCommandMessage(
506
- input.sessionID,
507
- "▣ Anthropic Files\n\nUsage: /anthropic files upload <path> [--account <email>]",
508
- );
509
- return;
510
- }
511
- const resolvedPath = resolve(filePath);
512
- if (!existsSync(resolvedPath)) {
513
- await sendCommandMessage(input.sessionID, `▣ Anthropic Files (error)\n\nFile not found: ${resolvedPath}`);
514
- return;
515
- }
516
- const content = readFileSync(resolvedPath);
517
- const filename = basename(resolvedPath);
518
- const blob = new Blob([content]);
519
- const form = new FormData();
520
- form.append("file", blob, filename);
521
- form.append("purpose", "assistants");
522
- const res = await fetch(`${apiBase}/v1/files`, {
523
- method: "POST",
524
- headers: {
525
- authorization: authHeaders.authorization,
526
- "anthropic-beta": "oauth-2025-04-20,files-api-2025-04-14",
527
- },
528
- body: form,
529
- });
530
- if (!res.ok) {
531
- const errBody = await res.text();
532
- await sendCommandMessage(
533
- input.sessionID,
534
- `▣ Anthropic Files (error) [${label}]\n\nUpload failed (HTTP ${res.status}): ${errBody}`,
535
- );
536
- return;
537
- }
538
- const file = (await res.json()) as { id: string; filename: string; size?: number };
539
- const sizeKB = ((file.size || 0) / 1024).toFixed(1);
540
- capFileAccountMap(fileAccountMap, file.id, account.index);
541
- await sendCommandMessage(
542
- input.sessionID,
543
- `▣ Anthropic Files [${label}]\n\nUploaded: ${file.id}\n Filename: ${file.filename}\n Size: ${sizeKB} KB`,
544
- );
172
+ if (primary === "set") {
173
+ await handleSetCommand(input.sessionID, args, { sendCommandMessage, config });
545
174
  return;
546
- }
175
+ }
547
176
 
548
- if (action === "get" || action === "info") {
549
- const fileId = filteredArgs[2]?.trim();
550
- if (!fileId) {
551
- await sendCommandMessage(
552
- input.sessionID,
553
- "▣ Anthropic Files\n\nUsage: /anthropic files get <file_id> [--account <email>]",
554
- );
555
- return;
556
- }
557
- const res = await fetch(`${apiBase}/v1/files/${encodeURIComponent(fileId)}`, { headers: authHeaders });
558
- if (!res.ok) {
559
- const errBody = await res.text();
560
- await sendCommandMessage(
561
- input.sessionID,
562
- `▣ Anthropic Files (error) [${label}]\n\nHTTP ${res.status}: ${errBody}`,
563
- );
564
- return;
565
- }
566
- const file = (await res.json()) as {
567
- id: string;
568
- filename: string;
569
- purpose: string;
570
- size?: number;
571
- mime_type?: string;
572
- created_at?: string;
573
- };
574
- capFileAccountMap(fileAccountMap, file.id, account.index);
575
- const lines = [
576
- `▣ Anthropic Files [${label}]`,
577
- "",
578
- ` ID: ${file.id}`,
579
- ` Filename: ${file.filename}`,
580
- ` Purpose: ${file.purpose}`,
581
- ` Size: ${((file.size || 0) / 1024).toFixed(1)} KB`,
582
- ` Type: ${file.mime_type || "unknown"}`,
583
- ` Created: ${file.created_at || "unknown"}`,
584
- ];
585
- await sendCommandMessage(input.sessionID, lines.join("\n"));
177
+ if (primary === "betas") {
178
+ await handleBetasCommand(input.sessionID, args, { sendCommandMessage, config, initialAccountPinned });
586
179
  return;
587
- }
180
+ }
588
181
 
589
- if (action === "delete" || action === "rm") {
590
- const fileId = filteredArgs[2]?.trim();
591
- if (!fileId) {
592
- await sendCommandMessage(
593
- input.sessionID,
594
- "▣ Anthropic Files\n\nUsage: /anthropic files delete <file_id> [--account <email>]",
595
- );
596
- return;
597
- }
598
- const res = await fetch(`${apiBase}/v1/files/${encodeURIComponent(fileId)}`, {
599
- method: "DELETE",
600
- headers: authHeaders,
182
+ if (primary === "files") {
183
+ await handleFilesCommand(input.sessionID, args, {
184
+ sendCommandMessage,
185
+ accountManager,
186
+ fileAccountMap,
187
+ refreshAccountTokenSingleFlight,
601
188
  });
602
- if (!res.ok) {
603
- const errBody = await res.text();
604
- await sendCommandMessage(
605
- input.sessionID,
606
- `▣ Anthropic Files (error) [${label}]\n\nHTTP ${res.status}: ${errBody}`,
607
- );
608
- return;
609
- }
610
- fileAccountMap.delete(fileId);
611
- await sendCommandMessage(input.sessionID, `▣ Anthropic Files [${label}]\n\nDeleted: ${fileId}`);
612
189
  return;
613
- }
190
+ }
614
191
 
615
- if (action === "download" || action === "dl") {
616
- const fileId = filteredArgs[2]?.trim();
617
- if (!fileId) {
618
- await sendCommandMessage(
619
- input.sessionID,
620
- "▣ Anthropic Files\n\nUsage: /anthropic files download <file_id> [output_path] [--account <email>]",
621
- );
622
- return;
623
- }
624
- const outputPath = filteredArgs.slice(3).join(" ").trim();
625
- const metaRes = await fetch(`${apiBase}/v1/files/${encodeURIComponent(fileId)}`, { headers: authHeaders });
626
- if (!metaRes.ok) {
627
- const errBody = await metaRes.text();
628
- await sendCommandMessage(
629
- input.sessionID,
630
- `▣ Anthropic Files (error) [${label}]\n\nHTTP ${metaRes.status}: ${errBody}`,
631
- );
632
- return;
633
- }
634
- const meta = (await metaRes.json()) as { filename: string };
635
- const savePath = outputPath ? resolve(outputPath) : resolve(meta.filename);
636
- const res = await fetch(`${apiBase}/v1/files/${encodeURIComponent(fileId)}/content`, { headers: authHeaders });
637
- if (!res.ok) {
638
- const errBody = await res.text();
639
- await sendCommandMessage(
640
- input.sessionID,
641
- `▣ Anthropic Files (error) [${label}]\n\nDownload failed (HTTP ${res.status}): ${errBody}`,
642
- );
643
- return;
644
- }
645
- const buffer = Buffer.from(await res.arrayBuffer());
646
- writeFileSync(savePath, buffer);
647
- const sizeKB = (buffer.length / 1024).toFixed(1);
192
+ // Interactive CLI command is not compatible with slash flow.
193
+ if (primary === "manage" || primary === "mg") {
648
194
  await sendCommandMessage(
649
- input.sessionID,
650
- `▣ Anthropic Files [${label}]\n\nDownloaded: ${meta.filename}\n Saved to: ${savePath}\n Size: ${sizeKB} KB`,
195
+ input.sessionID,
196
+ "▣ Anthropic\n\n`manage` is interactive-only. Use granular slash commands (switch/enable/disable/remove/reset) or run `opencode-anthropic-auth manage` in a terminal.",
651
197
  );
652
198
  return;
653
- }
654
-
655
- const helpLines = [
656
- "▣ Anthropic Files",
657
- "",
658
- "Usage: /anthropic files <action> [--account <email|index>]",
659
- "",
660
- "Actions:",
661
- " list List uploaded files (all accounts if no --account)",
662
- " upload <path> Upload a file (max 350MB)",
663
- " get <file_id> Get file metadata",
664
- " delete <file_id> Delete a file",
665
- " download <file_id> [path] Download file content",
666
- "",
667
- "Options:",
668
- " --account <email|index> Target a specific account (1-based index)",
669
- "",
670
- "Supported formats: PDF, DOCX, TXT, CSV, Excel, Markdown, images",
671
- "Files can be referenced by file_id in Messages API requests.",
672
- "",
673
- "When using round-robin, file_ids are automatically pinned to the",
674
- "account that owns them for Messages API requests.",
675
- ];
676
- await sendCommandMessage(input.sessionID, helpLines.join("\n"));
677
- return;
678
- } catch (err) {
679
- await sendCommandMessage(input.sessionID, `▣ Anthropic Files (error)\n\n${(err as Error).message}`);
680
- return;
681
199
  }
682
- }
683
200
 
684
- // Interactive CLI command is not compatible with slash flow.
685
- if (primary === "manage" || primary === "mg") {
686
- await sendCommandMessage(
687
- input.sessionID,
688
- "▣ Anthropic\n\n`manage` is interactive-only. Use granular slash commands (switch/enable/disable/remove/reset) or run `opencode-anthropic-auth manage` in a terminal.",
689
- );
690
- return;
691
- }
201
+ // Route remaining commands through the CLI command surface.
202
+ const cliArgs = [...args];
203
+ if (cliArgs.length === 0) cliArgs.push("list");
692
204
 
693
- // Route remaining commands through the CLI command surface.
694
- const cliArgs = [...args];
695
- if (cliArgs.length === 0) cliArgs.push("list");
696
-
697
- // Avoid readline prompts in slash mode.
698
- if (
699
- (primary === "remove" || primary === "rm" || primary === "logout" || primary === "lo") &&
700
- !cliArgs.includes("--force")
701
- ) {
702
- cliArgs.push("--force");
703
- }
205
+ // Avoid readline prompts in slash mode.
206
+ if (
207
+ (primary === "remove" || primary === "rm" || primary === "logout" || primary === "lo") &&
208
+ !cliArgs.includes("--force")
209
+ ) {
210
+ cliArgs.push("--force");
211
+ }
704
212
 
705
- const result = await runCliCommand(cliArgs);
706
- const heading = result.code === 0 ? "▣ Anthropic" : "▣ Anthropic (error)";
707
- const body = result.stdout || result.stderr || "No output.";
708
- await sendCommandMessage(input.sessionID, [heading, "", body].join("\n"));
709
- await reloadAccountManagerFromDisk();
213
+ const result = await runCliCommand(cliArgs);
214
+ const heading = result.code === 0 ? "▣ Anthropic" : "▣ Anthropic (error)";
215
+ const body = result.stdout || result.stderr || "No output.";
216
+ await sendCommandMessage(input.sessionID, [heading, "", body].join("\n"));
217
+ await reloadAccountManagerFromDisk();
710
218
  }