@pruddiman/hem 0.0.1-beta-5671db0

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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/dist/agents/arbiter-agent.d.ts +72 -0
  3. package/dist/agents/arbiter-agent.js +149 -0
  4. package/dist/agents/architecture-agent.d.ts +148 -0
  5. package/dist/agents/architecture-agent.js +459 -0
  6. package/dist/agents/base-agent.d.ts +44 -0
  7. package/dist/agents/base-agent.js +57 -0
  8. package/dist/agents/crossref-agent.d.ts +140 -0
  9. package/dist/agents/crossref-agent.js +560 -0
  10. package/dist/agents/crossref-arbiter-agent.d.ts +72 -0
  11. package/dist/agents/crossref-arbiter-agent.js +147 -0
  12. package/dist/agents/documentation-agent.d.ts +55 -0
  13. package/dist/agents/documentation-agent.js +159 -0
  14. package/dist/agents/exploration-agent.d.ts +58 -0
  15. package/dist/agents/exploration-agent.js +102 -0
  16. package/dist/agents/grouping-agent.d.ts +167 -0
  17. package/dist/agents/grouping-agent.js +557 -0
  18. package/dist/agents/index-agent.d.ts +86 -0
  19. package/dist/agents/index-agent.js +360 -0
  20. package/dist/agents/organization-agent.d.ts +144 -0
  21. package/dist/agents/organization-agent.js +607 -0
  22. package/dist/auth.d.ts +372 -0
  23. package/dist/auth.js +1072 -0
  24. package/dist/broadcast-mcp.d.ts +21 -0
  25. package/dist/broadcast-mcp.js +59 -0
  26. package/dist/changelog.d.ts +85 -0
  27. package/dist/changelog.js +223 -0
  28. package/dist/decision-queue.d.ts +173 -0
  29. package/dist/decision-queue.js +265 -0
  30. package/dist/diff-scope.d.ts +24 -0
  31. package/dist/diff-scope.js +28 -0
  32. package/dist/discovery.d.ts +54 -0
  33. package/dist/discovery.js +405 -0
  34. package/dist/grouping.d.ts +37 -0
  35. package/dist/grouping.js +343 -0
  36. package/dist/helpers/format.d.ts +5 -0
  37. package/dist/helpers/format.js +13 -0
  38. package/dist/helpers/index.d.ts +11 -0
  39. package/dist/helpers/index.js +11 -0
  40. package/dist/helpers/parsing.d.ts +52 -0
  41. package/dist/helpers/parsing.js +128 -0
  42. package/dist/helpers/paths.d.ts +41 -0
  43. package/dist/helpers/paths.js +67 -0
  44. package/dist/helpers/strings.d.ts +45 -0
  45. package/dist/helpers/strings.js +97 -0
  46. package/dist/index.d.ts +135 -0
  47. package/dist/index.js +1087 -0
  48. package/dist/merge-utils.d.ts +22 -0
  49. package/dist/merge-utils.js +34 -0
  50. package/dist/orchestrator.d.ts +194 -0
  51. package/dist/orchestrator.js +1169 -0
  52. package/dist/output.d.ts +106 -0
  53. package/dist/output.js +243 -0
  54. package/dist/progress.d.ts +228 -0
  55. package/dist/progress.js +644 -0
  56. package/dist/providers/copilot.d.ts +247 -0
  57. package/dist/providers/copilot.js +598 -0
  58. package/dist/providers/index.d.ts +15 -0
  59. package/dist/providers/index.js +12 -0
  60. package/dist/providers/opencode.d.ts +156 -0
  61. package/dist/providers/opencode.js +416 -0
  62. package/dist/providers/types.d.ts +156 -0
  63. package/dist/providers/types.js +16 -0
  64. package/dist/resources.d.ts +76 -0
  65. package/dist/resources.js +151 -0
  66. package/dist/search-index.d.ts +71 -0
  67. package/dist/search-index.js +187 -0
  68. package/dist/search-mcp.d.ts +25 -0
  69. package/dist/search-mcp.js +100 -0
  70. package/dist/server-utils.d.ts +56 -0
  71. package/dist/server-utils.js +135 -0
  72. package/dist/session.d.ts +227 -0
  73. package/dist/session.js +370 -0
  74. package/dist/types.d.ts +272 -0
  75. package/dist/types.js +5 -0
  76. package/dist/worktree.d.ts +82 -0
  77. package/dist/worktree.js +187 -0
  78. package/package.json +45 -0
