scoutline 0.10.2 → 0.12.0

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 (151) hide show
  1. package/README.md +99 -44
  2. package/dist/capabilities/diagnostics.d.ts +75 -0
  3. package/dist/capabilities/diagnostics.d.ts.map +1 -1
  4. package/dist/capabilities/diagnostics.js.map +1 -1
  5. package/dist/capabilities/quota.d.ts +64 -1
  6. package/dist/capabilities/quota.d.ts.map +1 -1
  7. package/dist/capabilities/quota.js.map +1 -1
  8. package/dist/capabilities/vision.d.ts +4 -3
  9. package/dist/capabilities/vision.d.ts.map +1 -1
  10. package/dist/capabilities/vision.js +7 -5
  11. package/dist/capabilities/vision.js.map +1 -1
  12. package/dist/commands/code.d.ts +9 -1
  13. package/dist/commands/code.d.ts.map +1 -1
  14. package/dist/commands/code.js +4 -4
  15. package/dist/commands/code.js.map +1 -1
  16. package/dist/commands/crawl.d.ts.map +1 -1
  17. package/dist/commands/crawl.js +14 -3
  18. package/dist/commands/crawl.js.map +1 -1
  19. package/dist/commands/doctor.d.ts +71 -1
  20. package/dist/commands/doctor.d.ts.map +1 -1
  21. package/dist/commands/doctor.js +96 -15
  22. package/dist/commands/doctor.js.map +1 -1
  23. package/dist/commands/init.d.ts +186 -0
  24. package/dist/commands/init.d.ts.map +1 -0
  25. package/dist/commands/init.js +1221 -0
  26. package/dist/commands/init.js.map +1 -0
  27. package/dist/commands/map.d.ts.map +1 -1
  28. package/dist/commands/map.js +16 -3
  29. package/dist/commands/map.js.map +1 -1
  30. package/dist/commands/quota.d.ts +32 -0
  31. package/dist/commands/quota.d.ts.map +1 -1
  32. package/dist/commands/quota.js +165 -24
  33. package/dist/commands/quota.js.map +1 -1
  34. package/dist/commands/read.d.ts.map +1 -1
  35. package/dist/commands/read.js +9 -3
  36. package/dist/commands/read.js.map +1 -1
  37. package/dist/commands/repo.d.ts.map +1 -1
  38. package/dist/commands/repo.js +5 -3
  39. package/dist/commands/repo.js.map +1 -1
  40. package/dist/commands/research.d.ts +35 -7
  41. package/dist/commands/research.d.ts.map +1 -1
  42. package/dist/commands/research.js +93 -15
  43. package/dist/commands/research.js.map +1 -1
  44. package/dist/commands/tools.d.ts +8 -0
  45. package/dist/commands/tools.d.ts.map +1 -1
  46. package/dist/commands/tools.js +4 -4
  47. package/dist/commands/tools.js.map +1 -1
  48. package/dist/commands/vision.d.ts +10 -0
  49. package/dist/commands/vision.d.ts.map +1 -1
  50. package/dist/commands/vision.js +25 -2
  51. package/dist/commands/vision.js.map +1 -1
  52. package/dist/index.d.ts +136 -1
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +894 -298
  55. package/dist/index.js.map +1 -1
  56. package/dist/lib/api-client.d.ts +1 -1
  57. package/dist/lib/api-client.d.ts.map +1 -1
  58. package/dist/lib/api-client.js +6 -2
  59. package/dist/lib/api-client.js.map +1 -1
  60. package/dist/lib/cache.d.ts +9 -2
  61. package/dist/lib/cache.d.ts.map +1 -1
  62. package/dist/lib/cache.js +9 -2
  63. package/dist/lib/cache.js.map +1 -1
  64. package/dist/lib/code-mode.d.ts +8 -0
  65. package/dist/lib/code-mode.d.ts.map +1 -1
  66. package/dist/lib/code-mode.js +3 -1
  67. package/dist/lib/code-mode.js.map +1 -1
  68. package/dist/lib/config-store.d.ts +165 -0
  69. package/dist/lib/config-store.d.ts.map +1 -0
  70. package/dist/lib/config-store.js +332 -0
  71. package/dist/lib/config-store.js.map +1 -0
  72. package/dist/lib/config.d.ts +2 -2
  73. package/dist/lib/config.d.ts.map +1 -1
  74. package/dist/lib/config.js +19 -11
  75. package/dist/lib/config.js.map +1 -1
  76. package/dist/lib/consumption.d.ts +144 -0
  77. package/dist/lib/consumption.d.ts.map +1 -0
  78. package/dist/lib/consumption.js +161 -0
  79. package/dist/lib/consumption.js.map +1 -0
  80. package/dist/lib/errors.d.ts +11 -0
  81. package/dist/lib/errors.d.ts.map +1 -1
  82. package/dist/lib/errors.js +14 -0
  83. package/dist/lib/errors.js.map +1 -1
  84. package/dist/lib/execution.d.ts +32 -4
  85. package/dist/lib/execution.d.ts.map +1 -1
  86. package/dist/lib/execution.js +156 -13
  87. package/dist/lib/execution.js.map +1 -1
  88. package/dist/lib/mcp-client.d.ts +12 -0
  89. package/dist/lib/mcp-client.d.ts.map +1 -1
  90. package/dist/lib/mcp-client.js +16 -7
  91. package/dist/lib/mcp-client.js.map +1 -1
  92. package/dist/lib/mcp-config.d.ts +10 -0
  93. package/dist/lib/mcp-config.d.ts.map +1 -1
  94. package/dist/lib/mcp-config.js +13 -7
  95. package/dist/lib/mcp-config.js.map +1 -1
  96. package/dist/lib/monitor-client.d.ts +19 -3
  97. package/dist/lib/monitor-client.d.ts.map +1 -1
  98. package/dist/lib/monitor-client.js +21 -5
  99. package/dist/lib/monitor-client.js.map +1 -1
  100. package/dist/lib/provider-fallback.d.ts +150 -0
  101. package/dist/lib/provider-fallback.d.ts.map +1 -0
  102. package/dist/lib/provider-fallback.js +510 -0
  103. package/dist/lib/provider-fallback.js.map +1 -0
  104. package/dist/lib/quota-mapping.d.ts +402 -0
  105. package/dist/lib/quota-mapping.d.ts.map +1 -0
  106. package/dist/lib/quota-mapping.js +539 -0
  107. package/dist/lib/quota-mapping.js.map +1 -0
  108. package/dist/lib/quota-store.d.ts +271 -0
  109. package/dist/lib/quota-store.d.ts.map +1 -0
  110. package/dist/lib/quota-store.js +540 -0
  111. package/dist/lib/quota-store.js.map +1 -0
  112. package/dist/lib/trigger-detection.d.ts +149 -0
  113. package/dist/lib/trigger-detection.d.ts.map +1 -0
  114. package/dist/lib/trigger-detection.js +149 -0
  115. package/dist/lib/trigger-detection.js.map +1 -0
  116. package/dist/lib/tty.d.ts +8 -1
  117. package/dist/lib/tty.d.ts.map +1 -1
  118. package/dist/lib/tty.js +53 -1
  119. package/dist/lib/tty.js.map +1 -1
  120. package/dist/providers/brave/adapter.d.ts +26 -0
  121. package/dist/providers/brave/adapter.d.ts.map +1 -1
  122. package/dist/providers/brave/adapter.js +66 -4
  123. package/dist/providers/brave/adapter.js.map +1 -1
  124. package/dist/providers/brave/client.d.ts +46 -2
  125. package/dist/providers/brave/client.d.ts.map +1 -1
  126. package/dist/providers/brave/client.js +55 -3
  127. package/dist/providers/brave/client.js.map +1 -1
  128. package/dist/providers/exa/adapter.d.ts.map +1 -1
  129. package/dist/providers/exa/adapter.js +10 -1
  130. package/dist/providers/exa/adapter.js.map +1 -1
  131. package/dist/providers/firecrawl/adapter.d.ts.map +1 -1
  132. package/dist/providers/firecrawl/adapter.js +13 -3
  133. package/dist/providers/firecrawl/adapter.js.map +1 -1
  134. package/dist/providers/minimax/adapter.d.ts.map +1 -1
  135. package/dist/providers/minimax/adapter.js +4 -0
  136. package/dist/providers/minimax/adapter.js.map +1 -1
  137. package/dist/providers/selection.d.ts +114 -1
  138. package/dist/providers/selection.d.ts.map +1 -1
  139. package/dist/providers/selection.js +99 -3
  140. package/dist/providers/selection.js.map +1 -1
  141. package/dist/providers/tavily/adapter.d.ts.map +1 -1
  142. package/dist/providers/tavily/adapter.js +2 -0
  143. package/dist/providers/tavily/adapter.js.map +1 -1
  144. package/dist/providers/types.d.ts +33 -1
  145. package/dist/providers/types.d.ts.map +1 -1
  146. package/dist/providers/types.js +8 -0
  147. package/dist/providers/types.js.map +1 -1
  148. package/dist/providers/zai/adapter.d.ts.map +1 -1
  149. package/dist/providers/zai/adapter.js +18 -5
  150. package/dist/providers/zai/adapter.js.map +1 -1
  151. package/package.json +2 -1
