@marcusrbrown/infra 0.7.0 → 0.8.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.
@@ -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,63 @@ 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
203
- }
204
-
205
- function resolveBaseUrl(input?: string): string {
206
- return stripTrailingSlash(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
207
- }
208
-
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.')
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
235
86
  }
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.')
87
+ prompts?: {
88
+ promptValue: typeof promptValue
89
+ confirm: typeof confirm
90
+ intro: typeof intro
91
+ note: typeof note
92
+ outro: typeof outro
239
93
  }
240
-
241
- if (!options.harness) {
242
- throw new Error('--harness is required when stdin is not a TTY. Choose opencode or claude-code.')
94
+ smoke?: {
95
+ runSmokeTest: typeof runSmokeTest
243
96
  }
244
-
245
- if (options.harness === 'generic') {
246
- throw new Error('--harness generic is interactive-only because it requires custom secret names.')
97
+ validation?: {
98
+ assertProxyReachable: typeof assertProxyReachable
99
+ assertProxyKeyWorks: typeof assertProxyKeyWorks
100
+ verifyModelsAvailable: typeof verifyModelsAvailable
247
101
  }
248
102
  }
249
103
 
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
- }
104
+ function resolveBaseUrl(input?: string): string {
105
+ return stripTrailingSlash(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
347
106
  }
348
107
 
349
108
  function extractErrorMessage(error: unknown): string {
350
109
  return error instanceof Error ? error.message : String(error)
351
110
  }
352
111
 
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
- }
112
+ // Redact a bearer token for display in interactive prompts — never show raw key values.
113
+ // Exported for direct unit testing of the redaction contract. The redacted form is
114
+ // what gets shown in the interactive R8 prompt; the raw key must never reach the prompt UI.
115
+ export function redactKey(key: string): string {
116
+ if (key.length < 12) return 'sk-***'
117
+ return `${key.slice(0, 3)}***${key.slice(-4)}`
397
118
  }