package/dist/auth.js ADDED
@@ -0,0 +1,1072 @@
1
+ /**
2
+ * Authentication detection, first-run prompt, free model fetch, and preferences I/O.
3
+ * Implements US3 (Smart Authentication with Zero-Friction Onboarding).
4
+ */
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import React from "react";
9
+ import { render } from "ink";
10
+ import { AuthPrompt, ApiKeyInput, FreeModelPicker, OAuthProviderSelect, OAuthWaiting, } from "./progress.js";
11
+ // ── Constants ──────────────────────────────────────────────────────────
12
+ /** Directory where Hem stores user preferences. */
13
+ export const CONFIG_DIR = join(homedir(), ".config", "hem");
14
+ /** Full path to the preferences file. */
15
+ export const PREFERENCES_PATH = join(CONFIG_DIR, "preferences.json");
16
+ // ── Preferences I/O ────────────────────────────────────────────────────
17
+ /**
18
+ * Resolve the preferences file path.
19
+ * Uses the provided `configDir` override (for testing) or the default `CONFIG_DIR`.
20
+ */
21
+ function resolvePreferencesPath(configDir) {
22
+ const dir = configDir ?? CONFIG_DIR;
23
+ return { dir, filePath: join(dir, "preferences.json") };
24
+ }
25
+ /**
26
+ * Load user preferences from `~/.config/hem/preferences.json`.
27
+ *
28
+ * - Returns `null` if the file does not exist.
29
+ * - Auto-creates the `~/.config/hem/` directory if missing.
30
+ * - Handles JSON parse errors gracefully by returning `null`.
31
+ *
32
+ * @param configDir — Override for the config directory (used in tests).
33
+ *
34
+ * Reference: FR-023, plan.md lines 197-205.
35
+ */
36
+ export async function loadPreferences(configDir) {
37
+ const { dir, filePath } = resolvePreferencesPath(configDir);
38
+ // Ensure the config directory exists (idempotent).
39
+ await mkdir(dir, { recursive: true });
40
+ try {
41
+ const raw = await readFile(filePath, "utf-8");
42
+ const parsed = JSON.parse(raw);
43
+ return parsed;
44
+ }
45
+ catch {
46
+ // File doesn't exist or JSON is malformed — both are non-fatal.
47
+ return null;
48
+ }
49
+ }
50
+ /**
51
+ * Save user preferences to `~/.config/hem/preferences.json`.
52
+ *
53
+ * Writes formatted JSON (2-space indent) for human readability.
54
+ * Auto-creates the `~/.config/hem/` directory if missing.
55
+ *
56
+ * @param prefs — The preferences object to persist.
57
+ * @param configDir — Override for the config directory (used in tests).
58
+ *
59
+ * Reference: FR-023, plan.md lines 197-205.
60
+ */
61
+ export async function savePreferences(prefs, configDir) {
62
+ const { dir, filePath } = resolvePreferencesPath(configDir);
63
+ // Ensure the config directory exists (idempotent).
64
+ await mkdir(dir, { recursive: true });
65
+ await writeFile(filePath, JSON.stringify(prefs, null, 2) + "\n", "utf-8");
66
+ }
67
+ // ── Project Config I/O ─────────────────────────────────────────────────
68
+ /** Default directory for project-local Hem configuration. */
69
+ export const PROJECT_CONFIG_DIR = join(process.cwd(), ".hem");
70
+ /** Full path to the project config file. */
71
+ export const PROJECT_CONFIG_PATH = join(PROJECT_CONFIG_DIR, "config.json");
72
+ /**
73
+ * Resolve the project config file path.
74
+ * Uses the provided `configDir` override (for testing) or the default `PROJECT_CONFIG_DIR`.
75
+ */
76
+ function resolveProjectConfigPath(configDir) {
77
+ const dir = configDir ?? PROJECT_CONFIG_DIR;
78
+ return { dir, filePath: join(dir, "config.json") };
79
+ }
80
+ /**
81
+ * Load project-local configuration from `{cwd}/.hem/config.json`.
82
+ *
83
+ * - Returns `null` if the file does not exist.
84
+ * - Auto-creates the `.hem/` directory if missing.
85
+ * - Handles JSON parse errors gracefully by returning `null`.
86
+ *
87
+ * @param configDir — Override for the config directory (used in tests).
88
+ */
89
+ export async function loadProjectConfig(configDir) {
90
+ const { dir, filePath } = resolveProjectConfigPath(configDir);
91
+ // Ensure the config directory exists (idempotent).
92
+ await mkdir(dir, { recursive: true });
93
+ try {
94
+ const raw = await readFile(filePath, "utf-8");
95
+ const parsed = JSON.parse(raw);
96
+ return parsed;
97
+ }
98
+ catch {
99
+ // File doesn't exist or JSON is malformed — both are non-fatal.
100
+ return null;
101
+ }
102
+ }
103
+ /**
104
+ * Save project-local configuration to `{cwd}/.hem/config.json`.
105
+ *
106
+ * Writes formatted JSON (2-space indent) for human readability.
107
+ * Auto-creates the `.hem/` directory if missing.
108
+ *
109
+ * @param config — The project config object to persist.
110
+ * @param configDir — Override for the config directory (used in tests).
111
+ */
112
+ export async function saveProjectConfig(config, configDir) {
113
+ const { dir, filePath } = resolveProjectConfigPath(configDir);
114
+ // Ensure the config directory exists (idempotent).
115
+ await mkdir(dir, { recursive: true });
116
+ await writeFile(filePath, JSON.stringify(config, null, 2) + "\n", "utf-8");
117
+ }
118
+ // ── Free Model Fetching ────────────────────────────────────────────────
119
+ /** URL for the Opencode model catalog. */
120
+ export const ZEN_CATALOG_URL = "https://opencode.ai/zen/v1/models";
121
+ /** Path to the on-disk model catalog cache (TTL: 24 hours). */
122
+ const CATALOG_CACHE_PATH = join(CONFIG_DIR, "model-catalog-cache.json");
123
+ /** How long a cached model catalog is considered fresh (ms). */
124
+ const CATALOG_CACHE_TTL_MS = 24 * 60 * 60 * 1_000;
125
+ /** Error message when the Zen catalog is unreachable. */
126
+ export const ZEN_FETCH_ERROR = "Could not reach Opencode model catalog.\n" +
127
+ " Check your internet connection, or log in with an existing provider:\n" +
128
+ " npx @pruddiman/hem auth login\n";
129
+ /**
130
+ * Custom error class thrown when the Opencode model catalog cannot be
131
+ * reached (network error, timeout, non-200 status, invalid JSON).
132
+ *
133
+ * Used to distinguish catalog connectivity failures from other errors so
134
+ * callers (e.g., `handleGenerate`) can present a clear, actionable message.
135
+ *
136
+ * Reference: spec.md line 122, contracts/cli-interface.md lines 212-214.
137
+ */
138
+ export class ZenCatalogError extends Error {
139
+ constructor(cause) {
140
+ super(ZEN_FETCH_ERROR);
141
+ this.name = "ZenCatalogError";
142
+ if (cause !== undefined) {
143
+ this.cause = cause;
144
+ }
145
+ }
146
+ }
147
+ /**
148
+ * Classify a model's data retention policy from its privacy metadata.
149
+ *
150
+ * - If a "zero-retention" field exists (truthy) → `"zero-retention"`
151
+ * - If "training" is mentioned in any privacy-related string → `"may-train"`
152
+ * - Otherwise → `"unknown"`
153
+ *
154
+ * @internal
155
+ */
156
+ export function classifyRetention(model) {
157
+ const privacy = model.privacy ?? model.data_policy ?? {};
158
+ // Check for a zero-retention indicator.
159
+ if (privacy["zero-retention"] ||
160
+ privacy["zero_retention"] ||
161
+ privacy.zeroRetention) {
162
+ return {
163
+ retention: "zero-retention",
164
+ note: typeof privacy.note === "string"
165
+ ? privacy.note
166
+ : "Zero data retention policy.",
167
+ };
168
+ }
169
+ // Check for training mentions in any privacy/policy string values.
170
+ const privacyStr = JSON.stringify(privacy).toLowerCase();
171
+ if (privacyStr.includes("training") || privacyStr.includes("train")) {
172
+ return {
173
+ retention: "may-train",
174
+ note: typeof privacy.note === "string"
175
+ ? privacy.note
176
+ : "Data may be used for training.",
177
+ };
178
+ }
179
+ return {
180
+ retention: "unknown",
181
+ note: typeof privacy.note === "string"
182
+ ? privacy.note
183
+ : "Data retention policy unknown.",
184
+ };
185
+ }
186
+ /**
187
+ * Fetch free models from the Opencode model catalog.
188
+ *
189
+ * 1. Fetches from `https://opencode.ai/zen/v1/models` using the global `fetch` API.
190
+ * 2. Filters models where `pricing.input === 0 && pricing.output === 0`.
191
+ * 3. Classifies each model's data retention from privacy metadata.
192
+ * 4. Maps to `FreeModelInfo[]` with `providerID` always `"opencode"`.
193
+ * 5. Returns sorted with zero-retention models first.
194
+ *
195
+ * @param fetchFn — Override for the fetch function (used in tests).
196
+ *
197
+ * Reference: FR-024, FR-025, plan.md lines 216-233.
198
+ */
199
+ export async function fetchFreeModels(fetchFn = globalThis.fetch) {
200
+ // Only use the on-disk cache when the real global fetch is in use.
201
+ // Tests pass a custom fetchFn and must always hit the network mock.
202
+ const useCache = fetchFn === globalThis.fetch;
203
+ if (useCache) {
204
+ try {
205
+ const raw = await readFile(CATALOG_CACHE_PATH, "utf-8");
206
+ const cached = JSON.parse(raw);
207
+ if (typeof cached.ts === "number" &&
208
+ Array.isArray(cached.models) &&
209
+ Date.now() - cached.ts < CATALOG_CACHE_TTL_MS) {
210
+ return cached.models;
211
+ }
212
+ }
213
+ catch {
214
+ // Cache miss or parse error — fall through to network fetch
215
+ }
216
+ }
217
+ let response;
218
+ try {
219
+ response = await fetchFn(ZEN_CATALOG_URL);
220
+ }
221
+ catch (err) {
222
+ throw new ZenCatalogError(err);
223
+ }
224
+ if (!response.ok) {
225
+ throw new ZenCatalogError();
226
+ }
227
+ let data;
228
+ try {
229
+ data = await response.json();
230
+ }
231
+ catch (err) {
232
+ throw new ZenCatalogError(err);
233
+ }
234
+ // The API may return models at the top level as an array, or nested under a key.
235
+ const models = Array.isArray(data)
236
+ ? data
237
+ : Array.isArray(data.models)
238
+ ? data.models
239
+ : Array.isArray(data.data)
240
+ ? data.data
241
+ : [];
242
+ // Filter to free models (pricing.input === 0 && pricing.output === 0).
243
+ const freeModels = models.filter((m) => {
244
+ const pricing = m.pricing ?? {};
245
+ return pricing.input === 0 && pricing.output === 0;
246
+ });
247
+ // Map to FreeModelInfo[].
248
+ const result = freeModels.map((m) => {
249
+ const { retention, note } = classifyRetention(m);
250
+ return {
251
+ id: (m.id ?? m.model_id ?? ""),
252
+ name: (m.name ?? m.display_name ?? m.id ?? ""),
253
+ providerID: "opencode",
254
+ dataRetention: retention,
255
+ retentionNote: note,
256
+ };
257
+ });
258
+ // Sort: zero-retention first, then may-train, then unknown.
259
+ const retentionOrder = {
260
+ "zero-retention": 0,
261
+ "may-train": 1,
262
+ unknown: 2,
263
+ };
264
+ result.sort((a, b) => (retentionOrder[a.dataRetention] ?? 2) -
265
+ (retentionOrder[b.dataRetention] ?? 2));
266
+ // Write cache (fire-and-forget; errors are silent)
267
+ if (useCache) {
268
+ const cache = { ts: Date.now(), models: result };
269
+ mkdir(CONFIG_DIR, { recursive: true })
270
+ .then(() => writeFile(CATALOG_CACHE_PATH, JSON.stringify(cache, null, 2), "utf-8"))
271
+ .catch(() => { });
272
+ }
273
+ return result;
274
+ }
275
+ // ── Stored Model Validation ────────────────────────────────────────────
276
+ /**
277
+ * Warning message template when a stored model is no longer available.
278
+ *
279
+ * Placeholders: `{providerID}` and `{modelID}` are replaced at runtime.
280
+ *
281
+ * Reference: spec.md line 123, contracts/cli-interface.md lines 208-209.
282
+ */
283
+ export const INVALID_MODEL_WARNING = 'Model "{providerID}/{modelID}" is no longer available.\n' +
284
+ " Run `npx @pruddiman/hem` without --model to select a new model.\n";
285
+ /**
286
+ * Validate that a stored model selection still corresponds to an available
287
+ * provider/model combination.
288
+ *
289
+ * Calls `client.config.providers()` and checks whether:
290
+ * 1. A provider with the stored `providerID` exists.
291
+ * 2. If that provider exposes a `models` map, the stored `modelID` is
292
+ * present in it — unless the modelID is `"default"`, which is always
293
+ * valid (it defers to the provider's default model).
294
+ *
295
+ * @param client — OpenCode SDK client instance.
296
+ * @param prefs — User preferences containing the stored model selection.
297
+ * @returns `true` if the stored model is valid, `false` otherwise.
298
+ *
299
+ * Reference: spec.md line 123, contracts/cli-interface.md lines 208-209.
300
+ */
301
+ export async function validateStoredModel(client, prefs) {
302
+ // No model stored → nothing to validate.
303
+ if (!prefs.model) {
304
+ return true;
305
+ }
306
+ const { providerID, modelID } = prefs.model;
307
+ const result = await client.config.providers();
308
+ const sdkProviders = result.data?.providers ?? [];
309
+ // Handle the opencode meta-provider with an encoded sub-provider in modelID.
310
+ // When the user selects "opencode" provider + a sub-provider model via `hem config`,
311
+ // the modelID is stored as "subProvider/modelID" (e.g. "github-copilot/opus-4.6").
312
+ // "opencode" is not a real entry in the SDK provider list, so we validate by
313
+ // splitting the modelID and checking the sub-provider instead.
314
+ if (providerID === "opencode" && modelID !== "default" && modelID.includes("/")) {
315
+ const slash = modelID.indexOf("/");
316
+ const subProviderID = modelID.slice(0, slash);
317
+ const subModelID = modelID.slice(slash + 1);
318
+ const subProvider = sdkProviders.find((p) => p.id === subProviderID);
319
+ if (!subProvider)
320
+ return false;
321
+ if (subProvider.models &&
322
+ typeof subProvider.models === "object" &&
323
+ Object.keys(subProvider.models).length > 0) {
324
+ return subModelID in subProvider.models;
325
+ }
326
+ return true;
327
+ }
328
+ // Check if the provider exists.
329
+ const provider = sdkProviders.find((p) => p.id === providerID);
330
+ if (!provider) {
331
+ return false;
332
+ }
333
+ // If the modelID is "default", it's always valid (defers to provider default).
334
+ if (modelID === "default") {
335
+ return true;
336
+ }
337
+ // If the provider has a models map, check if the model is in it.
338
+ // If there's no models map (or it's empty), we assume the provider
339
+ // supports any model and validation passes.
340
+ if (provider.models &&
341
+ typeof provider.models === "object" &&
342
+ Object.keys(provider.models).length > 0) {
343
+ return modelID in provider.models;
344
+ }
345
+ // No models map → assume valid (can't verify further).
346
+ return true;
347
+ }
348
+ // ── Provider Model Listing ─────────────────────────────────────────────
349
+ /**
350
+ * List available models for a provider by querying the OpenCode provider metadata.
351
+ * Returns [] if the provider has no models map or the API call fails.
352
+ */
353
+ export async function listProviderModels(client, providerID) {
354
+ try {
355
+ const result = await client.config.providers();
356
+ // Tolerate either {data: {providers}} (typed SDK shape) or a bare
357
+ // {providers} object — older SDK versions and some test mocks return the
358
+ // latter, and the cost of supporting both is one small cast.
359
+ const data = (result.data ?? result);
360
+ const allProviders = data.providers ?? [];
361
+ // For the "opencode" meta-provider, flatten models from all sub-providers
362
+ // (e.g. anthropic, openai) so the user can pick any available model.
363
+ if (providerID === "opencode") {
364
+ return allProviders
365
+ .flatMap((p) => {
366
+ if (!p.models || typeof p.models !== "object")
367
+ return [];
368
+ return Object.entries(p.models).map(([modelId, meta]) => ({
369
+ id: modelId,
370
+ name: `${p.name ?? p.id} \u2014 ${meta?.name ?? modelId}`,
371
+ providerID: p.id,
372
+ }));
373
+ })
374
+ .sort((a, b) => a.name.localeCompare(b.name));
375
+ }
376
+ const provider = allProviders.find((p) => p.id === providerID);
377
+ if (!provider?.models || typeof provider.models !== "object")
378
+ return [];
379
+ return Object.entries(provider.models)
380
+ .map(([id, meta]) => ({ id, name: meta?.name ?? id }))
381
+ .sort((a, b) => a.name.localeCompare(b.name));
382
+ }
383
+ catch {
384
+ return [];
385
+ }
386
+ }
387
+ // ── Auth Expiry Detection ──────────────────────────────────────────────
388
+ /**
389
+ * Error message template when authentication has expired.
390
+ *
391
+ * Placeholder: `{providerName}` is replaced at runtime with the provider
392
+ * identifier (e.g., "anthropic").
393
+ *
394
+ * Reference: spec.md line 124, contracts/cli-interface.md lines 205-206.
395
+ */
396
+ export const AUTH_EXPIRED_ERROR = 'Authentication expired for {providerName}.\n' +
397
+ " Run `npx @pruddiman/hem auth login` to re-authenticate.\n";
398
+ /**
399
+ * Custom error class thrown when an auth expiry is detected during
400
+ * the generation pipeline. Carries the provider name for the error
401
+ * message so callers can format the `AUTH_EXPIRED_ERROR` template.
402
+ */
403
+ export class AuthExpiredError extends Error {
404
+ /** The provider whose credentials have expired. */
405
+ providerName;
406
+ constructor(providerName, cause) {
407
+ super(AUTH_EXPIRED_ERROR.replace("{providerName}", providerName));
408
+ this.name = "AuthExpiredError";
409
+ this.providerName = providerName;
410
+ if (cause !== undefined) {
411
+ this.cause = cause;
412
+ }
413
+ }
414
+ }
415
+ /**
416
+ * Determine whether an error indicates expired or invalid authentication.
417
+ *
418
+ * Checks for HTTP 401 (Unauthorized) and 403 (Forbidden) status codes
419
+ * in common error shapes returned by LLM provider APIs:
420
+ * - `error.status` — fetch-style Response errors
421
+ * - `error.statusCode` — Node.js HTTP errors
422
+ * - `error.code` — string codes like `"unauthorized"` / `"forbidden"`
423
+ * - `error.message` — substring match for "401", "403", "unauthorized",
424
+ * "forbidden", "expired", "invalid.*key", "authentication"
425
+ *
426
+ * @param error — The error to inspect (any type).
427
+ * @returns `true` if the error looks like an auth expiry / invalid credentials.
428
+ *
429
+ * Reference: spec.md line 124, contracts/cli-interface.md lines 205-206.
430
+ */
431
+ export function isAuthExpired(error) {
432
+ if (error === null || error === undefined) {
433
+ return false;
434
+ }
435
+ // Handle non-object primitive errors (strings, numbers, etc.)
436
+ if (typeof error !== "object") {
437
+ const str = String(error).toLowerCase();
438
+ return (str.includes("401") ||
439
+ str.includes("403") ||
440
+ str.includes("unauthorized") ||
441
+ str.includes("forbidden") ||
442
+ str.includes("authentication") ||
443
+ str.includes("expired"));
444
+ }
445
+ const err = error;
446
+ // Check numeric status / statusCode fields (HTTP response codes).
447
+ if (err.status === 401 || err.status === 403) {
448
+ return true;
449
+ }
450
+ if (err.statusCode === 401 || err.statusCode === 403) {
451
+ return true;
452
+ }
453
+ // Check string code fields (SDK / provider error codes).
454
+ if (typeof err.code === "string") {
455
+ const code = err.code.toLowerCase();
456
+ if (code === "unauthorized" ||
457
+ code === "forbidden" ||
458
+ code === "authentication_error" ||
459
+ code === "invalid_api_key" ||
460
+ code === "expired_token") {
461
+ return true;
462
+ }
463
+ }
464
+ // Check the error message for common auth failure keywords.
465
+ if (typeof err.message === "string") {
466
+ const msg = err.message.toLowerCase();
467
+ if (msg.includes("401") ||
468
+ msg.includes("403") ||
469
+ msg.includes("unauthorized") ||
470
+ msg.includes("forbidden") ||
471
+ msg.includes("authentication") ||
472
+ msg.includes("expired") ||
473
+ /invalid\s*(api[_\s-]?)?key/i.test(err.message)) {
474
+ return true;
475
+ }
476
+ }
477
+ return false;
478
+ }
479
+ // ── Invalid API Key ────────────────────────────────────────────────────
480
+ /**
481
+ * Error message template when an API key is invalid or rejected.
482
+ *
483
+ * Placeholder: `{providerID}` is replaced at runtime with the provider
484
+ * identifier (e.g., "anthropic").
485
+ *
486
+ * Reference: spec.md line 127, contracts/cli-interface.md.
487
+ */
488
+ export const INVALID_API_KEY_ERROR = 'Authentication failed for provider "{providerID}".\n' +
489
+ " Please verify your API key is correct and has not expired.\n";
490
+ /**
491
+ * Custom error class thrown when an API key is rejected by the provider.
492
+ *
493
+ * Used to distinguish API-key-specific auth failures from generic
494
+ * auth expiry (e.g., OAuth token expired). Carries the `providerID`
495
+ * so callers (e.g., `handleGenerate`) can format a clear, actionable
496
+ * error message.
497
+ *
498
+ * Reference: spec.md line 127.
499
+ */
500
+ export class InvalidApiKeyError extends Error {
501
+ /** The provider that rejected the API key. */
502
+ providerID;
503
+ constructor(providerID, cause) {
504
+ super(INVALID_API_KEY_ERROR.replace("{providerID}", providerID));
505
+ this.name = "InvalidApiKeyError";
506
+ this.providerID = providerID;
507
+ if (cause !== undefined) {
508
+ this.cause = cause;
509
+ }
510
+ }
511
+ }
512
+ // ── API Key Injection ──────────────────────────────────────────────────
513
+ /**
514
+ * Inject an API key for a provider into the OpenCode SDK, bypassing OAuth
515
+ * and all interactive prompts.
516
+ *
517
+ * The `model` string must be in `provider/model-name` format (e.g.,
518
+ * `"anthropic/claude-sonnet-4"`). The provider ID is extracted as
519
+ * everything before the first `/`.
520
+ *
521
+ * @param client — OpenCode SDK client instance.
522
+ * @param model — Model identifier in `provider/model` format.
523
+ * @param apiKey — API key to inject for the provider.
524
+ *
525
+ * Reference: FR-022a, plan.md lines 146-148, spec.md scenario 7.
526
+ */
527
+ export async function injectApiKey(client, model, apiKey) {
528
+ // Extract the provider ID (everything before the first "/").
529
+ const slashIndex = model.indexOf("/");
530
+ if (slashIndex <= 0) {
531
+ throw new Error(`Invalid model format: "${model}". Expected "provider/model-name" (e.g., "anthropic/claude-sonnet-4").`);
532
+ }
533
+ const providerID = model.slice(0, slashIndex);
534
+ await client.auth.set({
535
+ path: { id: providerID },
536
+ body: { type: "api", key: apiKey },
537
+ });
538
+ }
539
+ // ── Auth Detection ─────────────────────────────────────────────────────
540
+ /**
541
+ * Map an OpenCode SDK provider `source` field to a `ConnectedProvider.authMethod`.
542
+ *
543
+ * SDK sources: "env" | "config" | "custom" | "api"
544
+ * - "env" → user exported an env var (e.g., ANTHROPIC_API_KEY)
545
+ * - "api" → credentials injected via `client.auth.set()` (API key or OAuth)
546
+ * - "config" → set in opencode.json config file
547
+ * - "custom" → custom provider configuration
548
+ *
549
+ * We conservatively map "api" → "api-key" and everything else → "env-var",
550
+ * except "config" which also maps to "api-key" (explicit user configuration).
551
+ */
552
+ function mapSourceToAuthMethod(source) {
553
+ switch (source) {
554
+ case "api":
555
+ case "config":
556
+ return "api-key";
557
+ case "env":
558
+ return "env-var";
559
+ case "custom":
560
+ return "oauth";
561
+ default:
562
+ return "env-var";
563
+ }
564
+ }
565
+ /**
566
+ * Detect the current authentication state by querying the OpenCode SDK client.
567
+ *
568
+ * - Calls `client.config.providers()` to get the list of connected providers.
569
+ * - Maps them to `ConnectedProvider[]`.
570
+ * - Determines `hasCredentials` (true if any provider is connected).
571
+ * - Loads preferences via `loadPreferences()`.
572
+ * - Sets `isFirstRun` to true if no preferences exist AND no credentials detected.
573
+ * - Returns a complete `AuthState` object.
574
+ *
575
+ * @param client — OpenCode SDK client instance.
576
+ * @param configDir — Override for the config directory (used in tests).
577
+ *
578
+ * Reference: FR-020, plan.md lines 148-151.
579
+ */
580
+ export async function detectAuthState(client, configDir) {
581
+ // Query the SDK for connected providers.
582
+ const result = await client.config.providers();
583
+ const sdkProviders = result.data?.providers ?? [];
584
+ // Filter to providers that have credentials (a key or env vars set).
585
+ // A provider with a `key` field populated has active credentials.
586
+ // We also consider providers whose `source` indicates explicit auth.
587
+ const connectedProviders = sdkProviders
588
+ .filter((p) => (p.key !== undefined && p.key !== null && p.key !== "") ||
589
+ p.source === "custom" || p.source === "api" || p.source === "config")
590
+ .map((p) => ({
591
+ id: p.id,
592
+ name: p.name,
593
+ authMethod: mapSourceToAuthMethod(p.source ?? "env"),
594
+ }));
595
+ // Detect GitHub Copilot credentials from environment variables.
596
+ // This complements (does not replace) the SDK-based detection above.
597
+ const copilotToken = process.env.COPILOT_GITHUB_TOKEN ||
598
+ process.env.GH_TOKEN ||
599
+ process.env.GITHUB_TOKEN;
600
+ if (copilotToken &&
601
+ !connectedProviders.some((p) => p.id === "github-copilot")) {
602
+ connectedProviders.push({
603
+ id: "github-copilot",
604
+ name: "GitHub Copilot",
605
+ authMethod: "env-var",
606
+ });
607
+ }
608
+ const hasCredentials = connectedProviders.length > 0;
609
+ // Load user preferences.
610
+ const preferences = await loadPreferences(configDir);
611
+ // Determine if this is a first run: no preferences AND no credentials.
612
+ const isFirstRun = preferences === null && !hasCredentials;
613
+ // Resolve the active model from preferences (if available).
614
+ const activeModel = preferences?.model ?? undefined;
615
+ return {
616
+ hasCredentials,
617
+ connectedProviders,
618
+ activeModel,
619
+ isFirstRun,
620
+ };
621
+ }
622
+ /**
623
+ * Resolve human-readable model and provider labels by querying the OpenCode
624
+ * provider metadata.
625
+ *
626
+ * When `model` has `modelID === "default"`, looks up the actual default model
627
+ * from the `default` map returned by `client.provider.list()`.
628
+ * When an explicit model is set, uses the metadata to find the human-readable name.
629
+ * Falls back to capitalized-ID logic if the lookup fails or the API call errors.
630
+ *
631
+ * @param client — OpenCode SDK client instance.
632
+ * @param model — The current resolved model selection (may be undefined).
633
+ * @returns An object with `modelLabel` and `providerLabel` strings.
634
+ */
635
+ export async function resolveActualModel(client, model) {
636
+ // Fallback label builder (matches existing logic in index.ts).
637
+ const fallback = () => {
638
+ if (!model) {
639
+ return { modelLabel: "default", providerLabel: "Default" };
640
+ }
641
+ const providerLabel = model.providerID.charAt(0).toUpperCase() + model.providerID.slice(1);
642
+ return {
643
+ modelLabel: `${model.providerID}/${model.modelID}`,
644
+ providerLabel,
645
+ };
646
+ };
647
+ if (!model) {
648
+ return fallback();
649
+ }
650
+ try {
651
+ const result = await client.provider.list();
652
+ // Tolerate either {data: {...}} (typed SDK shape) or a bare object —
653
+ // older SDK versions and some test mocks return the latter.
654
+ const data = (result.data ?? result);
655
+ const allProviders = data.all ?? [];
656
+ const defaultMap = data.default ?? {};
657
+ let providerID = model.providerID;
658
+ let modelID = model.modelID;
659
+ // When the model is "default", resolve via the default map.
660
+ if (modelID === "default") {
661
+ // Find the first connected provider's default, or use the providerID from the model.
662
+ const connected = data.connected ?? [];
663
+ if (providerID === "opencode" && connected.length > 0) {
664
+ // Use the first connected provider's default model.
665
+ for (const connID of connected) {
666
+ if (defaultMap[connID]) {
667
+ providerID = connID;
668
+ modelID = defaultMap[connID];
669
+ break;
670
+ }
671
+ }
672
+ }
673
+ else {
674
+ const fallback = defaultMap[providerID];
675
+ if (fallback) {
676
+ modelID = fallback;
677
+ }
678
+ }
679
+ }
680
+ // Look up the provider metadata for human-readable names.
681
+ const providerMeta = allProviders.find((p) => p.id === providerID);
682
+ const providerLabel = providerMeta?.name
683
+ ?? (providerID.charAt(0).toUpperCase() + providerID.slice(1));
684
+ // Look up the model metadata for a human-readable name.
685
+ const modelMeta = providerMeta?.models?.[modelID];
686
+ const modelLabel = modelMeta?.name
687
+ ?? `${providerID}/${modelID}`;
688
+ return { modelLabel, providerLabel };
689
+ }
690
+ catch {
691
+ return fallback();
692
+ }
693
+ }
694
+ // ── First-Run Flow ─────────────────────────────────────────────────────
695
+ /**
696
+ * Render an Ink component and wait for a result via a callback.
697
+ *
698
+ * Wraps Ink's `render()` in a Promise that resolves when the component
699
+ * invokes the callback. The Ink instance is cleaned up automatically.
700
+ *
701
+ * @param createComponent — A function that receives a `resolve` callback
702
+ * and returns a React element to render.
703
+ * @returns The value passed to the callback by the component.
704
+ *
705
+ * @internal
706
+ */
707
+ export function renderAndWait(createComponent) {
708
+ return new Promise((resolve) => {
709
+ let instance;
710
+ const handleResolve = (value) => {
711
+ // Unmount Ink once we have a result.
712
+ instance?.unmount();
713
+ resolve(value);
714
+ };
715
+ instance = render(createComponent(handleResolve));
716
+ });
717
+ }
718
+ /**
719
+ * Handle the "api-key" sub-flow of the first-run prompt.
720
+ *
721
+ * Renders the `ApiKeyInput` component to collect a provider ID and API key,
722
+ * then injects the key into the OpenCode SDK via `client.auth.set()`.
723
+ *
724
+ * @param client — OpenCode SDK client instance.
725
+ * @returns A `ModelSelection` with the chosen provider and a placeholder model ID.
726
+ *
727
+ * @internal
728
+ */
729
+ export async function handleApiKeyFlow(client) {
730
+ // Get the list of available providers for the selection menu.
731
+ const result = await client.config.providers();
732
+ const sdkProviders = result.data?.providers ?? [];
733
+ const providerIds = sdkProviders.map((p) => p.id);
734
+ // If no providers are available, fall back to a default set.
735
+ const availableProviders = providerIds.length > 0
736
+ ? providerIds
737
+ : ["anthropic", "openai", "google"];
738
+ // Render the ApiKeyInput component and wait for user submission.
739
+ const { providerId, key } = await renderAndWait((resolve) => React.createElement(ApiKeyInput, {
740
+ providers: availableProviders,
741
+ onSubmit: (providerId, key) => resolve({ providerId, key }),
742
+ }));
743
+ // Inject the API key into the SDK.
744
+ await client.auth.set({
745
+ path: { id: providerId },
746
+ body: { type: "api", key },
747
+ });
748
+ return {
749
+ providerID: providerId,
750
+ modelID: "default",
751
+ };
752
+ }
753
+ /**
754
+ * Handle the OAuth sub-flow of the first-run prompt.
755
+ *
756
+ * 1. Calls `client.config.providers()` to get a list of available OAuth providers.
757
+ * 2. Renders a provider selection prompt using Ink (list provider names,
758
+ * let user pick one) via the `OAuthProviderSelect` component.
759
+ * 3. Initiates OAuth by calling `client.provider.auth({ path: { id } })`
760
+ * (the SDK's OAuth authorize endpoint for the selected provider).
761
+ * 4. Waits for the callback/completion.
762
+ * 5. Returns a `ModelSelection` with the provider's default model.
763
+ *
764
+ * @param client — OpenCode SDK client instance.
765
+ * @returns A `ModelSelection` with the provider's default model.
766
+ *
767
+ * Reference: FR-018, plan.md lines 176-178.
768
+ */
769
+ export async function handleOAuthFlow(client) {
770
+ // Step 1: Get available providers from the SDK.
771
+ const result = await client.config.providers();
772
+ const sdkProviders = result.data?.providers ?? [];
773
+ if (sdkProviders.length === 0) {
774
+ throw new Error("No OAuth providers available. Use an API key or free models instead.");
775
+ }
776
+ // Map SDK providers to OAuthProviderOption[].
777
+ const providerOptions = sdkProviders.map((p) => ({
778
+ id: p.id,
779
+ name: (p.name ?? p.id),
780
+ }));
781
+ // Step 2: Render provider selection prompt and wait for user's choice.
782
+ const selectedProvider = await renderAndWait((resolve) => React.createElement(OAuthProviderSelect, {
783
+ providers: providerOptions,
784
+ onSelect: resolve,
785
+ }));
786
+ // Step 3: Initiate OAuth for the selected provider.
787
+ // The SDK returns an authorization URL and handles the callback.
788
+ let oauthResult;
789
+ try {
790
+ oauthResult = await client.provider.oauth.authorize({
791
+ path: { id: selectedProvider.id },
792
+ });
793
+ }
794
+ catch (err) {
795
+ throw new Error(`OAuth authentication failed for ${selectedProvider.name}: ${err?.message ?? "Unknown error"}`);
796
+ }
797
+ // Step 4: If the SDK returns a URL, show the waiting UI.
798
+ // The SDK wraps responses in { data, request, response }.
799
+ const oauthData = oauthResult?.data;
800
+ if (oauthData?.url) {
801
+ // Render a waiting indicator while OAuth completes in the browser.
802
+ // The SDK call itself waits for completion, so this is purely informational.
803
+ const waitInstance = render(React.createElement(OAuthWaiting, {
804
+ providerName: selectedProvider.name,
805
+ url: oauthData.url,
806
+ }));
807
+ // If the SDK provides a wait/callback mechanism, wait for it.
808
+ if (typeof oauthData.wait === "function") {
809
+ try {
810
+ await oauthData.wait();
811
+ }
812
+ finally {
813
+ waitInstance.unmount();
814
+ }
815
+ }
816
+ else {
817
+ // The OAuth flow completed synchronously or the SDK handles it internally.
818
+ waitInstance.unmount();
819
+ }
820
+ }
821
+ // Step 5: Return a ModelSelection with the provider's default model.
822
+ return {
823
+ providerID: selectedProvider.id,
824
+ modelID: "default",
825
+ };
826
+ }
827
+ /**
828
+ * Handle the "free models" sub-flow of the first-run prompt.
829
+ *
830
+ * Fetches free models from the Opencode catalog via `fetchFreeModels()`,
831
+ * renders the `FreeModelPicker` with the results, and returns the user's
832
+ * selection as a `ModelSelection`.
833
+ *
834
+ * @param client — OpenCode SDK client instance (unused; models are fetched
835
+ * from the public Zen catalog).
836
+ * @param models — Optional list of free models (used for testing to skip
837
+ * the network fetch).
838
+ * @returns A `ModelSelection` with the chosen free model.
839
+ *
840
+ * Reference: FR-024, FR-025, plan.md lines 180-195.
841
+ */
842
+ export async function handleFreeModelSelection(_client, models) {
843
+ // Use provided models (for testing) or fetch dynamically from the Zen catalog.
844
+ const availableModels = models ?? await fetchFreeModels();
845
+ const selectedModel = await renderAndWait((resolve) => React.createElement(FreeModelPicker, {
846
+ models: availableModels,
847
+ onSelect: resolve,
848
+ }));
849
+ return {
850
+ providerID: selectedModel.providerID,
851
+ modelID: selectedModel.id,
852
+ };
853
+ }
854
+ /**
855
+ * Run the interactive auth prompt and dispatch to the chosen sub-flow.
856
+ *
857
+ * Shared logic used by both `handleFirstRun()` and `handleAuthLogin()`.
858
+ * Renders the `AuthPrompt` component, waits for the user's choice, and
859
+ * dispatches to the appropriate sub-flow:
860
+ * - "oauth" → `handleOAuthFlow(client)`
861
+ * - "api-key" → renders `ApiKeyInput` and calls `client.auth.set()`
862
+ * - "free" → `handleFreeModelSelection(client)`
863
+ * - "exit" → returns null
864
+ *
865
+ * After successful authentication, saves the chosen model to preferences
866
+ * via `savePreferences()`.
867
+ *
868
+ * @param client — OpenCode SDK client instance.
869
+ * @param configDir — Override for the config directory (used in tests).
870
+ * @returns The chosen `ModelSelection`, or `null` if the user chose to exit.
871
+ *
872
+ * @internal
873
+ */
874
+ async function runAuthPrompt(client, configDir) {
875
+ // Step 1: Show the auth prompt and get the user's choice.
876
+ const choice = await renderAndWait((resolve) => React.createElement(AuthPrompt, { onSelect: resolve }));
877
+ // Step 2: Dispatch based on choice.
878
+ let modelSelection = null;
879
+ switch (choice) {
880
+ case "oauth":
881
+ modelSelection = await handleOAuthFlow(client);
882
+ break;
883
+ case "api-key":
884
+ modelSelection = await handleApiKeyFlow(client);
885
+ break;
886
+ case "free":
887
+ modelSelection = await handleFreeModelSelection(client);
888
+ break;
889
+ case "exit":
890
+ return null;
891
+ }
892
+ // Step 3: Save preferences with the chosen model.
893
+ if (modelSelection !== null) {
894
+ const prefs = {
895
+ model: modelSelection,
896
+ agreedToFreeModelTerms: choice === "free",
897
+ lastUsedAt: new Date().toISOString(),
898
+ };
899
+ await savePreferences(prefs, configDir);
900
+ }
901
+ // Step 4: Return the model selection.
902
+ return modelSelection;
903
+ }
904
+ /**
905
+ * Orchestrate the first-run authentication flow.
906
+ *
907
+ * Renders the `AuthPrompt` component, waits for the user's choice, and
908
+ * dispatches to the appropriate sub-flow:
909
+ * - "oauth" → `handleOAuthFlow(client)`
910
+ * - "api-key" → renders `ApiKeyInput` and calls `client.auth.set()`
911
+ * - "free" → `handleFreeModelSelection(client)`
912
+ * - "exit" → returns null
913
+ *
914
+ * After successful authentication, saves the chosen model to preferences
915
+ * via `savePreferences()`.
916
+ *
917
+ * @param client — OpenCode SDK client instance.
918
+ * @param configDir — Override for the config directory (used in tests).
919
+ * @returns The chosen `ModelSelection`, or `null` if the user chose to exit.
920
+ *
921
+ * Reference: FR-018, FR-020, plan.md lines 140-196.
922
+ */
923
+ export async function handleFirstRun(client, configDir) {
924
+ return runAuthPrompt(client, configDir);
925
+ }
926
+ // ── Auth Subcommands ───────────────────────────────────────────────────
927
+ /**
928
+ * Handle `hem auth login` — interactive auth, standalone (no generation).
929
+ *
930
+ * Runs the same interactive flow as `handleFirstRun` but standalone:
931
+ * the user picks a method (OAuth, API key, free, or exit), authenticates,
932
+ * and preferences are saved. No documentation generation follows.
933
+ *
934
+ * @param client — OpenCode SDK client instance.
935
+ * @param configDir — Override for the config directory (used in tests).
936
+ *
937
+ * Reference: FR-021, contracts/cli-interface.md lines 14-19.
938
+ */
939
+ export async function handleAuthLogin(client, configDir) {
940
+ const result = await runAuthPrompt(client, configDir);
941
+ if (result === null) {
942
+ // User chose "exit" — nothing to do.
943
+ return;
944
+ }
945
+ // Auth was successful. Preferences are already saved by runAuthPrompt.
946
+ }
947
+ /**
948
+ * Format an auth method for display.
949
+ *
950
+ * Maps the internal `ConnectedProvider.authMethod` value to a human-readable
951
+ * string matching the contract format:
952
+ * - "oauth" → "(OAuth)"
953
+ * - "api-key" → "API Key"
954
+ * - "env-var" → "(env var)"
955
+ *
956
+ * @internal
957
+ */
958
+ function formatAuthMethod(authMethod) {
959
+ switch (authMethod) {
960
+ case "oauth":
961
+ return "(OAuth)";
962
+ case "api-key":
963
+ return "API Key";
964
+ case "env-var":
965
+ return "(env var)";
966
+ }
967
+ }
968
+ /**
969
+ * Handle `hem auth list` — show connected providers with status.
970
+ *
971
+ * Calls `client.config.providers()`, formats and prints connected providers
972
+ * with status (checkmark + name + auth method) per `contracts/cli-interface.md`
973
+ * lines 132-143, also shows the default model from preferences.
974
+ *
975
+ * Output format:
976
+ * ```
977
+ * Connected providers:
978
+ * ✓ anthropic Claude Pro/Max (OAuth)
979
+ * ✓ opencode Zen API Key
980
+ *
981
+ * Default model: anthropic/claude-sonnet-4
982
+ * ```
983
+ *
984
+ * @param client — OpenCode SDK client instance.
985
+ * @param configDir — Override for the config directory (used in tests).
986
+ * @param writeFn — Override for the output function (used in tests).
987
+ *
988
+ * Reference: FR-021, contracts/cli-interface.md lines 132-143.
989
+ */
990
+ export async function handleAuthList(client, configDir, writeFn = (msg) => process.stdout.write(msg)) {
991
+ // Query the SDK for connected providers.
992
+ const result = await client.config.providers();
993
+ const sdkProviders = result.data?.providers ?? [];
994
+ // Filter to providers with credentials (API key or OAuth/custom source).
995
+ const connectedProviders = sdkProviders
996
+ .filter((p) => (p.key !== undefined && p.key !== null && p.key !== "") ||
997
+ p.source === "custom" || p.source === "api" || p.source === "config")
998
+ .map((p) => ({
999
+ id: p.id,
1000
+ name: p.name,
1001
+ authMethod: mapSourceToAuthMethod(p.source ?? "env"),
1002
+ }));
1003
+ // Load user preferences for the default model.
1004
+ const preferences = await loadPreferences(configDir);
1005
+ // Output the list.
1006
+ writeFn("\n");
1007
+ if (connectedProviders.length === 0) {
1008
+ writeFn(" No providers connected.\n");
1009
+ writeFn(" Run `npx @pruddiman/hem auth login` to set up authentication.\n");
1010
+ }
1011
+ else {
1012
+ writeFn(" Connected providers:\n");
1013
+ // Find the longest provider ID for alignment.
1014
+ const maxIdLen = connectedProviders.reduce((max, p) => Math.max(max, p.id.length), 0);
1015
+ for (const provider of connectedProviders) {
1016
+ const padding = " ".repeat(Math.max(1, maxIdLen - provider.id.length + 4));
1017
+ const method = formatAuthMethod(provider.authMethod);
1018
+ writeFn(` \u2713 ${provider.id}${padding}${provider.name} ${method}\n`);
1019
+ }
1020
+ }
1021
+ writeFn("\n");
1022
+ // Show the default model if set in preferences.
1023
+ if (preferences?.model) {
1024
+ writeFn(` Default model: ${preferences.model.providerID}/${preferences.model.modelID}\n`);
1025
+ }
1026
+ else {
1027
+ writeFn(" Default model: (none)\n");
1028
+ }
1029
+ writeFn("\n");
1030
+ }
1031
+ /**
1032
+ * Handle `hem auth logout` — remove credentials.
1033
+ *
1034
+ * If `target` is provided, removes credentials for that specific provider.
1035
+ * If no `target`, removes all credentials by iterating over connected providers.
1036
+ * Prints a confirmation message.
1037
+ *
1038
+ * @param client — OpenCode SDK client instance.
1039
+ * @param target — Optional provider ID to remove. If omitted, removes all.
1040
+ * @param writeFn — Override for the output function (used in tests).
1041
+ *
1042
+ * Reference: FR-021, contracts/cli-interface.md lines 14-19, 141-143.
1043
+ */
1044
+ export async function handleAuthLogout(client, target, writeFn = (msg) => process.stdout.write(msg)) {
1045
+ // The OpenCode SDK's typed `client.auth.remove` only handles MCP auth
1046
+ // (URL `/mcp/{name}/auth`). There is no SDK method for removing
1047
+ // provider credentials at `/auth/{id}` — only `set`. We call the
1048
+ // server endpoint via the underlying HTTP client to keep the
1049
+ // existing behavior; cast is necessary because the typed surface
1050
+ // does not expose this. See SDK gen/sdk.gen.d.ts.
1051
+ const removeProviderAuth = (id) => client.auth.remove({ path: { id } });
1052
+ if (target) {
1053
+ // Remove credentials for a specific provider.
1054
+ await removeProviderAuth(target);
1055
+ writeFn(`\n \u2713 Removed credentials for ${target}.\n\n`);
1056
+ }
1057
+ else {
1058
+ // Remove all credentials: query providers, then remove each one.
1059
+ const result = await client.config.providers();
1060
+ const sdkProviders = result.data?.providers ?? [];
1061
+ const connectedProviders = sdkProviders.filter((p) => (p.key !== undefined && p.key !== null && p.key !== "") ||
1062
+ p.source === "custom" || p.source === "api" || p.source === "config");
1063
+ if (connectedProviders.length === 0) {
1064
+ writeFn("\n No credentials to remove.\n\n");
1065
+ return;
1066
+ }
1067
+ for (const provider of connectedProviders) {
1068
+ await removeProviderAuth(provider.id);
1069
+ }
1070
+ writeFn(`\n \u2713 Removed all credentials (${connectedProviders.length} provider${connectedProviders.length === 1 ? "" : "s"}).\n\n`);
1071
+ }
1072
+ }