@@ -0,0 +1,1221 @@
1
+ /**
2
+ * Init command — interactive onboarding wizard (T3a + T3b — Plan A).
3
+ *
4
+ * This module owns the interactive `scoutline init` flow:
5
+ * - State detection (absent / corrupt / already-onboarded / fresh).
6
+ * - Provider checklist (registry-derived; none pre-checked — equal weight).
7
+ * - Per-provider ask-key-first → hidden input → inline single-attempt
8
+ * validation through `DiagnosticsCapability.invoke({probe:true})` against
9
+ * an EPHEMERAL resolved env (never persists, never mutates `process.env`).
10
+ * - Honest broad classification of probe failures: key-problems
11
+ * (`AuthError`/`ApiError`) reject + re-prompt; `NetworkError` offers
12
+ * save-unverified. No false-precise "wrong/disabled/mismatch" subtypes
13
+ * are inferred from message text — the taxonomy only distinguishes
14
+ * `AuthError`/`ApiError`/`NetworkError`.
15
+ * - Credit-cost disclosure before any paid-provider probe.
16
+ * - Fallback preference question.
17
+ * - Atomic config write (T1 `writeConfig` primitive) + redacted summary.
18
+ *
19
+ * T3b additions:
20
+ * - **Re-config menu** when an already-valid config exists: edit a
21
+ * Provider key, add a Provider, remove a Provider, change the
22
+ * fallback preference, re-run the full wizard, or cancel. Editing
23
+ * a Provider key resets `verification.status` to `unverified`
24
+ * (Doctor re-promotes after a successful probe).
25
+ * - **Corrupt-config repair**: tolerant `inspectConfig` distinguishes
26
+ * `absent` / `valid` / `corrupt`. On `corrupt` the wizard offers to
27
+ * back up the live file and rewrite a fresh config — `init` is the
28
+ * recovery path, not a victim of corruption.
29
+ * - **Formal non-TTY refuse**: without an interactive terminal the
30
+ * wizard refuses before any prompt, prints env instructions and
31
+ * the `init` hint, and exits.
32
+ * - **Stale-env-after-import warning**: when the user imports a key
33
+ * from env, the wizard notes that the env value will continue to
34
+ * take precedence (env > file in the runtime precedence rule) —
35
+ * the wizard cannot turn off the env value for the user.
36
+ * - **`hintShown` reset on re-init**: a re-config or fresh write
37
+ * resets the env-only hint marker so a user who later switches to
38
+ * env-only usage sees the hint once again.
39
+ *
40
+ * Boundary rules:
41
+ * - No Provider transport is constructed outside the per-provider probe;
42
+ * the candidate credential lives only in the ephemeral env until the
43
+ * final atomic write.
44
+ * - All prompt IO flows through the {@link InitPrompts} seam; no direct
45
+ * `process.stdin` / `process.stdout` reads in this module. Production
46
+ * wires `@inquirer/prompts`; tests inject scripted doubles.
47
+ * - Registration links render BOTH a terminal hyperlink AND the literal
48
+ * URL text so captured / non-hyperlink output stays usable.
49
+ * - No selection work (Plan B); the wizard writes at most one
50
+ * `fallbackEnabled` flag and per-Provider records.
51
+ */
52
+ import { inspectConfig, writeConfig } from "../lib/config-store.js";
53
+ import { AuthError, ApiError, NetworkError } from "../lib/errors.js";
54
+ // ---------------------------------------------------------------------------
55
+ // Public constants
56
+ // ---------------------------------------------------------------------------
57
+ /**
58
+ * Help text for `scoutline init --help`. T3b completes the wizard's
59
+ * lifecycle (re-config menu, corrupt-config repair, formal non-TTY
60
+ * refuse), so the T3a PREVIEW caveat is dropped — the public claim of
61
+ * a complete `init` is now accurate.
62
+ */
63
+ export const INIT_HELP = `
64
+ init - Interactive onboarding wizard
65
+
66
+ Usage: scoutline init [options]
67
+
68
+ The wizard walks you through recording API keys in
69
+ ~/.scoutline/config.json (mode 0600). It supports four states:
70
+
71
+ - ABSENT (no config yet): the fresh-onboarding flow runs.
72
+ - VALID + ALREADY-ONBOARDED: a re-config menu runs (edit a key,
73
+ add a Provider, remove a Provider, change the fallback
74
+ preference, re-run the full wizard, or cancel). Editing a key
75
+ resets that Provider's verification to "unverified".
76
+ - VALID + EMPTY: the fresh-onboarding flow runs.
77
+ - CORRUPT: the wizard offers to back up the live file and rewrite
78
+ a fresh config. init is the recovery path for a corrupt config.
79
+
80
+ The fresh flow:
81
+ - offers to import a provider key already present in env (the
82
+ wizard notes that env precedence means the env value keeps
83
+ winning at runtime)
84
+ - shows a provider checklist (Z.AI, MiniMax, Tavily, Exa, Brave,
85
+ Firecrawl) with NO pre-checked defaults — every provider has
86
+ equal weight
87
+ - for each selected provider, asks whether you have a key, takes a
88
+ hidden input, and performs a single inline validation probe
89
+ against an ephemeral in-memory environment (the candidate
90
+ credential is never persisted or written to process.env until the
91
+ final atomic write)
92
+ - honestly classifies probe failures as auth/api (reject + re-prompt)
93
+ or network (offer save-unverified). No false-precise subtypes.
94
+ - asks the fallback preference (route automatically when the
95
+ selected provider is unavailable)
96
+ - writes ~/.scoutline/config.json atomically with mode 0600
97
+
98
+ Non-interactive terminals: init refuses before any prompt and exits.
99
+ Run it inside a real TTY, or set up credentials via the documented
100
+ environment variables instead.
101
+
102
+ Options:
103
+ --help Show this help
104
+
105
+ Exit codes:
106
+ 0 Onboarding completed, re-config applied, or already-onboarded
107
+ with the user choosing Cancel.
108
+ 1 User cancelled (Ctrl+C / EOF), the wizard was invoked without a
109
+ terminal, a write failed, or the user declined corrupt-config
110
+ repair.
111
+ `.trim();
112
+ const PROVIDER_PROMPT_META = {
113
+ zai: {
114
+ label: "Z.AI",
115
+ envVar: "Z_AI_API_KEY",
116
+ registrationUrl: "https://z.ai/manage-apikey",
117
+ probeCostsCredit: false,
118
+ },
119
+ minimax: {
120
+ label: "MiniMax",
121
+ envVar: "MINIMAX_API_KEY",
122
+ registrationUrl: "https://platform.minimaxi.com/user-center/basic-information/interface-key",
123
+ probeCostsCredit: false,
124
+ },
125
+ tavily: {
126
+ label: "Tavily",
127
+ envVar: "TAVILY_API_KEY",
128
+ registrationUrl: "https://app.tavily.com/settings",
129
+ probeCostsCredit: true,
130
+ },
131
+ exa: {
132
+ label: "Exa",
133
+ envVar: "EXA_API_KEY",
134
+ registrationUrl: "https://dashboard.exa.ai/api-keys",
135
+ probeCostsCredit: true,
136
+ },
137
+ brave: {
138
+ label: "Brave Search",
139
+ envVar: "BRAVE_SEARCH_API_KEY",
140
+ registrationUrl: "https://api.search.brave.com/app/subscriptions",
141
+ probeCostsCredit: true,
142
+ },
143
+ firecrawl: {
144
+ label: "Firecrawl",
145
+ envVar: "FIRECRAWL_API_KEY",
146
+ registrationUrl: "https://www.firecrawl.dev/signin",
147
+ probeCostsCredit: true,
148
+ },
149
+ };
150
+ /**
151
+ * Lookup helper that fails closed when a registry provider lacks prompt
152
+ * metadata. Guards against a future Provider landing in the registry
153
+ * without a matching entry above.
154
+ */
155
+ function providerMeta(id) {
156
+ const meta = PROVIDER_PROMPT_META[id];
157
+ if (!meta) {
158
+ throw new Error(`Provider "${id}" has no init-wizard metadata. ` +
159
+ `Add it to PROVIDER_PROMPT_META in src/commands/init.ts.`);
160
+ }
161
+ return meta;
162
+ }
163
+ // ---------------------------------------------------------------------------
164
+ // Hyperlink rendering
165
+ // ---------------------------------------------------------------------------
166
+ /**
167
+ * Terminal hyperlink escape (OSC 8). Modern terminals render this as a
168
+ * clickable link; non-hyperlink terminals show the literal text. The
169
+ * `printRegistrationLink` helper ALWAYS emits the literal URL too, so
170
+ * captured/non-hyperlink output remains usable.
171
+ */
172
+ function hyperlink(text, url) {
173
+ return `\x1B]8;;${url}\x1B\\${text}\x1B]8;;\x1B\\`;
174
+ }
175
+ /**
176
+ * Render a registration link with BOTH a terminal hyperlink and the
177
+ * literal URL text on a separate line. The literal URL is unconditional:
178
+ * captured output (CI logs, pipe redirection) and non-hyperlink terminals
179
+ * still see a copy-pasteable URL.
180
+ */
181
+ function renderRegistrationLine(id) {
182
+ const meta = providerMeta(id);
183
+ return `${meta.label}: ${hyperlink("Get an API key", meta.registrationUrl)}\n ${meta.registrationUrl}`;
184
+ }
185
+ /**
186
+ * Wrap the real `inspectConfig` / `writeConfig` pair as an
187
+ * {@link InitConfigStore}. The default options object is captured per-call
188
+ * so the wizard can pass a temp `filePath` / atomic options through
189
+ * unchanged in tests.
190
+ */
191
+ export function createDefaultConfigStore(options = {}) {
192
+ return {
193
+ async inspect() {
194
+ return inspectConfig(options);
195
+ },
196
+ async write(config, writeOptions) {
197
+ // The wizard prefers its own atomic options when supplied; otherwise
198
+ // fall back to the store's. This keeps test-injected temp `filePath`
199
+ // honored through both paths.
200
+ const merged = writeOptions
201
+ ? { ...options, ...writeOptions, atomic: { ...options.atomic, ...writeOptions.atomic } }
202
+ : options;
203
+ await writeConfig(config, merged);
204
+ },
205
+ };
206
+ }
207
+ /**
208
+ * Probe classifier. `AuthError`/`ApiError` (and HTTP-status-4xx-typed
209
+ * variants) are key-problems → reject + re-prompt. `NetworkError` is
210
+ * transient/connectivity → offer save-unverified. Everything else is
211
+ * surfaced honestly as "unknown" rather than mis-typed.
212
+ */
213
+ function classifyProbeError(error) {
214
+ const message = error instanceof Error ? error.message : String(error);
215
+ if (error instanceof AuthError || error instanceof ApiError) {
216
+ return { status: "auth-error", message };
217
+ }
218
+ if (error instanceof NetworkError) {
219
+ return { status: "network-error", message };
220
+ }
221
+ return { status: "unknown-error", message };
222
+ }
223
+ /**
224
+ * Build the ephemeral resolved-env for a single-provider probe: a fresh
225
+ * shallow copy of the injected env with the candidate key written into
226
+ * the provider's canonical env-var slot. `process.env` is never mutated.
227
+ *
228
+ * The candidate lives only in this ephemeral view until the final atomic
229
+ * write. If the same env-var is already set in `env` (env precedence),
230
+ * the candidate overrides it for the probe only — we are testing the
231
+ * CANDIDATE, not the ambient value.
232
+ */
233
+ function buildEphemeralProbeEnv(env, canonicalVar, candidateKey) {
234
+ const ephemeral = { ...env };
235
+ ephemeral[canonicalVar] = candidateKey;
236
+ return ephemeral;
237
+ }
238
+ /**
239
+ * Run a single probe against the descriptor. Constructs the adapter from
240
+ * the EPHEMERAL env (never `deps.env` directly), calls
241
+ * `diagnostics.invoke({probe:true})` EXACTLY once, and classifies the
242
+ * outcome honestly. Doctor's retry wrapper is intentionally NOT used —
243
+ * the wizard honours the "one attempt" contract from the ticket.
244
+ */
245
+ async function probeProviderOnce(descriptor, ephemeralEnv) {
246
+ const options = { probe: true };
247
+ try {
248
+ const adapter = descriptor.create({ env: ephemeralEnv });
249
+ const capability = adapter.diagnostics;
250
+ if (!capability) {
251
+ // The descriptor advertises diagnostics but the adapter did not
252
+ // supply the handle. Treat as unknown-error so the user can
253
+ // save-unverified rather than hard-fail the wizard.
254
+ return {
255
+ status: "unknown-error",
256
+ message: `Provider "${descriptor.id}" did not supply a diagnostics capability`,
257
+ };
258
+ }
259
+ await capability.invoke(options);
260
+ return { status: "verified" };
261
+ }
262
+ catch (error) {
263
+ return classifyProbeError(error);
264
+ }
265
+ }
266
+ /**
267
+ * `true` when the existing config indicates the user has already
268
+ * completed onboarding. The wizard treats this state as
269
+ * "already-onboarded — re-config is a T3b follow-up" rather than entering
270
+ * the fresh flow.
271
+ */
272
+ function isAlreadyOnboarded(config) {
273
+ for (const providerConfig of Object.values(config.providers)) {
274
+ const pc = providerConfig;
275
+ if (pc?.onboarded === true)
276
+ return true;
277
+ if (typeof pc?.apiKey === "string" && pc.apiKey.trim().length > 0)
278
+ return true;
279
+ }
280
+ return false;
281
+ }
282
+ /**
283
+ * Return the providers from `descriptors` whose canonical env-var holds a
284
+ * non-blank key in `env`. Used to offer env-import at the start of the
285
+ * fresh flow.
286
+ */
287
+ function detectEnvKeyProviders(descriptors, env) {
288
+ const out = [];
289
+ for (const descriptor of descriptors) {
290
+ const canonical = providerMeta(descriptor.id).envVar;
291
+ const value = env[canonical];
292
+ if (typeof value === "string" && value.trim().length > 0) {
293
+ out.push(descriptor.id);
294
+ }
295
+ }
296
+ return out;
297
+ }
298
+ // ---------------------------------------------------------------------------
299
+ // Wizard orchestrator
300
+ // ---------------------------------------------------------------------------
301
+ /**
302
+ * Non-TTY refuse message. The wizard is interactive end-to-end; without
303
+ * a terminal it cannot receive keypresses, and we do not want a partial
304
+ * (config-only) flow that misleads users into thinking they configured
305
+ * something. The refuse message points users at the environment-variable
306
+ * path AND at `init` so they have a non-interactive alternative.
307
+ */
308
+ function formatNonTTYRefuse(env, descriptors) {
309
+ const example = descriptors[0];
310
+ const exampleVar = example ? providerMeta(example.id).envVar : "Z_AI_API_KEY";
311
+ const detected = descriptors.filter((d) => {
312
+ const v = env[providerMeta(d.id).envVar];
313
+ return typeof v === "string" && v.trim().length > 0;
314
+ });
315
+ const detectedLine = detected.length > 0
316
+ ? `\nDetected env keys: ${detected.map((d) => `$${providerMeta(d.id).envVar}`).join(", ")}. The CLI will use them at runtime.`
317
+ : "";
318
+ return ("scoutline init requires an interactive terminal.\n" +
319
+ "Re-run inside a TTY, or set up credentials via environment variables:\n" +
320
+ ` export ${exampleVar}="your-api-key"\n` +
321
+ "Then run any command directly (for example `scoutline doctor`)." +
322
+ detectedLine +
323
+ "\n");
324
+ }
325
+ /**
326
+ * Run the interactive init wizard. Performs the T3b state dispatch:
327
+ * - non-TTY → refuse before any prompt + exit 1.
328
+ * - corrupt config → offer backup + rewrite (init is the recovery path).
329
+ * - valid + already-onboarded → re-config menu.
330
+ * - valid + empty / absent → fresh-onboarding flow.
331
+ *
332
+ * Returns the exit code:
333
+ * - 0 on success, re-config applied, or already-onboarded + Cancel.
334
+ * - 1 on user cancel, non-TTY refuse, write failure, or declined repair.
335
+ */
336
+ export async function runFreshOnboarding(deps) {
337
+ // Formal non-TTY refuse (T3b). Without a TTY the wizard cannot run;
338
+ // we refuse before any prompt and surface the env-only alternative.
339
+ if (!deps.stdinIsTTY) {
340
+ deps.writeStderr(formatNonTTYRefuse(deps.env, deps.descriptors));
341
+ return 1;
342
+ }
343
+ // Tolerant state detection. `inspect` returns absent / valid / corrupt;
344
+ // the wizard dispatches on the status rather than entering a single
345
+ // flow. This is the recovery path for a corrupt config.
346
+ const inspection = await deps.configStore.inspect();
347
+ if (inspection.status === "corrupt") {
348
+ return repairCorruptConfig(deps, inspection.filePath, inspection.error);
349
+ }
350
+ if (inspection.status === "valid" && isAlreadyOnboarded(inspection.config)) {
351
+ return runReconfigMenu(deps, inspection.config, inspection.filePath);
352
+ }
353
+ // Absent OR valid+empty → fresh-onboarding flow. The fresh flow writes
354
+ // a complete config (replacing any empty valid file) and resets the
355
+ // env-only hint marker so a later switch to env-only usage re-hints.
356
+ return runFreshFlow(deps);
357
+ }
358
+ /**
359
+ * The fresh-onboarding flow (T3a). Splash + env-key detection + provider
360
+ * checklist + per-provider probe + fallback preference + atomic write.
361
+ * Resets `hintShown` on the written config (the T3b release contract:
362
+ * a fresh write clears the marker so the trigger-detection hint can
363
+ * fire again if the user later switches to env-only usage).
364
+ */
365
+ async function runFreshFlow(deps) {
366
+ // Splash (init-only). Sent to stderr so stdout stays data-only for the
367
+ // final summary.
368
+ deps.writeStderr([
369
+ "",
370
+ "Welcome to scoutline onboarding.",
371
+ "This wizard writes ~/.scoutline/config.json with mode 0600.",
372
+ "You can cancel at any time with Ctrl+C — nothing is written until the end.",
373
+ "",
374
+ ].join("\n"));
375
+ // Detect ambient env keys for the import offer.
376
+ const envKeyProviders = detectEnvKeyProviders(deps.descriptors, deps.env);
377
+ if (envKeyProviders.length > 0) {
378
+ const labels = envKeyProviders.map((id) => `${providerMeta(id).label} ($${providerMeta(id).envVar})`);
379
+ deps.writeStderr(`Detected env key${envKeyProviders.length === 1 ? "" : "s"}: ${labels.join(", ")}.\n` +
380
+ "Each will be offered as an import candidate in the per-provider flow.\n");
381
+ }
382
+ // Step 1 — provider checklist. Choices come from the registry (equal
383
+ // weight; none pre-checked). Env-key providers surface that hint in
384
+ // their description column; credit-cost disclosure lands there too so
385
+ // the user can opt out before any paid probe.
386
+ const onboardings = await collectProviderOnboardings(deps, envKeyProviders);
387
+ if (onboardings === null) {
388
+ // User cancelled mid-flow.
389
+ return 1;
390
+ }
391
+ // Step 2 — fallback preference. The wizard writes `fallbackEnabled`;
392
+ // T2a consumes it at runtime.
393
+ let fallbackEnabled = true;
394
+ try {
395
+ fallbackEnabled = await deps.prompts.confirm("Route automatically if the selected provider is unavailable? [Y/n]", true);
396
+ }
397
+ catch {
398
+ // Cancel on the fallback prompt is still a cancel.
399
+ return 1;
400
+ }
401
+ // Step 3 — atomic write (T1 primitive). Build the final config and
402
+ // commit. No partial writes ever reach disk: `writeConfig` either
403
+ // replaces the live file atomically or leaves it untouched. The
404
+ // `hintShown` field is deliberately OMITTED from buildConfig so the
405
+ // written file does not carry the marker — a fresh write clears it.
406
+ const config = buildConfig(onboardings, fallbackEnabled);
407
+ try {
408
+ await deps.configStore.write(config);
409
+ }
410
+ catch (error) {
411
+ const message = error instanceof Error ? error.message : String(error);
412
+ deps.writeStderr(`Failed to write config: ${message}\n`);
413
+ return 1;
414
+ }
415
+ // Step 4 — redacted summary on stdout (data-only contract). The
416
+ // summary line is the ONLY write to stdout in the wizard; it lists
417
+ // provider ids and their verification status, never the keys.
418
+ deps.writeStdout(`${formatSummary(onboardings, fallbackEnabled)}\n`);
419
+ return 0;
420
+ }
421
+ // ---------------------------------------------------------------------------
422
+ // Corrupt-config repair (T3b). init is the documented recovery path for
423
+ // a corrupt config.json; the wizard offers a backup + rewrite rather
424
+ // than refusing or silently clobbering the live file.
425
+ // ---------------------------------------------------------------------------
426
+ /**
427
+ * Offer corrupt-config repair. Surfaces the underlying error, asks for
428
+ * confirmation, and on Yes:
429
+ * 1. Renames the live file to `<filePath>.corrupt-<timestamp>.bak`
430
+ * (best-effort; a rename failure does NOT block the rewrite —
431
+ * the user already confirmed).
432
+ * 2. Writes a fresh config via the normal atomic primitive (the
433
+ * backup line is logged to stderr; the live file is not unlinked
434
+ * without a backup unless rename fails, in which case we ask
435
+ * again).
436
+ * 3. Falls through to the fresh-onboarding flow so the user can
437
+ * rebuild their config interactively.
438
+ *
439
+ * On No (decline), the wizard exits 1 without modifying anything. This
440
+ * keeps init safe: a user who lands here by accident can back out.
441
+ */
442
+ async function repairCorruptConfig(deps, filePath, error) {
443
+ const errorMessage = error instanceof Error ? error.message : String(error);
444
+ deps.writeStderr([
445
+ "",
446
+ `scoutline: config.json at ${filePath} is corrupt or unreadable.`,
447
+ `Reason: ${errorMessage}`,
448
+ "",
449
+ "init can back up the live file and write a fresh config.",
450
+ "The backup is named <config.json>.corrupt-<timestamp>.bak and is",
451
+ "never deleted by scoutline.",
452
+ "",
453
+ ].join("\n"));
454
+ let proceed;
455
+ try {
456
+ proceed = await deps.prompts.confirm("Back up the corrupt config and run the fresh-onboarding flow? [y/N]", false);
457
+ }
458
+ catch {
459
+ // Cancel on the repair confirm is the same as declining.
460
+ return 1;
461
+ }
462
+ if (!proceed) {
463
+ deps.writeStderr("Declined repair. Run `scoutline init` again to retry, or fix/remove the file manually.\n");
464
+ return 1;
465
+ }
466
+ // Best-effort backup. The ConfigStore interface owns the file path
467
+ // (real or temp-dir-injected); we delegate the rename to a typed
468
+ // helper if the store exposes one, otherwise we use the same write
469
+ // primitive to land the fresh file (the live corrupt bytes are
470
+ // replaced atomically). The store seam is intentionally narrow so
471
+ // tests do not need a real fs.rename injection.
472
+ const backupPath = `${filePath}.corrupt-${deps.now()}.bak`;
473
+ const backedUp = await backupCorruptFile(deps, filePath, backupPath);
474
+ if (backedUp) {
475
+ deps.writeStderr(`Backed up corrupt config to ${backupPath}.\n`);
476
+ }
477
+ else {
478
+ // Rename failed — we did not create a backup. Re-ask the user so
479
+ // the live file is never clobbered without explicit consent.
480
+ deps.writeStderr("Could not create a backup (rename failed or unsupported by the store).");
481
+ try {
482
+ const clobber = await deps.prompts.confirm("Rewrite the live config WITHOUT a backup? [y/N]", false);
483
+ if (!clobber) {
484
+ deps.writeStderr("Declined. Repair aborted; the live file is untouched.\n");
485
+ return 1;
486
+ }
487
+ }
488
+ catch {
489
+ return 1;
490
+ }
491
+ }
492
+ // Fall through to the fresh flow. The first write replaces the live
493
+ // (corrupt) bytes atomically; nothing partial reaches disk.
494
+ deps.writeStderr("Running the fresh-onboarding flow. The corrupt file has been set aside.\n");
495
+ return runFreshFlow(deps);
496
+ }
497
+ /**
498
+ * Try to rename the corrupt file to the backup path. Returns `true` on
499
+ * success. The implementation prefers a store-supplied rename hook
500
+ * (tests can inject a fake); in production the real store delegates to
501
+ * `fs.rename`. A thrown rename is swallowed and reported as `false` so
502
+ * the caller can re-ask the user.
503
+ */
504
+ async function backupCorruptFile(deps, filePath, backupPath) {
505
+ const store = deps.configStore;
506
+ if (typeof store.backupCorrupt === "function") {
507
+ try {
508
+ return await store.backupCorrupt(filePath, backupPath);
509
+ }
510
+ catch {
511
+ return false;
512
+ }
513
+ }
514
+ return false;
515
+ }
516
+ /**
517
+ * Offer the re-config menu when an already-valid config exists. Each
518
+ * menu action re-uses the T3a ask-key → input → validate loop where
519
+ * applicable; editing a Provider key resets that Provider's
520
+ * `verification.status` to `unverified` (Doctor re-promotes after a
521
+ * successful probe).
522
+ *
523
+ * The menu loops until the user picks `cancel` or `rerun-full` (which
524
+ * exits the loop and either returns 0 or delegates to the fresh flow).
525
+ */
526
+ async function runReconfigMenu(deps, config, filePath) {
527
+ const configuredIds = Object.keys(config.providers).filter((id) => {
528
+ const provider = config.providers[id];
529
+ return provider && typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0;
530
+ });
531
+ const fallbackLine = config.fallbackEnabled === undefined
532
+ ? "fallback: default (true)"
533
+ : `fallback: ${config.fallbackEnabled ? "enabled" : "disabled"}`;
534
+ deps.writeStderr([
535
+ "",
536
+ `scoutline is already set up at ${filePath}.`,
537
+ `Providers configured: ${configuredIds.length === 0 ? "none" : configuredIds.join(", ")}.`,
538
+ fallbackLine,
539
+ "",
540
+ ].join("\n"));
541
+ for (;;) {
542
+ const action = await promptReconfigAction(deps, configuredIds);
543
+ if (action === null) {
544
+ // Cancel on the menu itself.
545
+ return 1;
546
+ }
547
+ if (action === "cancel") {
548
+ deps.writeStderr("No changes made.\n");
549
+ return 0;
550
+ }
551
+ if (action === "rerun-full") {
552
+ deps.writeStderr("Re-running the full onboarding flow. The live config will be replaced atomically.\n");
553
+ return runFreshFlow(deps);
554
+ }
555
+ // Mutating actions: each returns the next config (or null on cancel).
556
+ const next = await applyReconfigAction(deps, action, config, configuredIds);
557
+ if (next === "write-error") {
558
+ return 1;
559
+ }
560
+ if (next === "loop") {
561
+ // The action was a no-op (e.g. user backed out of a sub-prompt);
562
+ // re-render the menu.
563
+ continue;
564
+ }
565
+ if (next === "cancel") {
566
+ deps.writeStderr("No changes made.\n");
567
+ return 0;
568
+ }
569
+ // next === "written": the action mutated and persisted the config.
570
+ // Re-render the menu so the user can take another action.
571
+ configuredIds.length = 0;
572
+ const fresh = await deps.configStore.inspect();
573
+ if (fresh.status === "valid") {
574
+ for (const id of Object.keys(fresh.config.providers)) {
575
+ const provider = fresh.config.providers[id];
576
+ if (provider && typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0) {
577
+ configuredIds.push(id);
578
+ }
579
+ }
580
+ // Mirror mutations into the local `config` reference so the next
581
+ // iteration sees the latest state.
582
+ Object.assign(config, fresh.config);
583
+ }
584
+ }
585
+ }
586
+ /**
587
+ * Render the re-config menu and return the chosen action. Returns
588
+ * `null` on cancel (Ctrl+C / EOF).
589
+ */
590
+ async function promptReconfigAction(deps, configuredIds) {
591
+ const choices = [];
592
+ if (configuredIds.length > 0) {
593
+ choices.push({
594
+ value: "edit-key",
595
+ name: "Edit a provider key",
596
+ description: "Replace an existing API key (resets verification to unverified)",
597
+ });
598
+ choices.push({
599
+ value: "remove-provider",
600
+ name: "Remove a provider",
601
+ description: "Drop a provider entry from the config",
602
+ });
603
+ }
604
+ choices.push({
605
+ value: "add-provider",
606
+ name: "Add a provider",
607
+ description: "Run the per-provider flow for a provider not yet configured",
608
+ });
609
+ choices.push({
610
+ value: "change-fallback",
611
+ name: "Change fallback preference",
612
+ description: "Toggle the Provider-fallback flag (currently consulted at runtime)",
613
+ });
614
+ choices.push({
615
+ value: "rerun-full",
616
+ name: "Re-run full onboarding",
617
+ description: "Discard the current config and start the wizard from scratch",
618
+ });
619
+ choices.push({ value: "cancel", name: "Cancel", description: "Exit without changes" });
620
+ try {
621
+ return await deps.prompts.select("What would you like to do?", choices);
622
+ }
623
+ catch {
624
+ return null;
625
+ }
626
+ }
627
+ /**
628
+ * Apply a single mutating re-config action. Persists the result via the
629
+ * store and returns a status the caller uses to decide whether to
630
+ * re-render the menu, exit, or loop.
631
+ *
632
+ * - "written": the config was mutated + persisted successfully.
633
+ * - "loop": the user backed out of a sub-prompt (no mutation); re-render.
634
+ * - "cancel": the user explicitly cancelled; exit 0.
635
+ * - "write-error": the atomic write failed; exit 1.
636
+ */
637
+ async function applyReconfigAction(deps, action, config, configuredIds) {
638
+ if (action === "change-fallback") {
639
+ return changeFallback(deps, config);
640
+ }
641
+ if (action === "add-provider") {
642
+ return addProvider(deps, config, configuredIds);
643
+ }
644
+ if (action === "remove-provider") {
645
+ return removeProvider(deps, config, configuredIds);
646
+ }
647
+ // edit-key
648
+ return editProviderKey(deps, config, configuredIds);
649
+ }
650
+ /**
651
+ * Toggle the fallback flag. Reads the current value, asks the new
652
+ * preference, and persists.
653
+ */
654
+ async function changeFallback(deps, config) {
655
+ const current = config.fallbackEnabled ?? true;
656
+ try {
657
+ const next = await deps.prompts.confirm(`Route automatically if the selected provider is unavailable? [${current ? "Y/n" : "y/N"}]`, current);
658
+ const updated = {
659
+ ...config,
660
+ providers: { ...config.providers },
661
+ fallbackEnabled: next,
662
+ // hintShown is intentionally preserved (re-config does not reset it;
663
+ // only a fresh-write or re-init does).
664
+ ...(config.hintShown !== undefined ? { hintShown: config.hintShown } : {}),
665
+ };
666
+ return persistConfig(deps, updated);
667
+ }
668
+ catch {
669
+ return "loop";
670
+ }
671
+ }
672
+ /**
673
+ * Add a provider not yet configured. Reuses the T3a per-provider flow
674
+ * (ask-key → input → validate). The new record joins the existing
675
+ * `providers` map without disturbing the others.
676
+ */
677
+ async function addProvider(deps, config, configuredIds) {
678
+ const available = deps.descriptors.map((d) => d.id).filter((id) => !configuredIds.includes(id));
679
+ if (available.length === 0) {
680
+ deps.writeStderr("Every built-in provider is already configured.\n");
681
+ return "loop";
682
+ }
683
+ const choices = available.map((id) => ({
684
+ value: id,
685
+ name: providerMeta(id).label,
686
+ description: providerMeta(id).probeCostsCredit
687
+ ? "validation probe costs ~1 credit"
688
+ : "validation probe is free",
689
+ }));
690
+ let providerId;
691
+ try {
692
+ providerId = await deps.prompts.select("Add which provider?", choices);
693
+ }
694
+ catch {
695
+ return "loop";
696
+ }
697
+ const descriptor = deps.descriptors.find((d) => d.id === providerId);
698
+ if (!descriptor) {
699
+ deps.writeStderr(`Provider "${providerId}" is not in the registry.\n`);
700
+ return "loop";
701
+ }
702
+ // Reuse the T3a per-provider flow against an empty envKeyProviders so
703
+ // the import offer is skipped (the user is ADDING; we do not auto-pull
704
+ // from env here). The probe runs against the ephemeral candidate.
705
+ const onboarding = await onboardSingleProvider(deps, providerId, []);
706
+ if (onboarding === null) {
707
+ return "loop";
708
+ }
709
+ if (onboarding === "skip") {
710
+ return "loop";
711
+ }
712
+ const updated = {
713
+ ...config,
714
+ providers: {
715
+ ...config.providers,
716
+ [providerId]: {
717
+ apiKey: onboarding.apiKey,
718
+ onboarded: true,
719
+ verification: onboarding.verification,
720
+ },
721
+ },
722
+ ...(config.hintShown !== undefined ? { hintShown: config.hintShown } : {}),
723
+ };
724
+ const status = persistConfig(deps, updated);
725
+ if ((await status) === "written") {
726
+ deps.writeStdout(`${providerMeta(providerId).label}: added (verification: ${onboarding.verification.status}).\n`);
727
+ }
728
+ return status;
729
+ }
730
+ /**
731
+ * Remove a configured provider. The user picks from the currently
732
+ * configured set; the chosen entry is dropped from `providers`.
733
+ */
734
+ async function removeProvider(deps, config, configuredIds) {
735
+ if (configuredIds.length === 0) {
736
+ deps.writeStderr("No providers are configured.\n");
737
+ return "loop";
738
+ }
739
+ const choices = configuredIds.map((id) => ({
740
+ value: id,
741
+ name: providerMeta(id).label,
742
+ }));
743
+ choices.push({ value: undefined, name: "Back" });
744
+ let providerId;
745
+ try {
746
+ const picked = await deps.prompts.select("Remove which provider?", choices);
747
+ providerId = picked;
748
+ }
749
+ catch {
750
+ return "loop";
751
+ }
752
+ if (providerId === undefined) {
753
+ return "loop";
754
+ }
755
+ try {
756
+ const confirmed = await deps.prompts.confirm(`Remove ${providerMeta(providerId).label} from the config? [y/N]`, false);
757
+ if (!confirmed) {
758
+ return "loop";
759
+ }
760
+ }
761
+ catch {
762
+ return "loop";
763
+ }
764
+ const nextProviders = {
765
+ ...config.providers,
766
+ };
767
+ delete nextProviders[providerId];
768
+ const updated = {
769
+ ...config,
770
+ providers: nextProviders,
771
+ ...(config.hintShown !== undefined ? { hintShown: config.hintShown } : {}),
772
+ };
773
+ const status = persistConfig(deps, updated);
774
+ if ((await status) === "written") {
775
+ deps.writeStdout(`${providerMeta(providerId).label}: removed.\n`);
776
+ }
777
+ return status;
778
+ }
779
+ /**
780
+ * Edit (replace) a configured provider's key. Reuses the T3a per-provider
781
+ * flow's validate step against the ephemeral candidate. Editing a key
782
+ * RESETS that Provider's `verification.status` to `unverified` —
783
+ * Doctor re-promotes after a successful probe (the probe the wizard
784
+ * runs here does NOT promote because the wizard's probe is a
785
+ * connectivity check, not a Doctor invocation).
786
+ */
787
+ async function editProviderKey(deps, config, configuredIds) {
788
+ if (configuredIds.length === 0) {
789
+ deps.writeStderr("No providers are configured.\n");
790
+ return "loop";
791
+ }
792
+ const choices = configuredIds.map((id) => ({
793
+ value: id,
794
+ name: providerMeta(id).label,
795
+ }));
796
+ choices.push({ value: undefined, name: "Back" });
797
+ let providerId;
798
+ try {
799
+ providerId = await deps.prompts.select("Edit which provider's key?", choices);
800
+ }
801
+ catch {
802
+ return "loop";
803
+ }
804
+ if (providerId === undefined) {
805
+ return "loop";
806
+ }
807
+ const descriptor = deps.descriptors.find((d) => d.id === providerId);
808
+ if (!descriptor) {
809
+ deps.writeStderr(`Provider "${providerId}" is not in the registry.\n`);
810
+ return "loop";
811
+ }
812
+ const meta = providerMeta(providerId);
813
+ if (meta.probeCostsCredit) {
814
+ deps.writeStderr(`${meta.label}: validating the new key costs ~1 credit against your account.\n`);
815
+ }
816
+ let candidate;
817
+ try {
818
+ candidate = (await deps.prompts.password(`Paste the new ${meta.label} API key (input hidden):`)).trim();
819
+ }
820
+ catch {
821
+ return "loop";
822
+ }
823
+ // Reuse the T3a validate loop. The candidate is probed; on verified
824
+ // we persist a verified record; on save-unverified we persist an
825
+ // unverified record; on skip/cancel we re-render the menu.
826
+ const result = await validateAndCollect(deps, descriptor, candidate);
827
+ if (result === null) {
828
+ return "loop";
829
+ }
830
+ if (result === "skip") {
831
+ return "loop";
832
+ }
833
+ const updated = {
834
+ ...config,
835
+ providers: {
836
+ ...config.providers,
837
+ [providerId]: {
838
+ apiKey: result.apiKey,
839
+ onboarded: true,
840
+ verification: result.verification,
841
+ },
842
+ },
843
+ ...(config.hintShown !== undefined ? { hintShown: config.hintShown } : {}),
844
+ };
845
+ const status = persistConfig(deps, updated);
846
+ if ((await status) === "written") {
847
+ deps.writeStdout(`${meta.label}: key updated (verification: ${result.verification.status}). ` +
848
+ `Run \`scoutline doctor\` to re-verify.\n`);
849
+ }
850
+ return status;
851
+ }
852
+ /**
853
+ * Persist a mutated config through the store. Returns `"written"` on
854
+ * success or `"write-error"` on failure (the caller surfaces the exit
855
+ * code). The atomic primitive guarantees no partial write reaches disk.
856
+ */
857
+ async function persistConfig(deps, config) {
858
+ try {
859
+ await deps.configStore.write(config);
860
+ return "written";
861
+ }
862
+ catch (error) {
863
+ const message = error instanceof Error ? error.message : String(error);
864
+ deps.writeStderr(`Failed to write config: ${message}\n`);
865
+ return "write-error";
866
+ }
867
+ }
868
+ /**
869
+ * Step 1 of the wizard: collect per-provider onboarding state via the
870
+ * checklist + ask-key-first + hidden input + single probe. Returns
871
+ * `null` when the user cancelled mid-flow (so the caller treats it as a
872
+ * no-write exit).
873
+ */
874
+ async function collectProviderOnboardings(deps, envKeyProviders) {
875
+ // Loop until the user either selects at least one provider OR confirms
876
+ // the zero-provider continue. The "back to checklist" path on a
877
+ // zero-select-No branch is the documented fresh-flow edge case.
878
+ for (;;) {
879
+ const choices = deps.descriptors.map((descriptor) => {
880
+ const meta = providerMeta(descriptor.id);
881
+ const hints = [];
882
+ if (envKeyProviders.includes(descriptor.id)) {
883
+ hints.push(`env $${meta.envVar} present (importable)`);
884
+ }
885
+ if (meta.probeCostsCredit) {
886
+ hints.push("validation probe costs ~1 credit");
887
+ }
888
+ else {
889
+ hints.push("validation probe is free");
890
+ }
891
+ return {
892
+ value: descriptor.id,
893
+ name: meta.label,
894
+ description: hints.join("; "),
895
+ checked: false,
896
+ };
897
+ });
898
+ let selected;
899
+ try {
900
+ selected = await deps.prompts.checkbox("Select providers to configure (space to toggle, enter to confirm)", choices);
901
+ }
902
+ catch {
903
+ return null;
904
+ }
905
+ if (selected.length === 0) {
906
+ // Zero-provider confirmation. Default No returns to the checklist
907
+ // so the user does not accidentally exit on a stray enter.
908
+ let continueWithNone = false;
909
+ try {
910
+ continueWithNone = await deps.prompts.confirm("Continue with no providers? [y/N]", false);
911
+ }
912
+ catch {
913
+ return null;
914
+ }
915
+ if (!continueWithNone) {
916
+ // Loop back to the checklist.
917
+ continue;
918
+ }
919
+ // Empty-onboarding + fallback-default writes a minimal config and
920
+ // leaves a pointer that the user can re-run `init` later.
921
+ return [];
922
+ }
923
+ // Step 1b — per-provider ask-key-first → hidden input → single probe.
924
+ const onboardings = [];
925
+ for (const providerId of selected) {
926
+ const onboarding = await onboardSingleProvider(deps, providerId, envKeyProviders);
927
+ if (onboarding === null) {
928
+ return null;
929
+ }
930
+ if (onboarding === "skip") {
931
+ continue;
932
+ }
933
+ onboardings.push(onboarding);
934
+ }
935
+ return onboardings;
936
+ }
937
+ }
938
+ /**
939
+ * Per-provider flow: ask-key-first → hidden input → single probe. The
940
+ * candidate credential lives only in the ephemeral probe env. Returns
941
+ * `null` on cancel, `"skip"` if the user declined to provide a key (no
942
+ * registration link visit, no probe), or the resulting onboarding state.
943
+ */
944
+ async function onboardSingleProvider(deps, providerId, envKeyProviders) {
945
+ const meta = providerMeta(providerId);
946
+ const descriptor = deps.descriptors.find((d) => d.id === providerId);
947
+ if (!descriptor) {
948
+ // The checklist is registry-derived, so this is unreachable unless
949
+ // the caller passed a divergent `descriptors` list.
950
+ deps.writeStderr(`Provider "${providerId}" is not in the registry; skipping.\n`);
951
+ return "skip";
952
+ }
953
+ // Env-import offer takes precedence over ask-key-first. The wizard
954
+ // does NOT auto-import; it asks once per env-key provider.
955
+ if (envKeyProviders.includes(providerId)) {
956
+ try {
957
+ const importFromEnv = await deps.prompts.confirm(`Import ${meta.label} key from $${meta.envVar}? [Y/n]`, true);
958
+ if (importFromEnv) {
959
+ const candidate = deps.env[meta.envVar];
960
+ if (typeof candidate === "string" && candidate.trim().length > 0) {
961
+ // Stale-env-after-import warning (T3b edge case). The runtime
962
+ // precedence rule is env > file: an env value still set at
963
+ // runtime will keep winning over this imported file key, so
964
+ // the saved key is effectively dormant until the env value is
965
+ // removed. The wizard notes this so the user is not surprised
966
+ // later. The env value still wins at runtime by design; the
967
+ // wizard cannot unset it for the user.
968
+ deps.writeStderr(`Note: env precedence means $${meta.envVar} will keep overriding the saved key at runtime. ` +
969
+ `Unset it (or remove it from your shell profile) to make the saved key authoritative.\n`);
970
+ return validateAndCollect(deps, descriptor, candidate);
971
+ }
972
+ // The env value was blank/removed between detection and read;
973
+ // fall through to ask-key-first so the user can still supply one.
974
+ deps.writeStderr(`Env value for $${meta.envVar} is blank or missing; falling back to manual input.\n`);
975
+ }
976
+ }
977
+ catch {
978
+ return null;
979
+ }
980
+ }
981
+ // Ask-key-first. Default Yes — most users who reach this prompt have
982
+ // a key. Declining (No) shows the registration link and skips the
983
+ // provider; it does not advance on a key-problem, it just defers.
984
+ let hasKey;
985
+ try {
986
+ hasKey = await deps.prompts.confirm(`Do you have a ${meta.label} API key? [Y/n]`, true);
987
+ }
988
+ catch {
989
+ return null;
990
+ }
991
+ if (!hasKey) {
992
+ deps.writeStderr(`${renderRegistrationLine(providerId)}\n`);
993
+ return "skip";
994
+ }
995
+ // Credit-cost disclosure BEFORE any probe. Z.AI / MiniMax probes are
996
+ // free (tool discovery / raw quota); the other four charge ~1 credit.
997
+ if (meta.probeCostsCredit) {
998
+ deps.writeStderr(`${meta.label}: validating the key costs ~1 credit against your account.\n`);
999
+ }
1000
+ // Hidden password input. The value flows only into the ephemeral env.
1001
+ let candidate;
1002
+ try {
1003
+ candidate = await deps.prompts.password(`Paste your ${meta.label} API key (input hidden):`);
1004
+ }
1005
+ catch {
1006
+ return null;
1007
+ }
1008
+ return validateAndCollect(deps, descriptor, candidate);
1009
+ }
1010
+ /**
1011
+ * Build the ephemeral probe env for `descriptor` + `candidate`, run a
1012
+ * single probe, and classify. On verified → return the onboarding state.
1013
+ * On auth-error → re-prompt once for a fresh candidate. On
1014
+ * network-error → offer save-unverified. On unknown-error → surface
1015
+ * honestly and offer save-unverified (the wizard never claims an unknown
1016
+ * failure was verified). Returns `null` on cancel, `"skip"` if the user
1017
+ * declines all recovery options.
1018
+ */
1019
+ async function validateAndCollect(deps, descriptor, initialCandidate) {
1020
+ const meta = providerMeta(descriptor.id);
1021
+ let candidate = initialCandidate.trim();
1022
+ // Re-prompt loop. We allow one re-entry per auth/unknown error so the
1023
+ // user can fix a typo without restarting the wizard. The probe itself
1024
+ // is exactly one attempt per loop iteration (the ticket's "one
1025
+ // attempt" contract).
1026
+ for (;;) {
1027
+ if (candidate.length === 0) {
1028
+ deps.writeStderr(`${meta.label}: blank key — skipping this provider.\n`);
1029
+ return "skip";
1030
+ }
1031
+ const ephemeralEnv = buildEphemeralProbeEnv(deps.env, meta.envVar, candidate);
1032
+ const outcome = await probeProviderOnce(descriptor, ephemeralEnv);
1033
+ if (outcome.status === "verified") {
1034
+ return {
1035
+ providerId: descriptor.id,
1036
+ apiKey: candidate,
1037
+ verification: { status: "verified", checkedAt: deps.now() },
1038
+ };
1039
+ }
1040
+ if (outcome.status === "network-error") {
1041
+ // Offer save-unverified. The candidate is preserved; the
1042
+ // verification record marks the deferred state with the reason.
1043
+ try {
1044
+ const save = await deps.prompts.confirm(`${meta.label}: connectivity check failed (${outcome.message}). ` +
1045
+ "Save the key as UNVERIFIED? [Y/n]", true);
1046
+ if (save) {
1047
+ return {
1048
+ providerId: descriptor.id,
1049
+ apiKey: candidate,
1050
+ verification: {
1051
+ status: "unverified",
1052
+ checkedAt: deps.now(),
1053
+ reason: "network-deferred",
1054
+ },
1055
+ };
1056
+ }
1057
+ return "skip";
1058
+ }
1059
+ catch {
1060
+ return null;
1061
+ }
1062
+ }
1063
+ if (outcome.status === "auth-error") {
1064
+ // Key-problem → reject + re-prompt. Never advance on an
1065
+ // auth/api error.
1066
+ deps.writeStderr(`${meta.label}: key rejected (${outcome.message}).\n`);
1067
+ try {
1068
+ candidate = (await deps.prompts.password(`Re-enter your ${meta.label} API key (or press Ctrl+C to skip):`)).trim();
1069
+ }
1070
+ catch {
1071
+ return null;
1072
+ }
1073
+ continue;
1074
+ }
1075
+ // Unknown-error — surface honestly. Offer save-unverified as a
1076
+ // graceful recovery rather than masking as verified.
1077
+ deps.writeStderr(`${meta.label}: probe failed — ${outcome.message}\n`);
1078
+ try {
1079
+ const save = await deps.prompts.confirm(`${meta.label}: save the key as UNVERIFIED anyway? [y/N]`, false);
1080
+ if (save) {
1081
+ return {
1082
+ providerId: descriptor.id,
1083
+ apiKey: candidate,
1084
+ verification: {
1085
+ status: "unverified",
1086
+ checkedAt: deps.now(),
1087
+ reason: "unknown-probe-failure",
1088
+ },
1089
+ };
1090
+ }
1091
+ return "skip";
1092
+ }
1093
+ catch {
1094
+ return null;
1095
+ }
1096
+ }
1097
+ }
1098
+ // ---------------------------------------------------------------------------
1099
+ // Config assembly + summary
1100
+ // ---------------------------------------------------------------------------
1101
+ /**
1102
+ * Build the final {@link ScoutlineConfig} from the wizard's collected
1103
+ * state. Each onboarding entry becomes a `providers[id]` row carrying
1104
+ * the api key, the onboarding-completed flag, and the verification
1105
+ * record. `fallbackEnabled` is the Step-2 answer.
1106
+ */
1107
+ function buildConfig(onboardings, fallbackEnabled) {
1108
+ const providers = {};
1109
+ for (const onboarding of onboardings) {
1110
+ providers[onboarding.providerId] = {
1111
+ apiKey: onboarding.apiKey,
1112
+ onboarded: true,
1113
+ verification: onboarding.verification,
1114
+ };
1115
+ }
1116
+ return {
1117
+ version: 1,
1118
+ fallbackEnabled,
1119
+ providers,
1120
+ };
1121
+ }
1122
+ /**
1123
+ * Format the redacted stdout summary line. Provider ids and verification
1124
+ * status are surfaced; keys never are. One line per provider + a
1125
+ * fallback footer.
1126
+ */
1127
+ function formatSummary(onboardings, fallbackEnabled) {
1128
+ if (onboardings.length === 0) {
1129
+ return ("scoutline onboarding complete with no providers configured. " +
1130
+ "Re-run `scoutline init` to add one.");
1131
+ }
1132
+ const lines = onboardings.map((onboarding) => {
1133
+ const meta = providerMeta(onboarding.providerId);
1134
+ const status = onboarding.verification.status;
1135
+ return `${meta.label} (${onboarding.providerId}): ${status}`;
1136
+ });
1137
+ lines.push(`fallbackEnabled=${fallbackEnabled ? "true" : "false"}`);
1138
+ lines.push("Wrote ~/.scoutline/config.json (mode 0600).");
1139
+ return lines.join("\n");
1140
+ }
1141
+ let inquirerPromise;
1142
+ async function loadInquirer() {
1143
+ if (!inquirerPromise) {
1144
+ inquirerPromise = import("@inquirer/prompts").then((mod) => {
1145
+ return mod;
1146
+ });
1147
+ }
1148
+ return inquirerPromise;
1149
+ }
1150
+ /**
1151
+ * Build the production {@link InitPrompts} from `@inquirer/prompts`. The
1152
+ * adapter:
1153
+ * - Rewires the inquirer output stream to `process.stderr` so the
1154
+ * wizard keeps the codebase's data-only-stdout contract (the final
1155
+ * summary line is the only stdout write).
1156
+ * - Maps the four wizard prompt shapes onto the inquirer calls.
1157
+ *
1158
+ * Tests never call this — they inject a scripted {@link InitPrompts}
1159
+ * double instead, so test runs are fully hermetic and do not need a TTY.
1160
+ */
1161
+ export function createInquirerPrompts() {
1162
+ const context = { output: process.stderr };
1163
+ return {
1164
+ async checkbox(message, choices) {
1165
+ const inquirer = await loadInquirer();
1166
+ return inquirer.checkbox({
1167
+ message,
1168
+ // inquirer mutates the choice array; pass a defensive shallow
1169
+ // copy so the wizard's `readonly` source is untouched.
1170
+ choices: choices.map((c) => ({ ...c })),
1171
+ }, context);
1172
+ },
1173
+ async select(message, choices) {
1174
+ const inquirer = await loadInquirer();
1175
+ return inquirer.select({
1176
+ message,
1177
+ choices: choices.map((c) => ({ ...c })),
1178
+ }, context);
1179
+ },
1180
+ async confirm(message, defaultYes) {
1181
+ const inquirer = await loadInquirer();
1182
+ return inquirer.confirm({ message, default: defaultYes }, context);
1183
+ },
1184
+ async password(message) {
1185
+ const inquirer = await loadInquirer();
1186
+ const value = await inquirer.password({ message, mask: "" }, context);
1187
+ return typeof value === "string" ? value.trim() : "";
1188
+ },
1189
+ async input(message) {
1190
+ const inquirer = await loadInquirer();
1191
+ return inquirer.input({ message }, context);
1192
+ },
1193
+ };
1194
+ }
1195
+ // ---------------------------------------------------------------------------
1196
+ // Top-level handler
1197
+ // ---------------------------------------------------------------------------
1198
+ /**
1199
+ * Top-level init handler. Wired into the dispatch switch in `index.ts`.
1200
+ *
1201
+ * - `--help` / `-h` prints {@link INIT_HELP} to stdout and returns 0.
1202
+ * - Otherwise dispatches to {@link runFreshOnboarding}.
1203
+ *
1204
+ * The CLI arg list is the same shape every other handler receives; the
1205
+ * handler only inspects it for the help flag.
1206
+ */
1207
+ export async function handleInitWithHelp(args, deps) {
1208
+ if (args.includes("--help") || args.includes("-h")) {
1209
+ deps.writeStdout(`${INIT_HELP}\n`);
1210
+ return 0;
1211
+ }
1212
+ return runFreshOnboarding(deps);
1213
+ }
1214
+ /**
1215
+ * Backwards-compatible alias used by the dispatch switch. Tests that drive
1216
+ * the wizard directly pass an empty arg list.
1217
+ */
1218
+ export async function handleInit(deps) {
1219
+ return handleInitWithHelp([], deps);
1220
+ }
1221
+ //# sourceMappingURL=init.js.map