398
119
 
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
- /**
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.
428
- */
429
- async function queryRateLimitReset(): Promise<string> {
430
- 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
- }
441
- } 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)
524
- }
525
-
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
- /**
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.
549
- *
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.
655
- *
656
- * Short-circuits immediately for anthropic-only setups — no fetch is made.
657
- * Never echoes the Authorization header in any error message.
658
- */
659
- export async function verifyModelsAvailable(
120
+ async function buildInteractivePlan(
121
+ options: SetupOptions,
660
122
  baseUrl: string,
661
- key: string,
662
- providers: ProviderId[],
663
- model: string,
664
- ): 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
- try {
729
- const response = await fetch(baseUrl, {
730
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
731
- })
732
-
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
739
- }
740
- throw new Error(`Unable to reach proxy at ${baseUrl}: ${extractErrorMessage(error)}`)
741
- }
742
- }
743
-
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
759
- }
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
-
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())
854
- }
855
- return
856
- }
857
-
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
- }
863
-
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
- }
869
-
870
- function collectCollisions(
871
- template: HarnessTemplate,
872
- existingSecrets: string[],
873
- existingVariables: string[],
874
- ): string[] {
875
- const collisions: string[] = []
876
-
877
- for (const secret of template.secrets) {
878
- if (existingSecrets.includes(secret.name)) {
879
- collisions.push(`secret ${secret.name}`)
880
- }
881
- }
882
-
883
- for (const variable of template.variables) {
884
- if (existingVariables.includes(variable.name)) {
885
- collisions.push(`variable ${variable.name}`)
886
- }
887
- }
888
-
889
- return collisions
890
- }
891
-
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
- )
930
-
931
- return {apiKeySecretName, baseUrlSecretName}
932
- }
933
-
934
- async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Promise<SetupPlan> {
123
+ promptsImpl: Required<RunSetupDeps>['prompts'],
124
+ ): Promise<SetupPlan> {
935
125
  const createKey = !options.key
936
126
  const keyName = createKey
937
- ? await promptValue(
127
+ ? await promptsImpl.promptValue(
938
128
  text({
939
129
  message: 'Name this new CLIProxyAPI key',
940
130
  placeholder: 'my-repo-ci',
@@ -946,7 +136,7 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
946
136
 
947
137
  const harness =
948
138
  options.harness ??
949
- (await promptValue(
139
+ (await promptsImpl.promptValue(
950
140
  select<Harness>({
951
141
  message: 'Choose the harness to configure',
952
142
  options: [
@@ -961,7 +151,7 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
961
151
  const repo = options.repo
962
152
  ? ensureRepoFormat(options.repo)
963
153
  : ensureRepoFormat(
964
- await promptValue(
154
+ await promptsImpl.promptValue(
965
155
  text({
966
156
  message: 'Target GitHub repository',
967
157
  placeholder: 'owner/repo',
@@ -1003,50 +193,18 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
1003
193
  * Returns true when the provider list includes anything beyond anthropic-only.
1004
194
  * Anthropic-only repos see no behavior change (G7 invariant).
1005
195
  */
1006
- export function mustConfirmDestructive(providers: ProviderId[]): boolean {
196
+ export function requiresDestructiveProviderChangeConfirmation(providers: ProviderId[]): boolean {
1007
197
  return !(providers.length === 1 && providers[0] === 'anthropic')
1008
198
  }
1009
199
 
1010
- export interface DryRunPreviewOptions {
1011
- repo: string
1012
- harness: Harness
1013
- providers: ProviderId[]
1014
- model: string
1015
- template: HarnessTemplate
1016
- }
200
+ // Deprecated: use requiresDestructiveProviderChangeConfirmation. Will be removed in a future major.
201
+ export const mustConfirmDestructive = requiresDestructiveProviderChangeConfirmation
1017
202
 
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
- }
1037
-
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> {
203
+ export async function buildNonInteractivePlan(
204
+ options: SetupOptions,
205
+ baseUrl: string,
206
+ deps?: Pick<RunSetupDeps, 'validation'>,
207
+ ): Promise<SetupPlan> {
1050
208
  const harness = harnessSchema.parse(options.harness)
1051
209
  const repo = ensureRepoFormat(options.repo ?? '')
1052
210
  const keyValue = options.key ?? ''
@@ -1074,15 +232,18 @@ export async function buildNonInteractivePlan(options: SetupOptions, baseUrl: st
1074
232
  }
1075
233
  }
1076
234
 
1077
- await verifyModelsAvailable(baseUrl, keyValue, providers, model)
235
+ const verifyModels = deps?.validation?.verifyModelsAvailable ?? verifyModelsAvailable
1078
236
 
1079
- // Destructive overwrite gate: non-anthropic-only requires --force in non-interactive mode
1080
- if (mustConfirmDestructive(providers) && !options.force) {
237
+ // Destructive overwrite gate: non-anthropic-only requires --force in non-interactive mode.
238
+ // Check BEFORE verifyModelsAvailable to avoid a network call when the gate will reject anyway.
239
+ if (requiresDestructiveProviderChangeConfirmation(providers) && !options.force) {
1081
240
  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.',
241
+ `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
242
  )
1084
243
  }
1085
244
 
245
+ await verifyModels(baseUrl, keyValue, providers, model)
246
+
1086
247
  return {
1087
248
  repo,
1088
249
  harness,
@@ -1092,206 +253,360 @@ export async function buildNonInteractivePlan(options: SetupOptions, baseUrl: st
1092
253
  }
1093
254
  }
1094
255
 
1095
- // ─── Smoke test runner ────────────────────────────────────────────────────────
256
+ // Default real implementations for DI
257
+ const realGh: Required<RunSetupDeps>['gh'] = {
258
+ assertGhInstalled,
259
+ assertGhAuthenticated,
260
+ assertRepoAccess,
261
+ listExistingGhNames,
262
+ createManagementApiKey,
263
+ deleteManagementApiKey,
264
+ applyGhValue,
265
+ withGhRetry,
266
+ }
267
+
268
+ const realPrompts: Required<RunSetupDeps>['prompts'] = {
269
+ promptValue,
270
+ confirm,
271
+ intro,
272
+ note,
273
+ outro,
274
+ }
275
+
276
+ const realSmoke: Required<RunSetupDeps>['smoke'] = {
277
+ runSmokeTest,
278
+ }
279
+
280
+ const realValidation: Required<RunSetupDeps>['validation'] = {
281
+ assertProxyReachable,
282
+ assertProxyKeyWorks,
283
+ verifyModelsAvailable,
284
+ }
285
+
286
+ const realCtx: ActionCtx = {
287
+ console: {
288
+ log: (...args: unknown[]) => {
289
+ console.log(...args)
290
+ },
291
+ error: (...args: unknown[]) => {
292
+ console.error(...args)
293
+ },
294
+ },
295
+ process: {
296
+ stdout: {write: (chunk: string) => process.stdout.write(chunk)},
297
+ stderr: {write: (chunk: string) => process.stderr.write(chunk)},
298
+ exit: (code: number) => process.exit(code),
299
+ },
300
+ }
301
+
302
+ // Internal: test-only DI surface. Not part of the published API.
303
+ export async function runSetupCommand(options: SetupOptions, deps: RunSetupDeps = {}): Promise<void> {
304
+ const interactive = deps.interactive ?? Boolean(process.stdin.isTTY)
305
+ const baseUrl = deps.baseUrl ?? resolveBaseUrl()
306
+ // ctxInjected distinguishes MCP-mounted callers (which need ctx.console.error to surface errors
307
+ // through the MCP transport) from bare CLI callers (where cli.ts top-level catch already logs).
308
+ // Without this distinction, CLI users see the error twice.
309
+ const ctxInjected = deps.ctx !== undefined
310
+ const ctx = deps.ctx ?? realCtx
311
+ const gh = deps.gh ?? realGh
312
+ const prompts = deps.prompts ?? realPrompts
313
+ const smoke = deps.smoke ?? realSmoke
314
+ const validation = deps.validation ?? realValidation
315
+ const mgmtKeyResolver = deps.resolveManagementKey ?? resolveManagementKey
316
+
317
+ // --dry-run: short-circuit before validation so it works with no flags.
318
+ // Never blocks on stdin — safe to run anywhere.
319
+ if (options.dryRun) {
320
+ const providers: ProviderId[] = options.providers ? parseProviders(options.providers) : ['anthropic']
321
+ const model = options.model ?? PROVIDER_DEFAULTS[providers[0] as ProviderId]
322
+ const harness = options.harness ?? 'opencode'
323
+ const repo = options.repo ?? '<repo not specified>'
324
+ const keyValue = options.key ?? 'sk-placeholder'
325
+ ctx.console.log(
326
+ formatDryRunPreview({
327
+ repo,
328
+ harness,
329
+ providers,
330
+ model,
331
+ template: getHarnessTemplate(harness, {keyValue, baseUrl, providers, model}),
332
+ }),
333
+ )
334
+ return
335
+ }
1096
336
 
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}
337
+ validateSetupOptions(options, interactive)
1101
338
 
1102
- interface GhRunEntry {
1103
- databaseId: number
1104
- status: string
1105
- conclusion: string | null
1106
- url: string
1107
- createdAt: string
1108
- }
339
+ if (interactive) {
340
+ prompts.intro('CLIProxyAPI setup wizard')
341
+ }
1109
342
 
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
- }
343
+ try {
344
+ await withSpinner('Checking GitHub CLI availability', async () => {
345
+ await gh.assertGhInstalled()
346
+ await gh.assertGhAuthenticated()
347
+ })
1117
348
 
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
349
+ await withSpinner('Checking CLIProxyAPI reachability', async () => {
350
+ await validation.assertProxyReachable(baseUrl)
351
+ })
352
+
353
+ const plan = interactive
354
+ ? await buildInteractivePlan(options, baseUrl, prompts)
355
+ : await buildNonInteractivePlan(options, baseUrl, {validation})
356
+
357
+ if (plan.createKey) {
358
+ mgmtKeyResolver()
1143
359
  }
1144
- await new Promise(resolve => setTimeout(resolve, ms))
1145
- }
1146
360
 
1147
- const repoUrl = `https://github.com/${repo}`
361
+ await gh.withGhRetry(
362
+ `Checking GitHub access for ${plan.repo}`,
363
+ async () => {
364
+ await gh.assertRepoAccess(plan.repo)
365
+ },
366
+ interactive,
367
+ )
1148
368
 
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},
369
+ if (options.key) {
370
+ log.info('Using the provided API key value directly. No new CLIProxyAPI key will be created.')
371
+ }
372
+
373
+ if (interactive) {
374
+ prompts.note(
375
+ [
376
+ `Proxy: ${baseUrl}`,
377
+ `Repository: ${plan.repo}`,
378
+ `Harness: ${plan.harness}`,
379
+ plan.createKey ? `New key name: ${plan.keyName}` : 'Using existing key value',
380
+ 'GitHub values to write:',
381
+ formatTemplateSummary(plan.template),
382
+ ].join('\n'),
383
+ 'Setup summary',
384
+ )
385
+
386
+ const shouldContinue = await prompts.promptValue(
387
+ prompts.confirm({
388
+ message: 'Proceed with GitHub secret and variable updates?',
389
+ active: 'yes',
390
+ inactive: 'no',
391
+ initialValue: true,
392
+ }),
393
+ 'Setup cancelled before applying GitHub values.',
394
+ )
395
+
396
+ if (!shouldContinue) {
397
+ cancelAndExit('No changes applied.')
398
+ }
399
+ }
400
+
401
+ const [existingSecrets, existingVariables] = await gh.withGhRetry(
402
+ 'Checking existing GitHub secrets and variables',
403
+ async () =>
404
+ Promise.all([gh.listExistingGhNames(plan.repo, 'secret'), gh.listExistingGhNames(plan.repo, 'variable')]),
405
+ interactive,
1155
406
  )
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
407
+
408
+ // Key-reuse acknowledgment guard: bearer token must not appear in prompt text
409
+ if (options.key && existingSecrets.includes('OPENCODE_AUTH_JSON')) {
410
+ if (interactive) {
411
+ const proceed = await prompts.promptValue(
412
+ prompts.confirm({
413
+ message: `You supplied --key ${redactKey(options.key)}. Verify it matches the bearer token inside the existing OPENCODE_AUTH_JSON on ${plan.repo}. Continue?`,
414
+ active: 'yes',
415
+ inactive: 'no',
416
+ initialValue: false,
417
+ }),
418
+ 'Setup cancelled. Run with --ack-key-reuse to bypass interactive confirmation.',
419
+ )
420
+ if (!proceed) {
421
+ cancelAndExit('Setup cancelled. Run with --ack-key-reuse to bypass interactive confirmation.')
422
+ }
423
+ } else if (!options.ackKeyReuse) {
424
+ throw new Error(
425
+ `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.)`,
426
+ )
1165
427
  }
1166
428
  }
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
429
 
1172
- // ── Step 2: Trigger the workflow ──────────────────────────────────────────
1173
- const triggerTime = internals._testTriggerTime ?? new Date()
1174
-
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
- }
430
+ const collisions = collectCollisions(plan.template, existingSecrets, existingVariables)
1189
431
 
1190
- // ── Step 3: Poll for the new run ──────────────────────────────────────────
1191
- let latestMatchedRun: GhRunEntry | undefined
432
+ if (collisions.length > 0) {
433
+ if (!interactive && !options.force) {
434
+ throw new Error(
435
+ `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).`,
436
+ )
437
+ }
1192
438
 
1193
- for (const BACKOFF_M of BACKOFF_MS) {
1194
- await delayFn(BACKOFF_M ?? 60_000)
439
+ if (!interactive && options.force) {
440
+ log.warn(`Overwriting existing GitHub values: ${collisions.join(', ')}`)
441
+ // proceed
442
+ }
1195
443
 
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[]
444
+ if (interactive) {
445
+ log.warn(`Existing GitHub values will be overwritten: ${collisions.join(', ')}`)
446
+ const overwrite = await prompts.promptValue(
447
+ prompts.confirm({
448
+ message: 'Overwrite the existing GitHub values?',
449
+ active: 'overwrite',
450
+ inactive: 'cancel',
451
+ initialValue: false,
452
+ }),
453
+ 'Setup cancelled instead of overwriting existing values.',
454
+ )
455
+
456
+ if (!overwrite) {
457
+ cancelAndExit('Existing GitHub values left unchanged.')
458
+ }
1220
459
  }
1221
- } catch {
1222
- // Parse/network error — retry on next poll
1223
- continue
1224
460
  }
1225
461
 
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
- })
462
+ let keyCreatedByThisRun = false
463
+ const managementKey = plan.createKey ? mgmtKeyResolver() : undefined
1234
464
 
1235
- if (candidates.length === 0) {
1236
- // Our run not visible yet keep polling
1237
- continue
465
+ if (plan.createKey && managementKey) {
466
+ await withSpinner('Creating a new CLIProxyAPI key', async () => {
467
+ await gh.createManagementApiKey(baseUrl, managementKey, plan.keyValue)
468
+ keyCreatedByThisRun = true
469
+ })
1238
470
  }
1239
471
 
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
472
+ try {
473
+ await gh.withGhRetry(
474
+ 'Writing GitHub secrets and variables',
475
+ async spinnerInstance => {
476
+ for (const secret of plan.template.secrets) {
477
+ spinnerInstance.message(`Setting secret ${secret.name}`)
478
+ await gh.applyGhValue('secret', secret.name, plan.repo, secret.value)
479
+ }
480
+
481
+ for (const variable of plan.template.variables) {
482
+ spinnerInstance.message(`Setting variable ${variable.name}`)
483
+ await gh.applyGhValue('variable', variable.name, plan.repo, variable.value)
484
+ }
485
+ },
486
+ interactive,
487
+ )
1243
488
 
1244
- const {status, conclusion, url: runUrl} = matched
489
+ await withSpinner('Verifying the new key through the proxy', async () => {
490
+ await validation.assertProxyKeyWorks(baseUrl, plan.keyValue)
491
+ })
1245
492
 
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
- }
493
+ if (plan.harness === 'opencode') {
494
+ const workflow = await gh.withGhRetry(
495
+ `Checking ${plan.repo} fro-bot.yaml wiring`,
496
+ async () => {
497
+ return checkFroBotWorkflow(plan.repo)
498
+ },
499
+ interactive,
500
+ )
1250
501
 
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)'
502
+ switch (workflow.kind) {
503
+ case 'missing': {
504
+ log.warn(
505
+ `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.`,
506
+ )
507
+ break
508
+ }
509
+ case 'unreachable': {
510
+ log.warn(
511
+ `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.`,
512
+ )
513
+ break
514
+ }
515
+ case 'no-agent-step': {
516
+ log.warn(
517
+ `${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.`,
518
+ )
519
+ break
520
+ }
521
+ case 'analyzed': {
522
+ for (const step of workflow.stepsWithGaps) {
523
+ const missing = [...step.missingInputs]
524
+ log.warn(
525
+ [
526
+ `${plan.repo} .github/workflows/fro-bot.yaml fro-bot/agent step #${step.stepOrdinal} is missing ${missing.length} required input${
527
+ missing.length > 1 ? 's' : ''
528
+ } (${missing.join(', ')}).`,
529
+ `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'}.`,
530
+ '',
531
+ `Add under the 'with:' block of the 'fro-bot/agent' step:`,
532
+ formatWorkflowSnippet(missing),
533
+ ].join('\n'),
534
+ )
535
+ }
536
+ break
1270
537
  }
538
+ default: {
539
+ const _exhaustive: never = workflow
540
+ throw new Error(`Unhandled FroBotWorkflowCheck kind: ${JSON.stringify(_exhaustive)}`)
541
+ }
542
+ }
543
+ }
544
+ } catch (mutationError) {
545
+ if (keyCreatedByThisRun && managementKey) {
546
+ try {
547
+ await gh.deleteManagementApiKey(baseUrl, managementKey, plan.keyValue)
548
+ log.warn('Rolled back the newly created CLIProxyAPI key after failure.')
1271
549
  } catch {
1272
- logNote = ' (log fetch failed, but run conclusion is success)'
550
+ log.warn(
551
+ 'Failed to roll back the newly created CLIProxyAPI key. Remove it manually via: infra cliproxy keys remove',
552
+ )
1273
553
  }
1274
- return {kind: 'pass', message: `Smoke test passed${logNote}`, runUrl}
1275
554
  }
1276
-
1277
- return {kind: 'fail', message: `Run completed with conclusion=${conclusion ?? 'unknown'}`, runUrl}
555
+ throw mutationError
1278
556
  }
1279
557
 
1280
- // Still in progress (queued, in_progress) — continue polling
1281
- }
558
+ if (interactive) {
559
+ prompts.outro(`Setup complete for ${plan.repo}. The ${plan.harness} harness can now use ${baseUrl}/v1.`)
560
+ } else {
561
+ log.success(`Setup complete for ${plan.repo}.`)
562
+ }
563
+
564
+ // ── Smoke test (opt-in, non-blocking) ──────────────────────────────
565
+ if (options.verifySmoke) {
566
+ const smokeResult = await withSpinner('Running smoke test', async () =>
567
+ smoke.runSmokeTest(plan.repo, plan.template.variables.find(v => v.name === 'FRO_BOT_MODEL')?.value ?? ''),
568
+ ).catch(async error => {
569
+ // withSpinner re-throws; catch here so smoke test never gates setup
570
+ return {
571
+ kind: 'unverified' as const,
572
+ message: `Smoke test error: ${extractErrorMessage(error)}`,
573
+ runUrl: undefined,
574
+ }
575
+ })
1282
576
 
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
- }
577
+ // Machine-parseable hook for MCP/agent consumers
578
+ ctx.console.log(`[smoke-test] kind=${smokeResult.kind}`)
1291
579
 
1292
- return {
1293
- kind: 'unverified',
1294
- message: `Smoke test trigger not yet visible; check ${repoUrl}/actions`,
580
+ switch (smokeResult.kind) {
581
+ case 'pass': {
582
+ log.success(`✓ ${smokeResult.message}${smokeResult.runUrl ? ` ${smokeResult.runUrl}` : ''}`)
583
+ break
584
+ }
585
+ case 'fail': {
586
+ log.warn(`✗ ${smokeResult.message}${smokeResult.runUrl ? ` — ${smokeResult.runUrl}` : ''}`)
587
+ break
588
+ }
589
+ case 'unverified': {
590
+ log.warn(`⚠ ${smokeResult.message}${smokeResult.runUrl ? ` — ${smokeResult.runUrl}` : ''}`)
591
+ break
592
+ }
593
+ default: {
594
+ const _exhaustive: never = smokeResult
595
+ throw new Error(`Unhandled SmokeResult kind: ${JSON.stringify(_exhaustive)}`)
596
+ }
597
+ }
598
+ }
599
+ } catch (error) {
600
+ const message = extractErrorMessage(error)
601
+ // MCP-mounted callers need ctx.console.error to surface the message through the MCP transport.
602
+ // Bare CLI callers leave it to cli.ts's top-level catch — emitting here too would double-log.
603
+ if (ctxInjected) {
604
+ ctx.console.error(message)
605
+ }
606
+ if (interactive) {
607
+ cancel(message)
608
+ }
609
+ throw error
1295
610
  }
1296
611
  }
1297
612
 
@@ -1324,7 +639,7 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
1324
639
  z
1325
640
  .string()
1326
641
  .describe(
1327
- 'Comma-separated list of providers to enable. Supported values: anthropic, openai. Example: --providers anthropic,openai',
642
+ 'Comma-separated list of providers to enable. Default: anthropic. Supported values: anthropic, openai. Example: --providers anthropic,openai',
1328
643
  ),
1329
644
  )
1330
645
  .option(
@@ -1333,7 +648,7 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
1333
648
  .string()
1334
649
  .regex(MODEL_ID_RE)
1335
650
  .describe(
1336
- 'Override the default model. Must be provider-prefixed and lowercase. Examples: anthropic/claude-sonnet-4-6, openai/gpt-4o',
651
+ '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
652
  ),
1338
653
  )
1339
654
  .option(
@@ -1345,275 +660,26 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
1345
660
  '--verify-smoke',
1346
661
  z.boolean().optional().describe('Run a smoke test against the proxy after setup completes.'),
1347
662
  )
663
+ .option(
664
+ '--ack-key-reuse',
665
+ z
666
+ .boolean()
667
+ .default(false)
668
+ .describe(
669
+ '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.',
670
+ ),
671
+ )
672
+ .example('# Preview planned actions without applying any changes (no flags required)')
673
+ .example('infra cliproxy setup --dry-run')
1348
674
  .example('# Run the interactive onboarding wizard')
1349
675
  .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
- }
676
+ .example('# Run non-interactively with an existing key (anthropic-only)')
677
+ .example('infra cliproxy setup --repo owner/repo --harness opencode --key sk-existing --force')
678
+ .example('# Enable both providers non-interactively (requires --force and --model)')
679
+ .example(
680
+ 'infra cliproxy setup --repo owner/repo --harness opencode --providers anthropic,openai --model openai/gpt-5.4-mini --key sk-existing --ack-key-reuse --force',
681
+ )
682
+ .action(async (options, ctx) => {
683
+ await runSetupCommand(options, {ctx})
1618
684
  })
1619
685
  }