@marcusrbrown/infra 0.7.0 → 0.8.1

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.
@@ -1,155 +1,50 @@
1
1
  /// <reference types="bun" />
2
2
 
3
- import type {SpinnerResult} from '@clack/prompts'
4
3
  import type {goke} from 'goke'
5
4
 
6
- import {cancel, confirm, intro, isCancel, log, multiselect, note, outro, select, spinner, text} from '@clack/prompts'
5
+ import type {ActionCtx} from '../../lib/action-ctx'
6
+
7
+ import {cancel, confirm, intro, log, note, outro, select, text} from '@clack/prompts'
7
8
  import {z} from 'zod'
8
9
 
9
10
  import {resolveManagementKey} from './config'
10
- import {toStringArray} from './keys'
11
+ import {
12
+ applyGhValue,
13
+ assertGhAuthenticated,
14
+ assertGhInstalled,
15
+ assertRepoAccess,
16
+ createManagementApiKey,
17
+ deleteManagementApiKey,
18
+ listExistingGhNames,
19
+ withGhRetry,
20
+ withSpinner,
21
+ } from './setup/gh'
22
+ import {formatDryRunPreview} from './setup/preview'
23
+ import {buildApiKeyValue, cancelAndExit, ensureRepoFormat, promptGenericSecretNames, promptValue} from './setup/prompts'
24
+ import {parseProviders, promptForModel, promptForProviders, PROVIDER_DEFAULTS, type ProviderId} from './setup/providers'
25
+ import {runSmokeTest} from './setup/smoke-test'
26
+ import {
27
+ collectCollisions,
28
+ formatTemplateSummary,
29
+ getHarnessTemplate,
30
+ harnessSchema,
31
+ stripTrailingSlash,
32
+ type Harness,
33
+ type HarnessTemplate,
34
+ } from './setup/templates'
35
+ import {
36
+ assertProxyKeyWorks,
37
+ assertProxyReachable,
38
+ MODEL_ID_RE,
39
+ validateSetupOptions,
40
+ verifyModelsAvailable,
41
+ } from './setup/validation'
42
+ import {checkFroBotWorkflow, formatWorkflowSnippet} from './setup/workflow-analyzer'
43
+
44
+ export {formatDryRunPreview, type DryRunPreviewOptions} from './setup/preview'
45
+ export {validateSetupOptions, verifyModelsAvailable} from './setup/validation'
11
46
 
12
47
  const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
13
- const HTTP_TIMEOUT_MS = 10_000
14
-
15
- const harnessSchema = z.enum(['opencode', 'claude-code', 'generic'])
16
- const ghRepoViewSchema = z.object({
17
- nameWithOwner: z.string(),
18
- viewerPermission: z.string(),
19
- })
20
- const ghNameListSchema = z.array(z.object({name: z.string()}))
21
-
22
- export type Harness = z.infer<typeof harnessSchema>
23
-
24
- const providerIdSchema = z.enum(['anthropic', 'openai'])
25
- export type ProviderId = z.infer<typeof providerIdSchema>
26
-
27
- const MODEL_ID_RE = /^(?:anthropic|openai)\/[a-z\d](?:[a-z\d.\-]*[a-z\d])?$/
28
-
29
- /**
30
- * Parse a comma-separated provider list string into a validated ProviderId array.
31
- * Rejects empty input, unknown providers, and duplicates.
32
- */
33
- export function parseProviders(input: string): ProviderId[] {
34
- const parts = input
35
- .split(',')
36
- .map(p => p.trim())
37
- .filter(Boolean)
38
-
39
- if (parts.length === 0) {
40
- throw new Error('--providers must not be empty. Supported values: anthropic, openai')
41
- }
42
-
43
- const parsed = parts.map(p => {
44
- const result = providerIdSchema.safeParse(p)
45
- if (!result.success) {
46
- throw new Error(`Unknown provider "${p}". Supported values: anthropic, openai`)
47
- }
48
- return result.data
49
- })
50
-
51
- const deduped = new Set(parsed)
52
- if (deduped.size < parsed.length) {
53
- throw new Error(`--providers contains duplicate values: ${parsed.join(',')}`)
54
- }
55
-
56
- return parsed
57
- }
58
-
59
- const PROVIDER_DEFAULTS: Record<ProviderId, string> = {
60
- anthropic: 'anthropic/claude-sonnet-4-6',
61
- openai: 'openai/gpt-5.4-mini',
62
- }
63
-
64
- const CUSTOM_MODEL_SENTINEL = '__custom__'
65
-
66
- /**
67
- * Interactively prompt the user to select one or more providers.
68
- * Anthropic is pre-checked. Empty selection re-prompts.
69
- */
70
- export async function promptForProviders(): Promise<ProviderId[]> {
71
- let providers: ProviderId[] = []
72
-
73
- do {
74
- const result = await multiselect<ProviderId>({
75
- message: 'Select providers to configure',
76
- options: [
77
- {value: 'anthropic', label: 'Anthropic'},
78
- {value: 'openai', label: 'OpenAI'},
79
- ],
80
- initialValues: ['anthropic'],
81
- required: false,
82
- })
83
-
84
- if (isCancel(result)) {
85
- cancelAndExit('Setup cancelled before selecting providers.')
86
- }
87
-
88
- providers = result as ProviderId[]
89
- } while (providers.length === 0)
90
-
91
- return providers
92
- }
93
-
94
- /**
95
- * Interactively prompt the user to select a default model.
96
- * When only one provider is selected, returns that provider's default immediately.
97
- * When multiple providers are selected, shows a select with preset options and a custom entry.
98
- */
99
- export async function promptForModel(providers: ProviderId[]): Promise<string> {
100
- if (providers.length === 1) {
101
- return PROVIDER_DEFAULTS[providers[0] as ProviderId]
102
- }
103
-
104
- const chosen = await select<string>({
105
- message: 'Choose a default model',
106
- options: [
107
- {value: 'openai/gpt-5.4-mini', label: 'openai/gpt-5.4-mini'},
108
- {value: 'anthropic/claude-sonnet-4-6', label: 'anthropic/claude-sonnet-4-6'},
109
- {value: CUSTOM_MODEL_SENTINEL, label: 'Enter custom model ID...'},
110
- ],
111
- })
112
-
113
- if (isCancel(chosen)) {
114
- cancelAndExit('Setup cancelled before selecting a model.')
115
- }
116
-
117
- if (chosen === CUSTOM_MODEL_SENTINEL) {
118
- return promptForCustomModel()
119
- }
120
-
121
- return chosen as string
122
- }
123
-
124
- async function promptForCustomModel(): Promise<string> {
125
- let modelId: string | undefined
126
-
127
- do {
128
- const result = await text({
129
- message: 'Enter a custom model ID (e.g. openai/gpt-5.4-mini)',
130
- placeholder: 'provider/model-name',
131
- validate: value => {
132
- if (!MODEL_ID_RE.test(value ?? '')) {
133
- return 'Model ID must match provider/model-name (lowercase, digits, dots, hyphens only)'
134
- }
135
- return undefined
136
- },
137
- })
138
-
139
- if (isCancel(result)) {
140
- cancelAndExit('Setup cancelled before entering a custom model ID.')
141
- }
142
-
143
- const candidate = result as string
144
- if (MODEL_ID_RE.test(candidate)) {
145
- modelId = candidate
146
- }
147
- // If the mock bypasses clack's internal validate and returns a bad value,
148
- // loop again to re-prompt.
149
- } while (!modelId)
150
-
151
- return modelId
152
- }
153
48
 
154
49
  export interface SetupOptions {
155
50
  key?: string
@@ -161,26 +56,7 @@ export interface SetupOptions {
161
56
  force?: boolean
162
57
  dryRun?: boolean
163
58
  verifySmoke?: boolean
164
- }
165
-
166
- export interface SecretAssignment {
167
- name: string
168
- value: string
169
- }
170
-
171
- export interface VariableAssignment {
172
- name: string
173
- value: string
174
- }
175
-
176
- export interface HarnessTemplate {
177
- secrets: SecretAssignment[]
178
- variables: VariableAssignment[]
179
- }
180
-
181
- interface GenericSecretNames {
182
- apiKeySecretName: string
183
- baseUrlSecretName: string
59
+ ackKeyReuse?: boolean
184
60
  }
185
61
 
186
62
  interface SetupPlan {
@@ -192,749 +68,173 @@ interface SetupPlan {
192
68
  template: HarnessTemplate
193
69
  }
194
70
 
195
- interface CommandResult {
196
- stdout: string
197
- stderr: string
198
- exitCode: number
199
- }
200
-
201
- function stripTrailingSlash(value: string): string {
202
- return value.endsWith('/') ? value.slice(0, -1) : value
71
+ // Internal: test-only DI surface. Not part of the published API.
72
+ export interface RunSetupDeps {
73
+ interactive?: boolean
74
+ baseUrl?: string
75
+ ctx?: ActionCtx
76
+ resolveManagementKey?: typeof resolveManagementKey
77
+ gh?: {
78
+ assertGhInstalled: typeof assertGhInstalled
79
+ assertGhAuthenticated: typeof assertGhAuthenticated
80
+ assertRepoAccess: typeof assertRepoAccess
81
+ listExistingGhNames: typeof listExistingGhNames
82
+ createManagementApiKey: typeof createManagementApiKey
83
+ deleteManagementApiKey: typeof deleteManagementApiKey
84
+ applyGhValue: typeof applyGhValue
85
+ withGhRetry: typeof withGhRetry
86
+ }
87
+ prompts?: {
88
+ promptValue: typeof promptValue
89
+ confirm: typeof confirm
90
+ intro: typeof intro
91
+ note: typeof note
92
+ outro: typeof outro
93
+ promptForProviders?: typeof promptForProviders
94
+ promptForModel?: typeof promptForModel
95
+ }
96
+ smoke?: {
97
+ runSmokeTest: typeof runSmokeTest
98
+ }
99
+ validation?: {
100
+ assertProxyReachable: typeof assertProxyReachable
101
+ assertProxyKeyWorks: typeof assertProxyKeyWorks
102
+ verifyModelsAvailable: typeof verifyModelsAvailable
103
+ }
203
104
  }
204
105
 
205
106
  function resolveBaseUrl(input?: string): string {
206
107
  return stripTrailingSlash(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
207
108
  }
208
109
 
209
- export function validateSetupOptions(options: SetupOptions, isInteractive: boolean): void {
210
- if (isInteractive) {
211
- return
212
- }
213
-
214
- // Validate providers/model first (independent of key/repo/harness)
215
- if (options.providers) {
216
- const providers = parseProviders(options.providers)
217
-
218
- if (providers.length > 1 && !options.model) {
219
- throw new Error('Pass --model <provider/model-id> when selecting multiple providers.')
220
- }
221
-
222
- if (options.model) {
223
- const slashIndex = options.model.indexOf('/')
224
- const prefix = slashIndex === -1 ? options.model : options.model.slice(0, slashIndex)
225
- if (!providers.includes(prefix as ProviderId)) {
226
- throw new Error(
227
- `Model prefix ${prefix} does not match selected providers (${providers.join(', ')}). Valid prefixes: ${providers.join(', ')}/`,
228
- )
229
- }
230
- }
231
- }
232
-
233
- if (!options.dryRun && !options.key) {
234
- throw new Error('--key is required when stdin is not a TTY. Provide an existing CLIProxyAPI key value.')
235
- }
236
-
237
- if (!options.repo) {
238
- throw new Error('--repo is required when stdin is not a TTY. Provide the target GitHub repository as owner/repo.')
239
- }
240
-
241
- if (!options.harness) {
242
- throw new Error('--harness is required when stdin is not a TTY. Choose opencode or claude-code.')
243
- }
244
-
245
- if (options.harness === 'generic') {
246
- throw new Error('--harness generic is interactive-only because it requires custom secret names.')
247
- }
248
- }
249
-
250
- export function getHarnessTemplate(
251
- harness: Harness,
252
- values: {
253
- keyValue?: string
254
- baseUrl?: string
255
- genericSecretNames?: GenericSecretNames
256
- providers?: ProviderId[]
257
- model?: string
258
- } = {},
259
- ): HarnessTemplate {
260
- const keyValue = values.keyValue ?? 'sk-placeholder'
261
- const baseUrl = stripTrailingSlash(values.baseUrl ?? DEFAULT_CLIPROXY_URL)
262
-
263
- if (harness === 'opencode') {
264
- // Normalize provider list: default to anthropic-only, always sort anthropic first
265
- const rawProviders = values.providers ?? ['anthropic']
266
- // Stable ordering: anthropic always before openai regardless of input order
267
- const PROVIDER_ORDER: ProviderId[] = ['anthropic', 'openai']
268
- const providers = PROVIDER_ORDER.filter(p => rawProviders.includes(p))
269
-
270
- // Resolve model
271
- let model: string
272
- if (values.model) {
273
- model = values.model
274
- } else if (providers.length === 1) {
275
- model = PROVIDER_DEFAULTS[providers[0] as ProviderId]
276
- } else {
277
- throw new Error('model required when multiple providers selected')
278
- }
279
-
280
- // OMO_PROVIDERS token map
281
- const OMO_TOKEN: Record<ProviderId, string> = {
282
- anthropic: 'claude-max20',
283
- openai: 'openai',
284
- }
285
-
286
- // Build auth JSON object (anthropic-first insertion order)
287
- const authObj: Record<string, {type: string; key: string}> = {}
288
- for (const p of providers) {
289
- authObj[p] = {type: 'api', key: keyValue}
290
- }
291
-
292
- // Build config JSON object (anthropic-first insertion order)
293
- const providerConfig: Record<string, {options: {baseURL: string}}> = {}
294
- for (const p of providers) {
295
- providerConfig[p] = {options: {baseURL: `${baseUrl}/v1`}}
296
- }
297
-
298
- const omoProviders = providers.map(p => OMO_TOKEN[p]).join(',')
299
-
300
- return {
301
- secrets: [
302
- {
303
- name: 'OPENCODE_AUTH_JSON',
304
- value: JSON.stringify(authObj),
305
- },
306
- {
307
- name: 'OPENCODE_CONFIG',
308
- value: JSON.stringify({provider: providerConfig}),
309
- },
310
- {
311
- name: 'OMO_PROVIDERS',
312
- value: omoProviders,
313
- },
314
- ],
315
- variables: [
316
- {
317
- name: 'FRO_BOT_MODEL',
318
- value: model,
319
- },
320
- ],
321
- }
322
- }
323
-
324
- if (harness === 'claude-code') {
325
- return {
326
- secrets: [
327
- {
328
- name: 'ANTHROPIC_API_KEY',
329
- value: keyValue,
330
- },
331
- ],
332
- variables: [],
333
- }
334
- }
335
-
336
- if (!values.genericSecretNames) {
337
- throw new Error('Generic harness requires custom secret names.')
338
- }
339
-
340
- return {
341
- secrets: [
342
- {name: values.genericSecretNames.apiKeySecretName, value: keyValue},
343
- {name: values.genericSecretNames.baseUrlSecretName, value: `${baseUrl}/v1`},
344
- ],
345
- variables: [],
346
- }
347
- }
348
-
349
- function extractErrorMessage(error: unknown): string {
350
- return error instanceof Error ? error.message : String(error)
351
- }
352
-
353
- function ensureRepoFormat(value: string): string {
354
- const trimmed = value.trim()
355
- if (!/^[^/\s]+\/[^/\s]+$/.test(trimmed)) {
356
- throw new Error('Repository must be in owner/repo format.')
357
- }
358
- return trimmed
359
- }
360
-
361
- function ensureSecretName(value: string, label: string): string {
362
- const trimmed = value.trim()
363
- if (!/^[A-Z][A-Z0-9_]*$/.test(trimmed)) {
364
- throw new Error(`${label} must be SCREAMING_SNAKE_CASE.`)
365
- }
366
- return trimmed
367
- }
368
-
369
- function cancelAndExit(message = 'Setup cancelled.'): never {
370
- cancel(message)
371
- process.exit(0)
372
- }
373
-
374
- async function promptValue<T extends string | boolean>(
375
- promise: Promise<T | symbol>,
376
- cancelMessage?: string,
377
- ): Promise<T> {
378
- const value = await promise
379
- if (isCancel(value)) {
380
- cancelAndExit(cancelMessage)
381
- }
382
- return value
383
- }
384
-
385
- async function withSpinner<T>(message: string, run: (spinnerInstance: SpinnerResult) => Promise<T>): Promise<T> {
386
- const spinnerInstance = spinner()
387
- spinnerInstance.start(message)
388
-
389
- try {
390
- const result = await run(spinnerInstance)
391
- spinnerInstance.stop(message)
392
- return result
393
- } catch (error) {
394
- spinnerInstance.error(`${message} failed`)
395
- throw error
396
- }
397
- }
398
-
399
- async function runCommand(command: string, args: string[]): Promise<CommandResult> {
400
- const child = Bun.spawn([command, ...args], {
401
- stdout: 'pipe',
402
- stderr: 'pipe',
403
- env: process.env,
404
- })
405
-
406
- const [stdout, stderr, exitCode] = await Promise.all([
407
- new Response(child.stdout).text(),
408
- new Response(child.stderr).text(),
409
- child.exited,
410
- ])
411
-
412
- return {stdout, stderr, exitCode}
413
- }
414
-
415
- async function runGh(args: string[]): Promise<CommandResult> {
416
- return runCommand('gh', args)
417
- }
418
-
419
- export function isGhRateLimitError(text: string): boolean {
420
- return /rate limit/i.test(text)
421
- }
422
-
423
110
  /**
424
- * Query the GitHub API rate limit reset time. The `rate_limit` endpoint is
425
- * exempt from rate limiting itself, so this should succeed even when the
426
- * primary GraphQL limit is exhausted. Returns a formatted local time string
427
- * or a fallback phrase when the endpoint is unreachable.
111
+ * Emit a warning without ever throwing. Used inside verifyWrittenNamesVisible so that a
112
+ * failure in warning emission itself cannot escape to the outer write catch and wrongly
113
+ * roll back a key whose secrets are already written.
428
114
  */
429
- async function queryRateLimitReset(): Promise<string> {
115
+ function safeWarn(message: string): void {
430
116
  try {
431
- const result = await runGh(['api', 'rate_limit'])
432
- if (result.exitCode === 0) {
433
- const parsed = JSON.parse(result.stdout) as {
434
- resources?: {graphql?: {reset?: number}; core?: {reset?: number}}
435
- }
436
- const reset = parsed.resources?.graphql?.reset ?? parsed.resources?.core?.reset
437
- if (reset) {
438
- return new Date(reset * 1000).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
439
- }
440
- }
117
+ log.warn(message)
441
118
  } catch {
442
- // Fall through to generic phrase
443
- }
444
- return 'an unknown time'
445
- }
446
-
447
- /**
448
- * Run a GitHub API operation wrapped in a spinner, retrying indefinitely on
449
- * rate-limit errors when in interactive mode. In non-interactive mode the
450
- * error is re-thrown with the reset time appended so the caller can surface
451
- * it without prompting.
452
- */
453
- export async function withGhRetry<T>(
454
- label: string,
455
- fn: (spinnerInstance: SpinnerResult) => Promise<T>,
456
- interactive: boolean,
457
- queryReset: () => Promise<string> = queryRateLimitReset,
458
- ): Promise<T> {
459
- for (;;) {
460
- try {
461
- return await withSpinner(label, fn)
462
- } catch (error) {
463
- const message = extractErrorMessage(error)
464
- if (!isGhRateLimitError(message)) {
465
- throw error
466
- }
467
- const reset = await queryReset()
468
- if (!interactive) {
469
- throw new Error(`${message} — GitHub API rate limit resets at ${reset}. Re-run when ready.`)
470
- }
471
- log.warn(`GitHub API rate limit exceeded. Resets at ${reset}.`)
472
- const retry = await promptValue(
473
- confirm({
474
- message: 'Retry this step when ready?',
475
- active: 'retry',
476
- inactive: 'abort',
477
- initialValue: true,
478
- }),
479
- 'Setup aborted after rate limit.',
480
- )
481
- if (!retry) {
482
- cancelAndExit('Setup aborted after GitHub API rate limit.')
483
- }
484
- }
485
- }
486
- }
487
-
488
- async function assertGhInstalled(): Promise<void> {
489
- if (!Bun.which('gh')) {
490
- throw new Error('GitHub CLI is required for cliproxy setup. Install gh first: https://cli.github.com/')
491
- }
492
- }
493
-
494
- async function assertGhAuthenticated(): Promise<void> {
495
- const result = await runGh(['auth', 'status'])
496
- if (result.exitCode !== 0) {
497
- throw new Error(`GitHub CLI is not authenticated. Run "gh auth login" first. ${result.stderr.trim()}`.trim())
498
- }
499
- }
500
-
501
- async function assertRepoAccess(repo: string): Promise<void> {
502
- const result = await runGh(['repo', 'view', repo, '--json', 'nameWithOwner,viewerPermission'])
503
- if (result.exitCode !== 0) {
504
- throw new Error(`Unable to access ${repo}. ${result.stderr.trim()}`.trim())
505
- }
506
-
507
- const parsed = ghRepoViewSchema.parse(JSON.parse(result.stdout))
508
- const writePermissions = new Set(['ADMIN', 'MAINTAIN', 'WRITE'])
509
-
510
- if (!writePermissions.has(parsed.viewerPermission)) {
511
- throw new Error(
512
- `GitHub CLI does not have write access to ${parsed.nameWithOwner}. Current permission: ${parsed.viewerPermission}.`,
513
- )
514
- }
515
- }
516
-
517
- async function listExistingGhNames(repo: string, kind: 'secret' | 'variable'): Promise<string[]> {
518
- const result = await runGh([kind, 'list', '--repo', repo, '--json', 'name'])
519
- if (result.exitCode !== 0) {
520
- throw new Error(`Unable to list existing GitHub ${kind}s for ${repo}. ${result.stderr.trim()}`.trim())
521
- }
522
-
523
- return ghNameListSchema.parse(JSON.parse(result.stdout)).map(entry => entry.name)
119
+ // Warning emission must never escape the post-write readback — a throw here would
120
+ // reach the outer write catch and wrongly roll back a key whose secrets are written.
121
+ }
122
+ }
123
+
124
+ function buildCannotVerifyMessage(repo: string, writtenSecretNames: string[], writtenVariableNames: string[]): string {
125
+ const secretPart =
126
+ writtenSecretNames.length > 0
127
+ ? `gh secret list --repo ${repo} (expect: ${writtenSecretNames.join(', ')})`
128
+ : `gh secret list --repo ${repo}`
129
+ const variablePart =
130
+ writtenVariableNames.length > 0
131
+ ? `gh variable list --repo ${repo} (expect: ${writtenVariableNames.join(', ')})`
132
+ : `gh variable list --repo ${repo}`
133
+ return (
134
+ `Post-write readback: could not verify the written names are visible in ${repo} ` +
135
+ `(the GitHub list call failed). Verify manually: ${secretPart}; ${variablePart}.`
136
+ )
524
137
  }
525
138
 
526
- export type FroBotWorkflowCheck =
527
- | {kind: 'missing'}
528
- | {kind: 'unreachable'; reason: string}
529
- | {kind: 'no-agent-step'}
530
- | {
531
- kind: 'analyzed'
532
- stepsWithGaps: readonly {stepOrdinal: number; missingInputs: readonly string[]}[]
533
- }
534
-
535
- // github-token and prompt are intentionally excluded from this check:
536
- // github-token is harness-agnostic (PAT wiring, not secret-routing) and prompt is
537
- // workflow-defined (the user's prompt body, not a harness default).
538
- //
539
- // NOTE: `enable-omo: true` is NOT a required input.
540
- // For proxy-routed providers configured via OPENCODE_CONFIG.provider.<name>.options.baseURL,
541
- // the fro-bot/agent action honors auth.json directly (regardless of oMo state).
542
- // Source: fro-bot/agent@v0.44.3+ action.yaml lines 99-104; verified by librarian 2026-05-25.
543
- const REQUIRED_OPENCODE_INPUTS = ['auth-json', 'opencode-config', 'omo-providers', 'model'] as const
544
-
545
139
  /**
546
- * Slice the workflow content into one entry per `fro-bot/agent` step. Handles
547
- * both the `- name:\n uses: ...` and `- uses: ...` step shapes. Returns an
548
- * empty array if no fro-bot/agent step is present.
140
+ * After a successful write, re-list secret and variable names and warn if any written name
141
+ * is absent on readback signaling an unreliable token list view that may have bypassed
142
+ * the pre-write safety gates.
549
143
  *
550
- * Step-scoped slicing prevents false-passes where a same-named input key in a
551
- * sibling step (strategy.matrix, custom actions, reusable workflow with:
552
- * blocks) could mask a genuine gap in fro-bot/agent's wiring.
553
- */
554
- function findFroBotAgentStepBodies(content: string): {stepOrdinal: number; body: string}[] {
555
- const bodies: {stepOrdinal: number; body: string}[] = []
556
- const pattern = /^(\s*(?:-\s+)?)uses:\s*fro-bot\/agent@/gm
557
-
558
- for (const match of content.matchAll(pattern)) {
559
- if (match.index === undefined || match[1] === undefined) continue
560
-
561
- const stepBodyIndent = match[1].length
562
- const dashIndent = Math.max(0, stepBodyIndent - 2)
563
- const lines = content.slice(match.index).split('\n')
564
- const stepLines: string[] = [lines[0] ?? '']
565
-
566
- for (let index = 1; index < lines.length; index += 1) {
567
- const line = lines[index] ?? ''
568
- if (!line.trim()) {
569
- stepLines.push(line)
570
- continue
571
- }
572
- const firstNonSpace = line.search(/\S/)
573
- if (firstNonSpace === dashIndent && line.trimStart().startsWith('-')) break
574
- if (firstNonSpace < dashIndent) break
575
- stepLines.push(line)
576
- }
577
-
578
- bodies.push({stepOrdinal: bodies.length + 1, body: stepLines.join('\n')})
579
- }
580
-
581
- return bodies
582
- }
583
-
584
- export function analyzeFroBotWorkflow(workflowContent: string): FroBotWorkflowCheck {
585
- const steps = findFroBotAgentStepBodies(workflowContent)
586
-
587
- if (steps.length === 0) {
588
- return {kind: 'no-agent-step'}
589
- }
590
-
591
- const stepsWithGaps = steps
592
- .map(step => ({
593
- stepOrdinal: step.stepOrdinal,
594
- missingInputs: REQUIRED_OPENCODE_INPUTS.filter(input => {
595
- const inputPattern = new RegExp(String.raw`^\s+${input}:`, 'm')
596
- return !inputPattern.test(step.body)
597
- }),
598
- }))
599
- .filter(step => step.missingInputs.length > 0)
600
-
601
- return {kind: 'analyzed', stepsWithGaps}
602
- }
603
-
604
- /**
605
- * Extracted pure helper: turn a `gh api /repos/.../contents/<file>` result into
606
- * a FroBotWorkflowCheck. Separated from checkFroBotWorkflow so tests can exercise
607
- * the 404-vs-transport-error logic without mocking Bun.spawn.
608
- */
609
- export function interpretGhContentResult(result: CommandResult): FroBotWorkflowCheck {
610
- if (result.exitCode === 0) {
611
- return analyzeFroBotWorkflow(result.stdout)
612
- }
613
-
614
- // gh prints `gh: Not Found (HTTP 404)` on 404; anything else is auth/network/5xx.
615
- if (/HTTP 404/.test(result.stderr)) {
616
- return {kind: 'missing'}
617
- }
618
-
619
- return {
620
- kind: 'unreachable',
621
- reason: result.stderr.trim() || `gh api exited with code ${result.exitCode}`,
622
- }
623
- }
624
-
625
- async function checkFroBotWorkflow(repo: string): Promise<FroBotWorkflowCheck> {
626
- const result = await runGh([
627
- 'api',
628
- '--header',
629
- 'Accept: application/vnd.github.raw',
630
- `/repos/${repo}/contents/.github/workflows/fro-bot.yaml`,
631
- ])
632
-
633
- return interpretGhContentResult(result)
634
- }
635
-
636
- // Snippet uses 10-space indent to match the canonical `with:` block depth
637
- // in marcusrbrown/infra/.github/workflows/fro-bot.yaml, so users can paste
638
- // directly under their step's `with:` key without re-indenting.
639
- export function formatWorkflowSnippet(missingInputs: readonly string[]): string {
640
- /* eslint-disable no-template-curly-in-string -- GitHub Actions expression syntax, not JS template literals */
641
- const inputMap: Record<string, string> = {
642
- 'auth-json': 'auth-json: ${{ secrets.OPENCODE_AUTH_JSON }}',
643
- 'opencode-config': 'opencode-config: ${{ secrets.OPENCODE_CONFIG }}',
644
- 'omo-providers': 'omo-providers: ${{ secrets.OMO_PROVIDERS }}',
645
- model: 'model: ${{ vars.FRO_BOT_MODEL }}',
646
- }
647
- /* eslint-enable no-template-curly-in-string */
648
- return missingInputs.map(input => ` ${inputMap[input]}`).join('\n')
649
- }
650
-
651
- /**
652
- * Pre-mutation validator: probes /v1/models to assert the resolved model is
653
- * available and (when providers includes openai) that at least one OpenAI model
654
- * is present on the proxy.
144
+ * Distinguishes:
145
+ * - Verified mismatch: readback succeeded but a written name is absent (strong signal).
146
+ * - Cannot verify: the readback gh call itself failed (weaker signal).
655
147
  *
656
- * Short-circuits immediately for anthropic-only setups no fetch is made.
657
- * Never echoes the Authorization header in any error message.
148
+ * NEVER throws. The entire body is wrapped in a single try/catch so any failure — including
149
+ * errors during diff computation or warning emission degrades to the cannot-verify warning.
150
+ * A throw here would propagate to the mutationError rollback and wrongly delete a key whose
151
+ * secrets are already written.
658
152
  */
659
- export async function verifyModelsAvailable(
660
- baseUrl: string,
661
- key: string,
662
- providers: ProviderId[],
663
- model: string,
153
+ async function verifyWrittenNamesVisible(
154
+ repo: string,
155
+ writtenSecretNames: string[],
156
+ writtenVariableNames: string[],
157
+ listExistingGhNames: typeof import('./setup/gh').listExistingGhNames,
664
158
  ): Promise<void> {
665
- // Anthropic-only: no fetch needed
666
- if (providers.length === 1 && providers[0] === 'anthropic') {
667
- return
668
- }
669
-
670
- const endpoint = `${baseUrl}/v1/models`
671
- const response = await fetch(endpoint, {
672
- headers: {Authorization: `Bearer ${key}`},
673
- signal: AbortSignal.timeout(10_000),
674
- })
675
-
676
- if (response.status === 401 || response.status === 403) {
677
- throw new Error('Proxy key rejected. Verify with `cliproxy keys list` or rerun setup to create a new one.')
678
- }
679
-
680
- if (!response.ok) {
681
- const rawBody = await response.text()
682
- // Redact any Authorization headers or sk-* token-shaped strings that the server might echo
683
- const redacted = rawBody
684
- .replaceAll(/Bearer\s+[^\s"]+/g, 'Bearer <redacted>')
685
- .replaceAll(/sk-[\w.-]{8,}/g, 'sk-<redacted>')
686
- const excerpt = redacted.slice(0, 200)
687
- throw new Error(`/v1/models returned HTTP ${response.status}: ${excerpt}`)
688
- }
689
-
690
- const json = (await response.json()) as unknown
691
- const data = (json as Record<string, unknown>)?.data
692
-
693
- if (!Array.isArray(data)) {
694
- throw new TypeError('Unexpected response from /v1/models: data is not an array.')
695
- }
696
-
697
- interface ModelEntry {
698
- id: string
699
- owned_by: string
700
- }
701
- const entries = data as ModelEntry[]
702
-
703
- // OpenAI presence check
704
- if (providers.includes('openai')) {
705
- const hasOpenAi = entries.some(e => e.owned_by === 'openai')
706
- if (!hasOpenAi) {
707
- throw new Error('No OpenAI models on proxy — is the Codex token loaded? Try `cliproxy login codex`.')
708
- }
709
- }
710
-
711
- // Model presence check: strip provider prefix to get bare id
712
- const slashIndex = model.indexOf('/')
713
- const bareId = slashIndex === -1 ? model : model.slice(slashIndex + 1)
714
- const providerPrefix = slashIndex >= 0 ? model.slice(0, slashIndex) : undefined
715
-
716
- const modelPresent = entries.some(e => e.id === bareId)
717
- if (!modelPresent) {
718
- // List available ids for the matching provider only
719
- const matchingIds = providerPrefix
720
- ? entries.filter(e => e.owned_by === providerPrefix).map(e => e.id)
721
- : entries.map(e => e.id)
722
- const available = matchingIds.length > 0 ? matchingIds.join(', ') : '(none)'
723
- throw new Error(`Model "${bareId}" not found on proxy. Available ${providerPrefix ?? 'models'}: ${available}`)
724
- }
725
- }
726
-
727
- async function assertProxyReachable(baseUrl: string): Promise<void> {
728
159
  try {
729
- const response = await fetch(baseUrl, {
730
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
731
- })
160
+ let secretReadback: string[]
161
+ let variableReadback: string[]
162
+ let readbackFailed = false
732
163
 
733
- if (!response.ok) {
734
- throw new Error(`Proxy check failed for ${baseUrl}: HTTP ${response.status}. Is the proxy running and reachable?`)
735
- }
736
- } catch (error) {
737
- if (error instanceof Error && error.message.startsWith('Proxy check failed')) {
738
- throw error
164
+ try {
165
+ secretReadback = await listExistingGhNames(repo, 'secret')
166
+ } catch {
167
+ readbackFailed = true
168
+ secretReadback = []
739
169
  }
740
- throw new Error(`Unable to reach proxy at ${baseUrl}: ${extractErrorMessage(error)}`)
741
- }
742
- }
743
170
 
744
- async function assertProxyKeyWorks(baseUrl: string, keyValue: string): Promise<void> {
745
- try {
746
- const response = await fetch(`${baseUrl}/v1/models`, {
747
- headers: {
748
- authorization: `Bearer ${keyValue}`,
749
- },
750
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
751
- })
752
-
753
- if (!response.ok) {
754
- throw new Error(`Proxy key verification failed with HTTP ${response.status}`)
755
- }
756
- } catch (error) {
757
- if (error instanceof Error && error.message.startsWith('Proxy key verification')) {
758
- throw error
171
+ if (readbackFailed) {
172
+ variableReadback = []
173
+ } else {
174
+ try {
175
+ variableReadback = await listExistingGhNames(repo, 'variable')
176
+ } catch {
177
+ readbackFailed = true
178
+ variableReadback = []
179
+ }
759
180
  }
760
- throw new Error(`Unable to verify proxy key at ${baseUrl}: ${extractErrorMessage(error)}`)
761
- }
762
- }
763
-
764
- async function requestJson(endpoint: string, init: RequestInit): Promise<unknown> {
765
- const response = await fetch(endpoint, {
766
- ...init,
767
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
768
- })
769
-
770
- if (!response.ok) {
771
- const body = await response.text()
772
- throw new Error(`${init.method ?? 'GET'} ${endpoint} failed with HTTP ${response.status}: ${body}`)
773
- }
774
-
775
- try {
776
- return await response.json()
777
- } catch {
778
- return null
779
- }
780
- }
781
-
782
- function managementHeaders(key: string): Headers {
783
- const headers = new Headers()
784
- headers.set('x-management-key', key)
785
- headers.set('content-type', 'application/json')
786
- return headers
787
- }
788
-
789
- function buildApiKeyValue(keyName: string): string {
790
- const slug = (
791
- keyName
792
- .trim()
793
- .toLowerCase()
794
- .match(/[a-z0-9]+/g) ?? []
795
- )
796
- .join('-')
797
- .slice(0, 24)
798
- const random = crypto.randomUUID().split('-').join('')
799
- return `sk-${slug || 'cliproxy'}-${random}`
800
- }
801
-
802
- async function createManagementApiKey(baseUrl: string, managementKey: string, keyValue: string): Promise<void> {
803
- const endpoint = `${baseUrl}/v0/management/api-keys`
804
- const currentPayload = await requestJson(endpoint, {
805
- method: 'GET',
806
- headers: managementHeaders(managementKey),
807
- })
808
- const currentKeys = toStringArray(currentPayload)
809
-
810
- if (currentKeys.includes(keyValue)) {
811
- return
812
- }
813
-
814
- await requestJson(endpoint, {
815
- method: 'PUT',
816
- headers: managementHeaders(managementKey),
817
- body: JSON.stringify([...currentKeys, keyValue]),
818
- })
819
- }
820
-
821
- async function deleteManagementApiKey(baseUrl: string, managementKey: string, keyValue: string): Promise<void> {
822
- const endpoint = `${baseUrl}/v0/management/api-keys`
823
- const currentPayload = await requestJson(endpoint, {
824
- method: 'GET',
825
- headers: managementHeaders(managementKey),
826
- })
827
- const currentKeys = toStringArray(currentPayload)
828
- const filtered = currentKeys.filter(k => k !== keyValue)
829
-
830
- if (filtered.length === currentKeys.length) {
831
- return
832
- }
833
-
834
- await requestJson(endpoint, {
835
- method: 'PUT',
836
- headers: managementHeaders(managementKey),
837
- body: JSON.stringify(filtered),
838
- })
839
- }
840
181
 
841
- async function applyGhValue(kind: 'secret' | 'variable', name: string, repo: string, value: string): Promise<void> {
842
- if (kind === 'secret') {
843
- const child = Bun.spawn(['gh', 'secret', 'set', name, '--repo', repo], {
844
- stdin: new Blob([value]).stream(),
845
- stdout: 'pipe',
846
- stderr: 'pipe',
847
- env: process.env,
848
- })
849
-
850
- const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited])
851
-
852
- if (exitCode !== 0) {
853
- throw new Error(`gh secret set ${name} failed: ${stderr.trim()}`.trim())
182
+ if (readbackFailed) {
183
+ safeWarn(buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames))
184
+ return
854
185
  }
855
- return
856
- }
857
186
 
858
- const result = await runGh([kind, 'set', name, '--repo', repo, '--body', value])
859
- if (result.exitCode !== 0) {
860
- throw new Error(`gh ${kind} set ${name} failed: ${result.stderr.trim()}`.trim())
861
- }
862
- }
187
+ const absentSecrets = writtenSecretNames.filter(name => !secretReadback.includes(name))
188
+ const absentVariables = writtenVariableNames.filter(name => !variableReadback.includes(name))
863
189
 
864
- function formatTemplateSummary(template: HarnessTemplate): string {
865
- const secretLines = template.secrets.map(secret => `- secret ${secret.name}`)
866
- const variableLines = template.variables.map(variable => `- variable ${variable.name}`)
867
- return [...secretLines, ...variableLines].join('\n')
868
- }
190
+ if (absentSecrets.length === 0 && absentVariables.length === 0) {
191
+ // Happy path: all written names are visible. Emit nothing.
192
+ return
193
+ }
869
194
 
870
- function collectCollisions(
871
- template: HarnessTemplate,
872
- existingSecrets: string[],
873
- existingVariables: string[],
874
- ): string[] {
875
- const collisions: string[] = []
195
+ const lines: string[] = [
196
+ `Post-write readback: the following written names are not visible in ${repo} — ` +
197
+ `the token's list view may be unreliable and the pre-write safety gates may have been bypassed.`,
198
+ ]
876
199
 
877
- for (const secret of template.secrets) {
878
- if (existingSecrets.includes(secret.name)) {
879
- collisions.push(`secret ${secret.name}`)
200
+ if (absentSecrets.length > 0) {
201
+ lines.push(` Absent secrets: ${absentSecrets.join(', ')}`)
202
+ lines.push(` Verify manually: gh secret list --repo ${repo}`)
880
203
  }
881
- }
882
204
 
883
- for (const variable of template.variables) {
884
- if (existingVariables.includes(variable.name)) {
885
- collisions.push(`variable ${variable.name}`)
205
+ if (absentVariables.length > 0) {
206
+ lines.push(` Absent variables: ${absentVariables.join(', ')}`)
207
+ lines.push(` Verify manually: gh variable list --repo ${repo}`)
886
208
  }
887
- }
888
209
 
889
- return collisions
210
+ safeWarn(lines.join('\n'))
211
+ } catch {
212
+ // Any failure in the entire verification block (readback, diff, or warning emission)
213
+ // degrades to this softer cannot-verify warning. Never re-throw.
214
+ safeWarn(buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames))
215
+ }
890
216
  }
891
217
 
892
- async function promptGenericSecretNames(): Promise<GenericSecretNames> {
893
- const apiKeySecretName = ensureSecretName(
894
- await promptValue(
895
- text({
896
- message: 'Name for the API key secret',
897
- placeholder: 'CLIPROXY_API_KEY',
898
- validate: value => {
899
- try {
900
- ensureSecretName(value ?? '', 'API key secret name')
901
- return undefined
902
- } catch (error) {
903
- return extractErrorMessage(error)
904
- }
905
- },
906
- }),
907
- 'Setup cancelled before choosing the generic API key secret name.',
908
- ),
909
- 'API key secret name',
910
- )
911
-
912
- const baseUrlSecretName = ensureSecretName(
913
- await promptValue(
914
- text({
915
- message: 'Name for the proxy base URL secret',
916
- placeholder: 'CLIPROXY_BASE_URL',
917
- validate: value => {
918
- try {
919
- ensureSecretName(value ?? '', 'Base URL secret name')
920
- return undefined
921
- } catch (error) {
922
- return extractErrorMessage(error)
923
- }
924
- },
925
- }),
926
- 'Setup cancelled before choosing the generic base URL secret name.',
927
- ),
928
- 'Base URL secret name',
929
- )
218
+ function extractErrorMessage(error: unknown): string {
219
+ return error instanceof Error ? error.message : String(error)
220
+ }
930
221
 
931
- return {apiKeySecretName, baseUrlSecretName}
222
+ // Redact a bearer token for display in interactive prompts — never show raw key values.
223
+ // Exported for direct unit testing of the redaction contract. The redacted form is
224
+ // what gets shown in the interactive key-reuse prompt; the raw key must never reach the prompt UI.
225
+ export function redactKey(key: string): string {
226
+ if (key.length < 12) return 'sk-***'
227
+ return `${key.slice(0, 3)}***${key.slice(-4)}`
932
228
  }
933
229
 
934
- async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Promise<SetupPlan> {
230
+ async function buildInteractivePlan(
231
+ options: SetupOptions,
232
+ baseUrl: string,
233
+ promptsImpl: Required<RunSetupDeps>['prompts'],
234
+ ): Promise<SetupPlan> {
935
235
  const createKey = !options.key
936
236
  const keyName = createKey
937
- ? await promptValue(
237
+ ? await promptsImpl.promptValue(
938
238
  text({
939
239
  message: 'Name this new CLIProxyAPI key',
940
240
  placeholder: 'my-repo-ci',
@@ -946,7 +246,7 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
946
246
 
947
247
  const harness =
948
248
  options.harness ??
949
- (await promptValue(
249
+ (await promptsImpl.promptValue(
950
250
  select<Harness>({
951
251
  message: 'Choose the harness to configure',
952
252
  options: [
@@ -961,7 +261,7 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
961
261
  const repo = options.repo
962
262
  ? ensureRepoFormat(options.repo)
963
263
  : ensureRepoFormat(
964
- await promptValue(
264
+ await promptsImpl.promptValue(
965
265
  text({
966
266
  message: 'Target GitHub repository',
967
267
  placeholder: 'owner/repo',
@@ -982,8 +282,10 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
982
282
  let model: string | undefined
983
283
 
984
284
  if (harness === 'opencode') {
985
- providers = await promptForProviders()
986
- model = await promptForModel(providers)
285
+ const doPromptForProviders = promptsImpl.promptForProviders ?? promptForProviders
286
+ const doPromptForModel = promptsImpl.promptForModel ?? promptForModel
287
+ providers = await doPromptForProviders()
288
+ model = await doPromptForModel(providers)
987
289
  }
988
290
 
989
291
  const keyValue = options.key ?? buildApiKeyValue(keyName ?? 'cliproxy')
@@ -1003,50 +305,18 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
1003
305
  * Returns true when the provider list includes anything beyond anthropic-only.
1004
306
  * Anthropic-only repos see no behavior change (G7 invariant).
1005
307
  */
1006
- export function mustConfirmDestructive(providers: ProviderId[]): boolean {
308
+ export function requiresDestructiveProviderChangeConfirmation(providers: ProviderId[]): boolean {
1007
309
  return !(providers.length === 1 && providers[0] === 'anthropic')
1008
310
  }
1009
311
 
1010
- export interface DryRunPreviewOptions {
1011
- repo: string
1012
- harness: Harness
1013
- providers: ProviderId[]
1014
- model: string
1015
- template: HarnessTemplate
1016
- }
1017
-
1018
- /**
1019
- * Format a dry-run preview string. The proxy key value is NEVER included —
1020
- * it is rendered as `<proxy-key>` in all positions.
1021
- */
1022
- export function formatDryRunPreview(opts: DryRunPreviewOptions): string {
1023
- const {repo, harness, providers, model, template} = opts
1024
-
1025
- const lines: string[] = [
1026
- `Dry run: cliproxy setup --harness ${harness}`,
1027
- `Repository: ${repo}`,
1028
- `Providers: ${providers.join(', ')}`,
1029
- `Model: ${model}`,
1030
- 'Planned secrets:',
1031
- ]
1032
-
1033
- for (const secret of template.secrets) {
1034
- const size = new TextEncoder().encode(secret.value).byteLength
1035
- lines.push(` - ${secret.name} (${size} bytes)`)
1036
- }
312
+ // Deprecated: use requiresDestructiveProviderChangeConfirmation. Will be removed in a future major.
313
+ export const mustConfirmDestructive = requiresDestructiveProviderChangeConfirmation
1037
314
 
1038
- lines.push('Planned variables:')
1039
- for (const variable of template.variables) {
1040
- lines.push(` - ${variable.name} = ${variable.value}`)
1041
- }
1042
-
1043
- lines.push('Proxy key (redacted): <proxy-key>')
1044
- lines.push('No mutations will be performed.')
1045
-
1046
- return lines.join('\n')
1047
- }
1048
-
1049
- export async function buildNonInteractivePlan(options: SetupOptions, baseUrl: string): Promise<SetupPlan> {
315
+ export async function buildNonInteractivePlan(
316
+ options: SetupOptions,
317
+ baseUrl: string,
318
+ deps?: Pick<RunSetupDeps, 'validation'>,
319
+ ): Promise<SetupPlan> {
1050
320
  const harness = harnessSchema.parse(options.harness)
1051
321
  const repo = ensureRepoFormat(options.repo ?? '')
1052
322
  const keyValue = options.key ?? ''
@@ -1074,15 +344,18 @@ export async function buildNonInteractivePlan(options: SetupOptions, baseUrl: st
1074
344
  }
1075
345
  }
1076
346
 
1077
- await verifyModelsAvailable(baseUrl, keyValue, providers, model)
347
+ const verifyModels = deps?.validation?.verifyModelsAvailable ?? verifyModelsAvailable
1078
348
 
1079
- // Destructive overwrite gate: non-anthropic-only requires --force in non-interactive mode
1080
- if (mustConfirmDestructive(providers) && !options.force) {
349
+ // Destructive overwrite gate: non-anthropic-only requires --force in non-interactive mode.
350
+ // Check BEFORE verifyModelsAvailable to avoid a network call when the gate will reject anyway.
351
+ if (requiresDestructiveProviderChangeConfirmation(providers) && !options.force) {
1081
352
  throw new Error(
1082
- 'Pass `--force` to confirm overwriting existing OPENCODE_AUTH_JSON/OPENCODE_CONFIG/OMO_PROVIDERS/FRO_BOT_MODEL. Run with `--dry-run` first to preview.',
353
+ `Refusing destructive provider change on ${options.repo ?? ''} without --force. Selected providers ${providers.join(', ')} would overwrite existing GitHub secret values (OPENCODE_AUTH_JSON, OPENCODE_CONFIG, OMO_PROVIDERS, FRO_BOT_MODEL). Note: --force authorizes overwriting these GitHub secret values; it does NOT rotate the underlying CLIProxyAPI proxy bearer token (which is preserved byte-for-byte when --key is supplied).`,
1083
354
  )
1084
355
  }
1085
356
 
357
+ await verifyModels(baseUrl, keyValue, providers, model)
358
+
1086
359
  return {
1087
360
  repo,
1088
361
  harness,
@@ -1092,206 +365,372 @@ export async function buildNonInteractivePlan(options: SetupOptions, baseUrl: st
1092
365
  }
1093
366
  }
1094
367
 
1095
- // ─── Smoke test runner ────────────────────────────────────────────────────────
368
+ // Default real implementations for DI
369
+ const realGh: Required<RunSetupDeps>['gh'] = {
370
+ assertGhInstalled,
371
+ assertGhAuthenticated,
372
+ assertRepoAccess,
373
+ listExistingGhNames,
374
+ createManagementApiKey,
375
+ deleteManagementApiKey,
376
+ applyGhValue,
377
+ withGhRetry,
378
+ }
379
+
380
+ const realPrompts: Required<RunSetupDeps>['prompts'] = {
381
+ promptValue,
382
+ confirm,
383
+ intro,
384
+ note,
385
+ outro,
386
+ promptForProviders,
387
+ promptForModel,
388
+ }
389
+
390
+ const realSmoke: Required<RunSetupDeps>['smoke'] = {
391
+ runSmokeTest,
392
+ }
393
+
394
+ const realValidation: Required<RunSetupDeps>['validation'] = {
395
+ assertProxyReachable,
396
+ assertProxyKeyWorks,
397
+ verifyModelsAvailable,
398
+ }
399
+
400
+ const realCtx: ActionCtx = {
401
+ console: {
402
+ log: (...args: unknown[]) => {
403
+ console.log(...args)
404
+ },
405
+ error: (...args: unknown[]) => {
406
+ console.error(...args)
407
+ },
408
+ },
409
+ process: {
410
+ stdout: {write: (chunk: string) => process.stdout.write(chunk)},
411
+ stderr: {write: (chunk: string) => process.stderr.write(chunk)},
412
+ exit: (code: number) => process.exit(code),
413
+ },
414
+ }
415
+
416
+ // Internal: test-only DI surface. Not part of the published API.
417
+ export async function runSetupCommand(options: SetupOptions, deps: RunSetupDeps = {}): Promise<void> {
418
+ const interactive = deps.interactive ?? Boolean(process.stdin.isTTY)
419
+ const baseUrl = deps.baseUrl ?? resolveBaseUrl()
420
+ // ctxInjected distinguishes MCP-mounted callers (which need ctx.console.error to surface errors
421
+ // through the MCP transport) from bare CLI callers (where cli.ts top-level catch already logs).
422
+ // Without this distinction, CLI users see the error twice.
423
+ const ctxInjected = deps.ctx !== undefined
424
+ const ctx = deps.ctx ?? realCtx
425
+ const gh = deps.gh ?? realGh
426
+ const prompts = deps.prompts ?? realPrompts
427
+ const smoke = deps.smoke ?? realSmoke
428
+ const validation = deps.validation ?? realValidation
429
+ const mgmtKeyResolver = deps.resolveManagementKey ?? resolveManagementKey
430
+
431
+ // --dry-run: short-circuit before validation so it works with no flags.
432
+ // Never blocks on stdin — safe to run anywhere.
433
+ if (options.dryRun) {
434
+ const providers: ProviderId[] = options.providers ? parseProviders(options.providers) : ['anthropic']
435
+ const model = options.model ?? PROVIDER_DEFAULTS[providers[0] as ProviderId]
436
+ const harness = options.harness ?? 'opencode'
437
+ const repo = options.repo ?? '<repo not specified>'
438
+ const keyValue = options.key ?? 'sk-placeholder'
439
+ ctx.console.log(
440
+ formatDryRunPreview({
441
+ repo,
442
+ harness,
443
+ providers,
444
+ model,
445
+ template: getHarnessTemplate(harness, {keyValue, baseUrl, providers, model}),
446
+ }),
447
+ )
448
+ return
449
+ }
450
+
451
+ validateSetupOptions(options, interactive)
1096
452
 
1097
- export type SmokeResult =
1098
- | {kind: 'pass'; message: string; runUrl: string}
1099
- | {kind: 'fail'; message: string; runUrl: string}
1100
- | {kind: 'unverified'; message: string; runUrl?: string}
453
+ if (interactive) {
454
+ prompts.intro('CLIProxyAPI setup wizard')
455
+ }
1101
456
 
1102
- interface GhRunEntry {
1103
- databaseId: number
1104
- status: string
1105
- conclusion: string | null
1106
- url: string
1107
- createdAt: string
1108
- }
457
+ try {
458
+ await withSpinner('Checking GitHub CLI availability', async () => {
459
+ await gh.assertGhInstalled()
460
+ await gh.assertGhAuthenticated()
461
+ })
1109
462
 
1110
- /** Options for testability: override poll delays and trigger time. */
1111
- interface SmokeTestInternals {
1112
- /** Override per-poll delay in ms (default: real backoff schedule). */
1113
- _testDelayMs?: number
1114
- /** Override the trigger timestamp used for createdAt heuristic. */
1115
- _testTriggerTime?: Date
1116
- }
463
+ await withSpinner('Checking CLIProxyAPI reachability', async () => {
464
+ await validation.assertProxyReachable(baseUrl)
465
+ })
1117
466
 
1118
- /**
1119
- * Run an optional post-mutation smoke test by triggering `fro-bot.yaml` and
1120
- * polling for completion. Returns a non-blocking SmokeResult — never throws.
1121
- *
1122
- * Race-safe: captures the highest existing run ID before triggering, then
1123
- * filters poll results to runs with databaseId > baselineId. When no prior
1124
- * runs exist (baselineId=null), falls back to createdAt > triggerTime.
1125
- *
1126
- * Known edge case: if a concurrent contributor's run appears before ours,
1127
- * we pick the highest databaseId above baseline — this may misattribute
1128
- * the concurrent run as ours. This is the best heuristic available without
1129
- * a run-specific correlation ID from `gh workflow run`.
1130
- */
1131
- export async function runSmokeTest(
1132
- repo: string,
1133
- _model: string,
1134
- internals: SmokeTestInternals = {},
1135
- ): Promise<SmokeResult> {
1136
- const BACKOFF_MS = [5_000, 15_000, 30_000, 60_000, 60_000]
1137
- const delayFn = async (ms: number): Promise<void> => {
1138
- if (internals._testDelayMs !== undefined) {
1139
- if (internals._testDelayMs > 0) {
1140
- await new Promise(resolve => setTimeout(resolve, internals._testDelayMs))
1141
- }
1142
- return
467
+ const plan = interactive
468
+ ? await buildInteractivePlan(options, baseUrl, prompts)
469
+ : await buildNonInteractivePlan(options, baseUrl, {validation})
470
+
471
+ if (plan.createKey) {
472
+ mgmtKeyResolver()
1143
473
  }
1144
- await new Promise(resolve => setTimeout(resolve, ms))
1145
- }
1146
474
 
1147
- const repoUrl = `https://github.com/${repo}`
475
+ await gh.withGhRetry(
476
+ `Checking GitHub access for ${plan.repo}`,
477
+ async () => {
478
+ await gh.assertRepoAccess(plan.repo)
479
+ },
480
+ interactive,
481
+ )
1148
482
 
1149
- // ── Step 1: Capture baseline run ID ──────────────────────────────────────
1150
- let baselineId: number | null = null
1151
- try {
1152
- const baselineChild = Bun.spawn(
1153
- ['gh', 'run', 'list', '--workflow=fro-bot.yaml', '--repo', repo, '--limit', '1', '--json', 'databaseId'],
1154
- {stdout: 'pipe', stderr: 'pipe', env: process.env},
483
+ if (options.key) {
484
+ log.info('Using the provided API key value directly. No new CLIProxyAPI key will be created.')
485
+ }
486
+
487
+ if (interactive) {
488
+ prompts.note(
489
+ [
490
+ `Proxy: ${baseUrl}`,
491
+ `Repository: ${plan.repo}`,
492
+ `Harness: ${plan.harness}`,
493
+ plan.createKey ? `New key name: ${plan.keyName}` : 'Using existing key value',
494
+ 'GitHub values to write:',
495
+ formatTemplateSummary(plan.template),
496
+ ].join('\n'),
497
+ 'Setup summary',
498
+ )
499
+
500
+ const shouldContinue = await prompts.promptValue(
501
+ prompts.confirm({
502
+ message: 'Proceed with GitHub secret and variable updates?',
503
+ active: 'yes',
504
+ inactive: 'no',
505
+ initialValue: true,
506
+ }),
507
+ 'Setup cancelled before applying GitHub values.',
508
+ )
509
+
510
+ if (!shouldContinue) {
511
+ cancelAndExit('No changes applied.')
512
+ }
513
+ }
514
+
515
+ const [existingSecrets, existingVariables] = await gh.withGhRetry(
516
+ 'Checking existing GitHub secrets and variables',
517
+ async () =>
518
+ Promise.all([gh.listExistingGhNames(plan.repo, 'secret'), gh.listExistingGhNames(plan.repo, 'variable')]),
519
+ interactive,
1155
520
  )
1156
- const [baselineStdout, , baselineExit] = await Promise.all([
1157
- new Response(baselineChild.stdout).text(),
1158
- new Response(baselineChild.stderr).text(),
1159
- baselineChild.exited,
1160
- ])
1161
- if (baselineExit === 0) {
1162
- const parsed = JSON.parse(baselineStdout) as {databaseId: number}[]
1163
- if (parsed.length > 0 && parsed[0]) {
1164
- baselineId = parsed[0].databaseId
521
+
522
+ // Key-reuse acknowledgment guard: bearer token must not appear in prompt text
523
+ if (options.key && existingSecrets.includes('OPENCODE_AUTH_JSON')) {
524
+ if (interactive) {
525
+ const proceed = await prompts.promptValue(
526
+ prompts.confirm({
527
+ message: `You supplied --key ${redactKey(options.key)}. Verify it matches the bearer token inside the existing OPENCODE_AUTH_JSON on ${plan.repo}. Continue?`,
528
+ active: 'yes',
529
+ inactive: 'no',
530
+ initialValue: false,
531
+ }),
532
+ 'Setup cancelled. Run with --ack-key-reuse to bypass interactive confirmation.',
533
+ )
534
+ if (!proceed) {
535
+ cancelAndExit('Setup cancelled. Run with --ack-key-reuse to bypass interactive confirmation.')
536
+ }
537
+ } else if (!options.ackKeyReuse) {
538
+ throw new Error(
539
+ `Refusing key-reuse without explicit acknowledgment. Pass --ack-key-reuse to confirm that --key matches the bearer token inside the existing OPENCODE_AUTH_JSON on ${plan.repo}. (The CLI cannot verify this because GitHub secrets are write-only.)`,
540
+ )
1165
541
  }
1166
542
  }
1167
- // If baseline call fails, baselineId stays null — we'll use createdAt heuristic
1168
- } catch {
1169
- // Network/parse error — continue with null baseline
1170
- }
1171
543
 
1172
- // ── Step 2: Trigger the workflow ──────────────────────────────────────────
1173
- const triggerTime = internals._testTriggerTime ?? new Date()
544
+ const collisions = collectCollisions(plan.template, existingSecrets, existingVariables)
1174
545
 
1175
- const triggerChild = Bun.spawn(
1176
- ['gh', 'workflow', 'run', 'fro-bot.yaml', '--repo', repo, '-f', 'prompt=reply with exactly: ack'],
1177
- {stdout: 'pipe', stderr: 'pipe', env: process.env},
1178
- )
1179
- const [, triggerStderr, triggerExit] = await Promise.all([
1180
- new Response(triggerChild.stdout).text(),
1181
- new Response(triggerChild.stderr).text(),
1182
- triggerChild.exited,
1183
- ])
1184
-
1185
- if (triggerExit !== 0) {
1186
- const redacted = triggerStderr.slice(0, 200)
1187
- return {kind: 'unverified', message: `gh workflow run failed: ${redacted}`}
1188
- }
546
+ if (collisions.length > 0) {
547
+ if (!interactive && !options.force) {
548
+ throw new Error(
549
+ `Refusing to overwrite existing GitHub values in ${plan.repo}: ${collisions.join(', ')}. Pass --force to confirm. Note: --force only authorizes overwriting these GitHub secret values; it does NOT rotate the underlying CLIProxyAPI proxy bearer token (which is preserved byte-for-byte when --key is supplied).`,
550
+ )
551
+ }
1189
552
 
1190
- // ── Step 3: Poll for the new run ──────────────────────────────────────────
1191
- let latestMatchedRun: GhRunEntry | undefined
553
+ if (!interactive && options.force) {
554
+ log.warn(
555
+ `Overwriting existing GitHub values: ${collisions.join(', ')}. ` +
556
+ `Concurrent setup runs against the same repo are not coordinated and resolve last-write-wins — don't run setup against this repo from two places at once.`,
557
+ )
558
+ // proceed
559
+ }
1192
560
 
1193
- for (const BACKOFF_M of BACKOFF_MS) {
1194
- await delayFn(BACKOFF_M ?? 60_000)
561
+ if (interactive) {
562
+ log.warn(`Existing GitHub values will be overwritten: ${collisions.join(', ')}`)
563
+ const overwrite = await prompts.promptValue(
564
+ prompts.confirm({
565
+ message: 'Overwrite the existing GitHub values?',
566
+ active: 'overwrite',
567
+ inactive: 'cancel',
568
+ initialValue: false,
569
+ }),
570
+ 'Setup cancelled instead of overwriting existing values.',
571
+ )
1195
572
 
1196
- let pollRuns: GhRunEntry[] = []
1197
- try {
1198
- const pollChild = Bun.spawn(
1199
- [
1200
- 'gh',
1201
- 'run',
1202
- 'list',
1203
- '--workflow=fro-bot.yaml',
1204
- '--repo',
1205
- repo,
1206
- '--limit',
1207
- '5',
1208
- '--json',
1209
- 'databaseId,status,conclusion,url,createdAt',
1210
- ],
1211
- {stdout: 'pipe', stderr: 'pipe', env: process.env},
1212
- )
1213
- const [pollStdout, , pollExit] = await Promise.all([
1214
- new Response(pollChild.stdout).text(),
1215
- new Response(pollChild.stderr).text(),
1216
- pollChild.exited,
1217
- ])
1218
- if (pollExit === 0) {
1219
- pollRuns = JSON.parse(pollStdout) as GhRunEntry[]
573
+ if (!overwrite) {
574
+ cancelAndExit('Existing GitHub values left unchanged.')
575
+ }
1220
576
  }
1221
- } catch {
1222
- // Parse/network error — retry on next poll
1223
- continue
1224
577
  }
1225
578
 
1226
- // Filter to runs triggered after our baseline
1227
- const candidates = pollRuns.filter(run => {
1228
- if (baselineId !== null) {
1229
- return run.databaseId > baselineId
1230
- }
1231
- // No baseline: use createdAt heuristic
1232
- return new Date(run.createdAt) > triggerTime
1233
- })
579
+ let keyCreatedByThisRun = false
580
+ const managementKey = plan.createKey ? mgmtKeyResolver() : undefined
1234
581
 
1235
- if (candidates.length === 0) {
1236
- // Our run not visible yet keep polling
1237
- continue
582
+ if (plan.createKey && managementKey) {
583
+ await withSpinner('Creating a new CLIProxyAPI key', async () => {
584
+ await gh.createManagementApiKey(baseUrl, managementKey, plan.keyValue)
585
+ keyCreatedByThisRun = true
586
+ })
1238
587
  }
1239
588
 
1240
- // Pick the highest databaseId from candidates (most likely ours)
1241
- const matched = candidates.reduce((best, run) => (run.databaseId > best.databaseId ? run : best))
1242
- latestMatchedRun = matched
589
+ try {
590
+ await gh.withGhRetry(
591
+ 'Writing GitHub secrets and variables',
592
+ async spinnerInstance => {
593
+ for (const secret of plan.template.secrets) {
594
+ spinnerInstance.message(`Setting secret ${secret.name}`)
595
+ await gh.applyGhValue('secret', secret.name, plan.repo, secret.value)
596
+ }
597
+
598
+ for (const variable of plan.template.variables) {
599
+ spinnerInstance.message(`Setting variable ${variable.name}`)
600
+ await gh.applyGhValue('variable', variable.name, plan.repo, variable.value)
601
+ }
602
+ },
603
+ interactive,
604
+ )
1243
605
 
1244
- const {status, conclusion, url: runUrl} = matched
606
+ await verifyWrittenNamesVisible(
607
+ plan.repo,
608
+ plan.template.secrets.map(s => s.name),
609
+ plan.template.variables.map(v => v.name),
610
+ gh.listExistingGhNames,
611
+ )
1245
612
 
1246
- // Environment approval gate
1247
- if (status === 'waiting' || (status === 'pending' && /approval/i.test(conclusion ?? ''))) {
1248
- return {kind: 'unverified', message: `Workflow requires environment approval at ${runUrl}`, runUrl}
1249
- }
613
+ await withSpinner('Verifying the new key through the proxy', async () => {
614
+ await validation.assertProxyKeyWorks(baseUrl, plan.keyValue)
615
+ })
1250
616
 
1251
- if (status === 'completed') {
1252
- if (conclusion === 'success') {
1253
- // Best-effort log grep for "ack"
1254
- let logNote = ''
1255
- try {
1256
- const logChild = Bun.spawn(['gh', 'run', 'view', String(matched.databaseId), '--log', '--repo', repo], {
1257
- stdout: 'pipe',
1258
- stderr: 'pipe',
1259
- env: process.env,
1260
- })
1261
- const [logStdout, , logExit] = await Promise.all([
1262
- new Response(logChild.stdout).text(),
1263
- new Response(logChild.stderr).text(),
1264
- logChild.exited,
1265
- ])
1266
- if (logExit !== 0) {
1267
- logNote = ' (log fetch failed, but run conclusion is success)'
1268
- } else if (!/\back\b/i.test(logStdout)) {
1269
- logNote = ' (log fetch succeeded but "ack" not found in output)'
617
+ if (plan.harness === 'opencode') {
618
+ const workflow = await gh.withGhRetry(
619
+ `Checking ${plan.repo} fro-bot.yaml wiring`,
620
+ async () => {
621
+ return checkFroBotWorkflow(plan.repo)
622
+ },
623
+ interactive,
624
+ )
625
+
626
+ switch (workflow.kind) {
627
+ case 'missing': {
628
+ log.warn(
629
+ `No .github/workflows/fro-bot.yaml found in ${plan.repo}. The secrets and variables are set, but Fro Bot won't run until the workflow exists and passes them as inputs. See marcusrbrown/infra/.github/workflows/fro-bot.yaml for a reference.`,
630
+ )
631
+ break
632
+ }
633
+ case 'unreachable': {
634
+ log.warn(
635
+ `Could not check .github/workflows/fro-bot.yaml in ${plan.repo}: ${workflow.reason}. The secrets and variables are set, but the workflow wiring was not verified. Re-run 'infra cliproxy setup' later to confirm, or inspect the file directly.`,
636
+ )
637
+ break
638
+ }
639
+ case 'no-agent-step': {
640
+ log.warn(
641
+ `${plan.repo} .github/workflows/fro-bot.yaml exists but has no 'fro-bot/agent' step. Add one that passes the secrets and variables just written. See marcusrbrown/infra/.github/workflows/fro-bot.yaml for a reference.`,
642
+ )
643
+ break
644
+ }
645
+ case 'analyzed': {
646
+ for (const step of workflow.stepsWithGaps) {
647
+ const missing = [...step.missingInputs]
648
+ log.warn(
649
+ [
650
+ `${plan.repo} .github/workflows/fro-bot.yaml fro-bot/agent step #${step.stepOrdinal} is missing ${missing.length} required input${
651
+ missing.length > 1 ? 's' : ''
652
+ } (${missing.join(', ')}).`,
653
+ `Without ${missing.includes('opencode-config') ? 'opencode-config, the baseURL override is ignored and Fro Bot hits api.anthropic.com with the proxy key, which fails with 401' : 'these, the secrets you just wrote will not reach OpenCode'}.`,
654
+ '',
655
+ `Add under the 'with:' block of the 'fro-bot/agent' step:`,
656
+ formatWorkflowSnippet(missing),
657
+ ].join('\n'),
658
+ )
659
+ }
660
+ break
661
+ }
662
+ default: {
663
+ const _exhaustive: never = workflow
664
+ throw new Error(`Unhandled FroBotWorkflowCheck kind: ${JSON.stringify(_exhaustive)}`)
1270
665
  }
666
+ }
667
+ }
668
+ } catch (mutationError) {
669
+ if (keyCreatedByThisRun && managementKey) {
670
+ try {
671
+ await gh.deleteManagementApiKey(baseUrl, managementKey, plan.keyValue)
672
+ log.warn('Rolled back the newly created CLIProxyAPI key after failure.')
1271
673
  } catch {
1272
- logNote = ' (log fetch failed, but run conclusion is success)'
674
+ log.warn(
675
+ 'Failed to roll back the newly created CLIProxyAPI key. Remove it manually via: infra cliproxy keys remove',
676
+ )
1273
677
  }
1274
- return {kind: 'pass', message: `Smoke test passed${logNote}`, runUrl}
1275
678
  }
1276
-
1277
- return {kind: 'fail', message: `Run completed with conclusion=${conclusion ?? 'unknown'}`, runUrl}
679
+ throw mutationError
1278
680
  }
1279
681
 
1280
- // Still in progress (queued, in_progress) — continue polling
1281
- }
682
+ if (interactive) {
683
+ prompts.outro(`Setup complete for ${plan.repo}. The ${plan.harness} harness can now use ${baseUrl}/v1.`)
684
+ } else {
685
+ log.success(`Setup complete for ${plan.repo}.`)
686
+ }
687
+
688
+ // ── Smoke test (opt-in, non-blocking) ──────────────────────────────
689
+ if (options.verifySmoke) {
690
+ const smokeResult = await withSpinner('Running smoke test', async () =>
691
+ smoke.runSmokeTest(plan.repo, plan.template.variables.find(v => v.name === 'FRO_BOT_MODEL')?.value ?? ''),
692
+ ).catch(async error => {
693
+ // withSpinner re-throws; catch here so smoke test never gates setup
694
+ return {
695
+ kind: 'unverified' as const,
696
+ message: `Smoke test error: ${extractErrorMessage(error)}`,
697
+ runUrl: undefined,
698
+ }
699
+ })
1282
700
 
1283
- // All polls exhausted
1284
- if (latestMatchedRun) {
1285
- return {
1286
- kind: 'unverified',
1287
- message: `Smoke test did not complete in 5 minutes; check ${latestMatchedRun.url}`,
1288
- runUrl: latestMatchedRun.url,
1289
- }
1290
- }
701
+ // Machine-parseable hook for MCP/agent consumers
702
+ ctx.console.log(`[smoke-test] kind=${smokeResult.kind}`)
1291
703
 
1292
- return {
1293
- kind: 'unverified',
1294
- message: `Smoke test trigger not yet visible; check ${repoUrl}/actions`,
704
+ switch (smokeResult.kind) {
705
+ case 'pass': {
706
+ log.success(`✓ ${smokeResult.message}${smokeResult.runUrl ? ` ${smokeResult.runUrl}` : ''}`)
707
+ break
708
+ }
709
+ case 'fail': {
710
+ log.warn(`✗ ${smokeResult.message}${smokeResult.runUrl ? ` — ${smokeResult.runUrl}` : ''}`)
711
+ break
712
+ }
713
+ case 'unverified': {
714
+ log.warn(`⚠ ${smokeResult.message}${smokeResult.runUrl ? ` — ${smokeResult.runUrl}` : ''}`)
715
+ break
716
+ }
717
+ default: {
718
+ const _exhaustive: never = smokeResult
719
+ throw new Error(`Unhandled SmokeResult kind: ${JSON.stringify(_exhaustive)}`)
720
+ }
721
+ }
722
+ }
723
+ } catch (error) {
724
+ const message = extractErrorMessage(error)
725
+ // MCP-mounted callers need ctx.console.error to surface the message through the MCP transport.
726
+ // Bare CLI callers leave it to cli.ts's top-level catch — emitting here too would double-log.
727
+ if (ctxInjected) {
728
+ ctx.console.error(message)
729
+ }
730
+ if (interactive) {
731
+ cancel(message)
732
+ }
733
+ throw error
1295
734
  }
1296
735
  }
1297
736
 
@@ -1324,7 +763,7 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
1324
763
  z
1325
764
  .string()
1326
765
  .describe(
1327
- 'Comma-separated list of providers to enable. Supported values: anthropic, openai. Example: --providers anthropic,openai',
766
+ 'Comma-separated list of providers to enable. Default: anthropic. Supported values: anthropic, openai. Example: --providers anthropic,openai',
1328
767
  ),
1329
768
  )
1330
769
  .option(
@@ -1333,7 +772,7 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
1333
772
  .string()
1334
773
  .regex(MODEL_ID_RE)
1335
774
  .describe(
1336
- 'Override the default model. Must be provider-prefixed and lowercase. Examples: anthropic/claude-sonnet-4-6, openai/gpt-4o',
775
+ 'Override the default model. Must be provider-prefixed and lowercase. Required when multiple providers selected. Examples: anthropic/claude-sonnet-4-6, openai/gpt-4o',
1337
776
  ),
1338
777
  )
1339
778
  .option(
@@ -1345,275 +784,26 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
1345
784
  '--verify-smoke',
1346
785
  z.boolean().optional().describe('Run a smoke test against the proxy after setup completes.'),
1347
786
  )
787
+ .option(
788
+ '--ack-key-reuse',
789
+ z
790
+ .boolean()
791
+ .default(false)
792
+ .describe(
793
+ 'Acknowledge that --key matches the bearer token inside the existing OPENCODE_AUTH_JSON. Required in non-interactive mode when --key is supplied for a repo with existing OPENCODE_AUTH_JSON.',
794
+ ),
795
+ )
796
+ .example('# Preview planned actions without applying any changes (no flags required)')
797
+ .example('infra cliproxy setup --dry-run')
1348
798
  .example('# Run the interactive onboarding wizard')
1349
799
  .example('infra cliproxy setup')
1350
- .example('# Run non-interactively with an existing key')
1351
- .example('infra cliproxy setup --key sk-test --repo owner/repo --harness opencode')
1352
- .example('# Enable both providers non-interactively')
1353
- .example('infra cliproxy setup --key sk-test --repo owner/repo --harness opencode --providers anthropic,openai')
1354
- .action(async options => {
1355
- const interactive = Boolean(process.stdin.isTTY)
1356
- const baseUrl = resolveBaseUrl()
1357
-
1358
- validateSetupOptions(options, interactive)
1359
-
1360
- if (interactive) {
1361
- intro('CLIProxyAPI setup wizard')
1362
- }
1363
-
1364
- try {
1365
- if (!options.dryRun) {
1366
- await withSpinner('Checking GitHub CLI availability', async () => {
1367
- await assertGhInstalled()
1368
- await assertGhAuthenticated()
1369
- })
1370
-
1371
- await withSpinner('Checking CLIProxyAPI reachability', async () => {
1372
- await assertProxyReachable(baseUrl)
1373
- })
1374
- }
1375
-
1376
- const plan = interactive
1377
- ? await buildInteractivePlan(options, baseUrl)
1378
- : await buildNonInteractivePlan(options, baseUrl)
1379
-
1380
- if (options.dryRun) {
1381
- const providers: ProviderId[] = options.providers ? parseProviders(options.providers) : ['anthropic']
1382
- const model = options.model ?? PROVIDER_DEFAULTS[providers[0] as ProviderId]
1383
- console.log(
1384
- formatDryRunPreview({
1385
- repo: plan.repo,
1386
- harness: plan.harness,
1387
- providers,
1388
- model,
1389
- template: plan.template,
1390
- }),
1391
- )
1392
- return
1393
- }
1394
-
1395
- if (plan.createKey) {
1396
- resolveManagementKey()
1397
- }
1398
-
1399
- await withGhRetry(
1400
- `Checking GitHub access for ${plan.repo}`,
1401
- async () => {
1402
- await assertRepoAccess(plan.repo)
1403
- },
1404
- interactive,
1405
- )
1406
-
1407
- if (options.key) {
1408
- log.info('Using the provided API key value directly. No new CLIProxyAPI key will be created.')
1409
- }
1410
-
1411
- if (interactive) {
1412
- note(
1413
- [
1414
- `Proxy: ${baseUrl}`,
1415
- `Repository: ${plan.repo}`,
1416
- `Harness: ${plan.harness}`,
1417
- plan.createKey ? `New key name: ${plan.keyName}` : 'Using existing key value',
1418
- 'GitHub values to write:',
1419
- formatTemplateSummary(plan.template),
1420
- ].join('\n'),
1421
- 'Setup summary',
1422
- )
1423
-
1424
- const shouldContinue = await promptValue(
1425
- confirm({
1426
- message: 'Proceed with GitHub secret and variable updates?',
1427
- active: 'yes',
1428
- inactive: 'no',
1429
- initialValue: true,
1430
- }),
1431
- 'Setup cancelled before applying GitHub values.',
1432
- )
1433
-
1434
- if (!shouldContinue) {
1435
- cancelAndExit('No changes applied.')
1436
- }
1437
- }
1438
-
1439
- const [existingSecrets, existingVariables] = await withGhRetry(
1440
- 'Checking existing GitHub secrets and variables',
1441
- async () =>
1442
- Promise.all([listExistingGhNames(plan.repo, 'secret'), listExistingGhNames(plan.repo, 'variable')]),
1443
- interactive,
1444
- )
1445
- const collisions = collectCollisions(plan.template, existingSecrets, existingVariables)
1446
-
1447
- if (collisions.length > 0) {
1448
- if (!interactive && !options.force) {
1449
- throw new Error(
1450
- `Refusing to overwrite existing GitHub values in non-interactive mode: ${collisions.join(', ')}. Pass --force to confirm.`,
1451
- )
1452
- }
1453
-
1454
- if (!interactive && options.force) {
1455
- log.warn(`Overwriting existing GitHub values: ${collisions.join(', ')}`)
1456
- // proceed
1457
- }
1458
-
1459
- if (interactive) {
1460
- log.warn(`Existing GitHub values will be overwritten: ${collisions.join(', ')}`)
1461
- const overwrite = await promptValue(
1462
- confirm({
1463
- message: 'Overwrite the existing GitHub values?',
1464
- active: 'overwrite',
1465
- inactive: 'cancel',
1466
- initialValue: false,
1467
- }),
1468
- 'Setup cancelled instead of overwriting existing values.',
1469
- )
1470
-
1471
- if (!overwrite) {
1472
- cancelAndExit('Existing GitHub values left unchanged.')
1473
- }
1474
- }
1475
- }
1476
-
1477
- let keyCreatedByThisRun = false
1478
- const managementKey = plan.createKey ? resolveManagementKey() : undefined
1479
-
1480
- if (plan.createKey && managementKey) {
1481
- await withSpinner('Creating a new CLIProxyAPI key', async () => {
1482
- await createManagementApiKey(baseUrl, managementKey, plan.keyValue)
1483
- keyCreatedByThisRun = true
1484
- })
1485
- }
1486
-
1487
- try {
1488
- await withGhRetry(
1489
- 'Writing GitHub secrets and variables',
1490
- async spinnerInstance => {
1491
- for (const secret of plan.template.secrets) {
1492
- spinnerInstance.message(`Setting secret ${secret.name}`)
1493
- await applyGhValue('secret', secret.name, plan.repo, secret.value)
1494
- }
1495
-
1496
- for (const variable of plan.template.variables) {
1497
- spinnerInstance.message(`Setting variable ${variable.name}`)
1498
- await applyGhValue('variable', variable.name, plan.repo, variable.value)
1499
- }
1500
- },
1501
- interactive,
1502
- )
1503
-
1504
- await withSpinner('Verifying the new key through the proxy', async () => {
1505
- await assertProxyKeyWorks(baseUrl, plan.keyValue)
1506
- })
1507
-
1508
- if (plan.harness === 'opencode') {
1509
- const workflow = await withGhRetry(
1510
- `Checking ${plan.repo} fro-bot.yaml wiring`,
1511
- async () => {
1512
- return checkFroBotWorkflow(plan.repo)
1513
- },
1514
- interactive,
1515
- )
1516
-
1517
- switch (workflow.kind) {
1518
- case 'missing': {
1519
- log.warn(
1520
- `No .github/workflows/fro-bot.yaml found in ${plan.repo}. The secrets and variables are set, but Fro Bot won't run until the workflow exists and passes them as inputs. See marcusrbrown/infra/.github/workflows/fro-bot.yaml for a reference.`,
1521
- )
1522
- break
1523
- }
1524
- case 'unreachable': {
1525
- log.warn(
1526
- `Could not check .github/workflows/fro-bot.yaml in ${plan.repo}: ${workflow.reason}. The secrets and variables are set, but the workflow wiring was not verified. Re-run 'infra cliproxy setup' later to confirm, or inspect the file directly.`,
1527
- )
1528
- break
1529
- }
1530
- case 'no-agent-step': {
1531
- log.warn(
1532
- `${plan.repo} .github/workflows/fro-bot.yaml exists but has no 'fro-bot/agent' step. Add one that passes the secrets and variables just written. See marcusrbrown/infra/.github/workflows/fro-bot.yaml for a reference.`,
1533
- )
1534
- break
1535
- }
1536
- case 'analyzed': {
1537
- for (const step of workflow.stepsWithGaps) {
1538
- const missing = [...step.missingInputs]
1539
- log.warn(
1540
- [
1541
- `${plan.repo} .github/workflows/fro-bot.yaml fro-bot/agent step #${step.stepOrdinal} is missing ${missing.length} required input${
1542
- missing.length > 1 ? 's' : ''
1543
- } (${missing.join(', ')}).`,
1544
- `Without ${missing.includes('opencode-config') ? 'opencode-config, the baseURL override is ignored and Fro Bot hits api.anthropic.com with the proxy key, which fails with 401' : 'these, the secrets you just wrote will not reach OpenCode'}.`,
1545
- '',
1546
- `Add under the 'with:' block of the 'fro-bot/agent' step:`,
1547
- formatWorkflowSnippet(missing),
1548
- ].join('\n'),
1549
- )
1550
- }
1551
- break
1552
- }
1553
- default: {
1554
- const _exhaustive: never = workflow
1555
- throw new Error(`Unhandled FroBotWorkflowCheck kind: ${JSON.stringify(_exhaustive)}`)
1556
- }
1557
- }
1558
- }
1559
- } catch (mutationError) {
1560
- if (keyCreatedByThisRun && managementKey) {
1561
- try {
1562
- await deleteManagementApiKey(baseUrl, managementKey, plan.keyValue)
1563
- log.warn('Rolled back the newly created CLIProxyAPI key after failure.')
1564
- } catch {
1565
- log.warn(
1566
- 'Failed to roll back the newly created CLIProxyAPI key. Remove it manually via: infra cliproxy keys remove',
1567
- )
1568
- }
1569
- }
1570
- throw mutationError
1571
- }
1572
-
1573
- if (interactive) {
1574
- outro(`Setup complete for ${plan.repo}. The ${plan.harness} harness can now use ${baseUrl}/v1.`)
1575
- } else {
1576
- log.success(`Setup complete for ${plan.repo}.`)
1577
- }
1578
-
1579
- // ── Smoke test (opt-in, non-blocking) ──────────────────────────────
1580
- if (options.verifySmoke) {
1581
- const smokeResult = await withSpinner('Running smoke test', async () =>
1582
- runSmokeTest(plan.repo, plan.template.variables.find(v => v.name === 'FRO_BOT_MODEL')?.value ?? ''),
1583
- ).catch(async error => {
1584
- // withSpinner re-throws; catch here so smoke test never gates setup
1585
- return {
1586
- kind: 'unverified' as const,
1587
- message: `Smoke test error: ${extractErrorMessage(error)}`,
1588
- runUrl: undefined,
1589
- }
1590
- })
1591
-
1592
- switch (smokeResult.kind) {
1593
- case 'pass': {
1594
- log.success(`✓ ${smokeResult.message}${smokeResult.runUrl ? ` — ${smokeResult.runUrl}` : ''}`)
1595
- break
1596
- }
1597
- case 'fail': {
1598
- log.warn(`✗ ${smokeResult.message}${smokeResult.runUrl ? ` — ${smokeResult.runUrl}` : ''}`)
1599
- break
1600
- }
1601
- case 'unverified': {
1602
- log.warn(`⚠ ${smokeResult.message}${smokeResult.runUrl ? ` — ${smokeResult.runUrl}` : ''}`)
1603
- break
1604
- }
1605
- default: {
1606
- const _exhaustive: never = smokeResult
1607
- throw new Error(`Unhandled SmokeResult kind: ${JSON.stringify(_exhaustive)}`)
1608
- }
1609
- }
1610
- }
1611
- } catch (error) {
1612
- const message = extractErrorMessage(error)
1613
- if (interactive) {
1614
- cancel(message)
1615
- }
1616
- throw error
1617
- }
800
+ .example('# Run non-interactively with an existing key (anthropic-only)')
801
+ .example('infra cliproxy setup --repo owner/repo --harness opencode --key sk-existing --force')
802
+ .example('# Enable both providers non-interactively (requires --force and --model)')
803
+ .example(
804
+ 'infra cliproxy setup --repo owner/repo --harness opencode --providers anthropic,openai --model openai/gpt-5.4-mini --key sk-existing --ack-key-reuse --force',
805
+ )
806
+ .action(async (options, ctx) => {
807
+ await runSetupCommand(options, {ctx})
1618
808
  })
1619
809
  }