@marcusrbrown/infra 0.6.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,52 +1,62 @@
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, 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
- const DEFAULT_OMO_PROVIDERS = 'claude-max20'
15
- const DEFAULT_FRO_BOT_MODEL = 'anthropic/claude-sonnet-4-6'
16
-
17
- const harnessSchema = z.enum(['opencode', 'claude-code', 'generic'])
18
- const ghRepoViewSchema = z.object({
19
- nameWithOwner: z.string(),
20
- viewerPermission: z.string(),
21
- })
22
- const ghNameListSchema = z.array(z.object({name: z.string()}))
23
-
24
- export type Harness = z.infer<typeof harnessSchema>
25
48
 
26
49
  export interface SetupOptions {
27
50
  key?: string
28
51
  repo?: string
29
52
  harness?: Harness
30
- }
31
-
32
- export interface SecretAssignment {
33
- name: string
34
- value: string
35
- }
36
-
37
- export interface VariableAssignment {
38
- name: string
39
- value: string
40
- }
41
-
42
- export interface HarnessTemplate {
43
- secrets: SecretAssignment[]
44
- variables: VariableAssignment[]
45
- }
46
-
47
- interface GenericSecretNames {
48
- apiKeySecretName: string
49
- baseUrlSecretName: string
53
+ /** Raw comma-separated provider list string (e.g. "anthropic,openai"). Use parseProviders() to validate. */
54
+ providers?: string
55
+ model?: string
56
+ force?: boolean
57
+ dryRun?: boolean
58
+ verifySmoke?: boolean
59
+ ackKeyReuse?: boolean
50
60
  }
51
61
 
52
62
  interface SetupPlan {
@@ -58,611 +68,63 @@ interface SetupPlan {
58
68
  template: HarnessTemplate
59
69
  }
60
70
 
61
- interface CommandResult {
62
- stdout: string
63
- stderr: string
64
- exitCode: number
65
- }
66
-
67
- function stripTrailingSlash(value: string): string {
68
- return value.endsWith('/') ? value.slice(0, -1) : value
69
- }
70
-
71
- function resolveBaseUrl(input?: string): string {
72
- return stripTrailingSlash(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
73
- }
74
-
75
- export function validateSetupOptions(options: SetupOptions, isInteractive: boolean): void {
76
- if (isInteractive) {
77
- return
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
78
86
  }
79
-
80
- if (!options.key) {
81
- throw new Error('--key is required when stdin is not a TTY. Provide an existing CLIProxyAPI key value.')
82
- }
83
-
84
- if (!options.repo) {
85
- 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
86
93
  }
87
-
88
- if (!options.harness) {
89
- throw new Error('--harness is required when stdin is not a TTY. Choose opencode or claude-code.')
94
+ smoke?: {
95
+ runSmokeTest: typeof runSmokeTest
90
96
  }
91
-
92
- if (options.harness === 'generic') {
93
- 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
94
101
  }
95
102
  }
96
103
 
97
- export function getHarnessTemplate(
98
- harness: Harness,
99
- values: {
100
- keyValue?: string
101
- baseUrl?: string
102
- genericSecretNames?: GenericSecretNames
103
- } = {},
104
- ): HarnessTemplate {
105
- const keyValue = values.keyValue ?? 'sk-placeholder'
106
- const baseUrl = stripTrailingSlash(values.baseUrl ?? DEFAULT_CLIPROXY_URL)
107
-
108
- if (harness === 'opencode') {
109
- return {
110
- secrets: [
111
- {
112
- name: 'OPENCODE_AUTH_JSON',
113
- value: JSON.stringify({anthropic: {type: 'api', key: keyValue}}),
114
- },
115
- {
116
- name: 'OPENCODE_CONFIG',
117
- value: JSON.stringify({provider: {anthropic: {options: {baseURL: `${baseUrl}/v1`}}}}),
118
- },
119
- {
120
- name: 'OMO_PROVIDERS',
121
- value: DEFAULT_OMO_PROVIDERS,
122
- },
123
- ],
124
- variables: [
125
- {
126
- name: 'FRO_BOT_MODEL',
127
- value: DEFAULT_FRO_BOT_MODEL,
128
- },
129
- ],
130
- }
131
- }
132
-
133
- if (harness === 'claude-code') {
134
- return {
135
- secrets: [
136
- {
137
- name: 'ANTHROPIC_API_KEY',
138
- value: keyValue,
139
- },
140
- ],
141
- variables: [],
142
- }
143
- }
144
-
145
- if (!values.genericSecretNames) {
146
- throw new Error('Generic harness requires custom secret names.')
147
- }
148
-
149
- return {
150
- secrets: [
151
- {name: values.genericSecretNames.apiKeySecretName, value: keyValue},
152
- {name: values.genericSecretNames.baseUrlSecretName, value: `${baseUrl}/v1`},
153
- ],
154
- variables: [],
155
- }
104
+ function resolveBaseUrl(input?: string): string {
105
+ return stripTrailingSlash(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
156
106
  }
157
107
 
158
108
  function extractErrorMessage(error: unknown): string {
159
109
  return error instanceof Error ? error.message : String(error)
160
110
  }
161
111
 
162
- function ensureRepoFormat(value: string): string {
163
- const trimmed = value.trim()
164
- if (!/^[^/\s]+\/[^/\s]+$/.test(trimmed)) {
165
- throw new Error('Repository must be in owner/repo format.')
166
- }
167
- return trimmed
168
- }
169
-
170
- function ensureSecretName(value: string, label: string): string {
171
- const trimmed = value.trim()
172
- if (!/^[A-Z][A-Z0-9_]*$/.test(trimmed)) {
173
- throw new Error(`${label} must be SCREAMING_SNAKE_CASE.`)
174
- }
175
- return trimmed
176
- }
177
-
178
- function cancelAndExit(message = 'Setup cancelled.'): never {
179
- cancel(message)
180
- process.exit(0)
181
- }
182
-
183
- async function promptValue<T extends string | boolean>(
184
- promise: Promise<T | symbol>,
185
- cancelMessage?: string,
186
- ): Promise<T> {
187
- const value = await promise
188
- if (isCancel(value)) {
189
- cancelAndExit(cancelMessage)
190
- }
191
- return value
192
- }
193
-
194
- async function withSpinner<T>(message: string, run: (spinnerInstance: SpinnerResult) => Promise<T>): Promise<T> {
195
- const spinnerInstance = spinner()
196
- spinnerInstance.start(message)
197
-
198
- try {
199
- const result = await run(spinnerInstance)
200
- spinnerInstance.stop(message)
201
- return result
202
- } catch (error) {
203
- spinnerInstance.error(`${message} failed`)
204
- throw error
205
- }
206
- }
207
-
208
- async function runCommand(command: string, args: string[]): Promise<CommandResult> {
209
- const child = Bun.spawn([command, ...args], {
210
- stdout: 'pipe',
211
- stderr: 'pipe',
212
- env: process.env,
213
- })
214
-
215
- const [stdout, stderr, exitCode] = await Promise.all([
216
- new Response(child.stdout).text(),
217
- new Response(child.stderr).text(),
218
- child.exited,
219
- ])
220
-
221
- return {stdout, stderr, exitCode}
222
- }
223
-
224
- async function runGh(args: string[]): Promise<CommandResult> {
225
- return runCommand('gh', args)
226
- }
227
-
228
- export function isGhRateLimitError(text: string): boolean {
229
- return /rate limit/i.test(text)
230
- }
231
-
232
- /**
233
- * Query the GitHub API rate limit reset time. The `rate_limit` endpoint is
234
- * exempt from rate limiting itself, so this should succeed even when the
235
- * primary GraphQL limit is exhausted. Returns a formatted local time string
236
- * or a fallback phrase when the endpoint is unreachable.
237
- */
238
- async function queryRateLimitReset(): Promise<string> {
239
- try {
240
- const result = await runGh(['api', 'rate_limit'])
241
- if (result.exitCode === 0) {
242
- const parsed = JSON.parse(result.stdout) as {
243
- resources?: {graphql?: {reset?: number}; core?: {reset?: number}}
244
- }
245
- const reset = parsed.resources?.graphql?.reset ?? parsed.resources?.core?.reset
246
- if (reset) {
247
- return new Date(reset * 1000).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
248
- }
249
- }
250
- } catch {
251
- // Fall through to generic phrase
252
- }
253
- return 'an unknown time'
254
- }
255
-
256
- /**
257
- * Run a GitHub API operation wrapped in a spinner, retrying indefinitely on
258
- * rate-limit errors when in interactive mode. In non-interactive mode the
259
- * error is re-thrown with the reset time appended so the caller can surface
260
- * it without prompting.
261
- */
262
- export async function withGhRetry<T>(
263
- label: string,
264
- fn: (spinnerInstance: SpinnerResult) => Promise<T>,
265
- interactive: boolean,
266
- queryReset: () => Promise<string> = queryRateLimitReset,
267
- ): Promise<T> {
268
- for (;;) {
269
- try {
270
- return await withSpinner(label, fn)
271
- } catch (error) {
272
- const message = extractErrorMessage(error)
273
- if (!isGhRateLimitError(message)) {
274
- throw error
275
- }
276
- const reset = await queryReset()
277
- if (!interactive) {
278
- throw new Error(`${message} — GitHub API rate limit resets at ${reset}. Re-run when ready.`)
279
- }
280
- log.warn(`GitHub API rate limit exceeded. Resets at ${reset}.`)
281
- const retry = await promptValue(
282
- confirm({
283
- message: 'Retry this step when ready?',
284
- active: 'retry',
285
- inactive: 'abort',
286
- initialValue: true,
287
- }),
288
- 'Setup aborted after rate limit.',
289
- )
290
- if (!retry) {
291
- cancelAndExit('Setup aborted after GitHub API rate limit.')
292
- }
293
- }
294
- }
295
- }
296
-
297
- async function assertGhInstalled(): Promise<void> {
298
- if (!Bun.which('gh')) {
299
- throw new Error('GitHub CLI is required for cliproxy setup. Install gh first: https://cli.github.com/')
300
- }
301
- }
302
-
303
- async function assertGhAuthenticated(): Promise<void> {
304
- const result = await runGh(['auth', 'status'])
305
- if (result.exitCode !== 0) {
306
- throw new Error(`GitHub CLI is not authenticated. Run "gh auth login" first. ${result.stderr.trim()}`.trim())
307
- }
308
- }
309
-
310
- async function assertRepoAccess(repo: string): Promise<void> {
311
- const result = await runGh(['repo', 'view', repo, '--json', 'nameWithOwner,viewerPermission'])
312
- if (result.exitCode !== 0) {
313
- throw new Error(`Unable to access ${repo}. ${result.stderr.trim()}`.trim())
314
- }
315
-
316
- const parsed = ghRepoViewSchema.parse(JSON.parse(result.stdout))
317
- const writePermissions = new Set(['ADMIN', 'MAINTAIN', 'WRITE'])
318
-
319
- if (!writePermissions.has(parsed.viewerPermission)) {
320
- throw new Error(
321
- `GitHub CLI does not have write access to ${parsed.nameWithOwner}. Current permission: ${parsed.viewerPermission}.`,
322
- )
323
- }
324
- }
325
-
326
- async function listExistingGhNames(repo: string, kind: 'secret' | 'variable'): Promise<string[]> {
327
- const result = await runGh([kind, 'list', '--repo', repo, '--json', 'name'])
328
- if (result.exitCode !== 0) {
329
- throw new Error(`Unable to list existing GitHub ${kind}s for ${repo}. ${result.stderr.trim()}`.trim())
330
- }
331
-
332
- return ghNameListSchema.parse(JSON.parse(result.stdout)).map(entry => entry.name)
333
- }
334
-
335
- export type FroBotWorkflowCheck =
336
- | {kind: 'missing'}
337
- | {kind: 'unreachable'; reason: string}
338
- | {kind: 'no-agent-step'}
339
- | {
340
- kind: 'analyzed'
341
- stepsWithGaps: readonly {stepOrdinal: number; missingInputs: readonly string[]}[]
342
- }
343
-
344
- // github-token and prompt are intentionally excluded from this check:
345
- // github-token is harness-agnostic (PAT wiring, not secret-routing) and prompt is
346
- // workflow-defined (the user's prompt body, not a harness default).
347
- const REQUIRED_OPENCODE_INPUTS = ['auth-json', 'opencode-config', 'omo-providers', 'model'] as const
348
-
349
- /**
350
- * Slice the workflow content into one entry per `fro-bot/agent` step. Handles
351
- * both the `- name:\n uses: ...` and `- uses: ...` step shapes. Returns an
352
- * empty array if no fro-bot/agent step is present.
353
- *
354
- * Step-scoped slicing prevents false-passes where a same-named input key in a
355
- * sibling step (strategy.matrix, custom actions, reusable workflow with:
356
- * blocks) could mask a genuine gap in fro-bot/agent's wiring.
357
- */
358
- function findFroBotAgentStepBodies(content: string): {stepOrdinal: number; body: string}[] {
359
- const bodies: {stepOrdinal: number; body: string}[] = []
360
- const pattern = /^(\s*(?:-\s+)?)uses:\s*fro-bot\/agent@/gm
361
-
362
- for (const match of content.matchAll(pattern)) {
363
- if (match.index === undefined || match[1] === undefined) continue
364
-
365
- const stepBodyIndent = match[1].length
366
- const dashIndent = Math.max(0, stepBodyIndent - 2)
367
- const lines = content.slice(match.index).split('\n')
368
- const stepLines: string[] = [lines[0] ?? '']
369
-
370
- for (let index = 1; index < lines.length; index += 1) {
371
- const line = lines[index] ?? ''
372
- if (!line.trim()) {
373
- stepLines.push(line)
374
- continue
375
- }
376
- const firstNonSpace = line.search(/\S/)
377
- if (firstNonSpace === dashIndent && line.trimStart().startsWith('-')) break
378
- if (firstNonSpace < dashIndent) break
379
- stepLines.push(line)
380
- }
381
-
382
- bodies.push({stepOrdinal: bodies.length + 1, body: stepLines.join('\n')})
383
- }
384
-
385
- return bodies
386
- }
387
-
388
- export function analyzeFroBotWorkflow(workflowContent: string): FroBotWorkflowCheck {
389
- const steps = findFroBotAgentStepBodies(workflowContent)
390
-
391
- if (steps.length === 0) {
392
- return {kind: 'no-agent-step'}
393
- }
394
-
395
- const stepsWithGaps = steps
396
- .map(step => ({
397
- stepOrdinal: step.stepOrdinal,
398
- missingInputs: REQUIRED_OPENCODE_INPUTS.filter(input => {
399
- const inputPattern = new RegExp(String.raw`^\s+${input}:`, 'm')
400
- return !inputPattern.test(step.body)
401
- }),
402
- }))
403
- .filter(step => step.missingInputs.length > 0)
404
-
405
- return {kind: 'analyzed', stepsWithGaps}
406
- }
407
-
408
- /**
409
- * Extracted pure helper: turn a `gh api /repos/.../contents/<file>` result into
410
- * a FroBotWorkflowCheck. Separated from checkFroBotWorkflow so tests can exercise
411
- * the 404-vs-transport-error logic without mocking Bun.spawn.
412
- */
413
- export function interpretGhContentResult(result: CommandResult): FroBotWorkflowCheck {
414
- if (result.exitCode === 0) {
415
- return analyzeFroBotWorkflow(result.stdout)
416
- }
417
-
418
- // gh prints `gh: Not Found (HTTP 404)` on 404; anything else is auth/network/5xx.
419
- if (/HTTP 404/.test(result.stderr)) {
420
- return {kind: 'missing'}
421
- }
422
-
423
- return {
424
- kind: 'unreachable',
425
- reason: result.stderr.trim() || `gh api exited with code ${result.exitCode}`,
426
- }
427
- }
428
-
429
- async function checkFroBotWorkflow(repo: string): Promise<FroBotWorkflowCheck> {
430
- const result = await runGh([
431
- 'api',
432
- '--header',
433
- 'Accept: application/vnd.github.raw',
434
- `/repos/${repo}/contents/.github/workflows/fro-bot.yaml`,
435
- ])
436
-
437
- return interpretGhContentResult(result)
438
- }
439
-
440
- // Snippet uses 10-space indent to match the canonical `with:` block depth
441
- // in marcusrbrown/infra/.github/workflows/fro-bot.yaml, so users can paste
442
- // directly under their step's `with:` key without re-indenting.
443
- export function formatWorkflowSnippet(missingInputs: readonly string[]): string {
444
- /* eslint-disable no-template-curly-in-string -- GitHub Actions expression syntax, not JS template literals */
445
- const inputMap: Record<string, string> = {
446
- 'auth-json': 'auth-json: ${{ secrets.OPENCODE_AUTH_JSON }}',
447
- 'opencode-config': 'opencode-config: ${{ secrets.OPENCODE_CONFIG }}',
448
- 'omo-providers': 'omo-providers: ${{ secrets.OMO_PROVIDERS }}',
449
- model: 'model: ${{ vars.FRO_BOT_MODEL }}',
450
- }
451
- /* eslint-enable no-template-curly-in-string */
452
- return missingInputs.map(input => ` ${inputMap[input]}`).join('\n')
453
- }
454
-
455
- async function assertProxyReachable(baseUrl: string): Promise<void> {
456
- try {
457
- const response = await fetch(baseUrl, {
458
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
459
- })
460
-
461
- if (!response.ok) {
462
- throw new Error(`Proxy check failed for ${baseUrl}: HTTP ${response.status}. Is the proxy running and reachable?`)
463
- }
464
- } catch (error) {
465
- if (error instanceof Error && error.message.startsWith('Proxy check failed')) {
466
- throw error
467
- }
468
- throw new Error(`Unable to reach proxy at ${baseUrl}: ${extractErrorMessage(error)}`)
469
- }
470
- }
471
-
472
- async function assertProxyKeyWorks(baseUrl: string, keyValue: string): Promise<void> {
473
- try {
474
- const response = await fetch(`${baseUrl}/v1/models`, {
475
- headers: {
476
- authorization: `Bearer ${keyValue}`,
477
- },
478
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
479
- })
480
-
481
- if (!response.ok) {
482
- throw new Error(`Proxy key verification failed with HTTP ${response.status}`)
483
- }
484
- } catch (error) {
485
- if (error instanceof Error && error.message.startsWith('Proxy key verification')) {
486
- throw error
487
- }
488
- throw new Error(`Unable to verify proxy key at ${baseUrl}: ${extractErrorMessage(error)}`)
489
- }
490
- }
491
-
492
- async function requestJson(endpoint: string, init: RequestInit): Promise<unknown> {
493
- const response = await fetch(endpoint, {
494
- ...init,
495
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
496
- })
497
-
498
- if (!response.ok) {
499
- const body = await response.text()
500
- throw new Error(`${init.method ?? 'GET'} ${endpoint} failed with HTTP ${response.status}: ${body}`)
501
- }
502
-
503
- try {
504
- return await response.json()
505
- } catch {
506
- return null
507
- }
508
- }
509
-
510
- function managementHeaders(key: string): Headers {
511
- const headers = new Headers()
512
- headers.set('x-management-key', key)
513
- headers.set('content-type', 'application/json')
514
- return headers
515
- }
516
-
517
- function buildApiKeyValue(keyName: string): string {
518
- const slug = (
519
- keyName
520
- .trim()
521
- .toLowerCase()
522
- .match(/[a-z0-9]+/g) ?? []
523
- )
524
- .join('-')
525
- .slice(0, 24)
526
- const random = crypto.randomUUID().split('-').join('')
527
- return `sk-${slug || 'cliproxy'}-${random}`
528
- }
529
-
530
- async function createManagementApiKey(baseUrl: string, managementKey: string, keyValue: string): Promise<void> {
531
- const endpoint = `${baseUrl}/v0/management/api-keys`
532
- const currentPayload = await requestJson(endpoint, {
533
- method: 'GET',
534
- headers: managementHeaders(managementKey),
535
- })
536
- const currentKeys = toStringArray(currentPayload)
537
-
538
- if (currentKeys.includes(keyValue)) {
539
- return
540
- }
541
-
542
- await requestJson(endpoint, {
543
- method: 'PUT',
544
- headers: managementHeaders(managementKey),
545
- body: JSON.stringify([...currentKeys, keyValue]),
546
- })
547
- }
548
-
549
- async function deleteManagementApiKey(baseUrl: string, managementKey: string, keyValue: string): Promise<void> {
550
- const endpoint = `${baseUrl}/v0/management/api-keys`
551
- const currentPayload = await requestJson(endpoint, {
552
- method: 'GET',
553
- headers: managementHeaders(managementKey),
554
- })
555
- const currentKeys = toStringArray(currentPayload)
556
- const filtered = currentKeys.filter(k => k !== keyValue)
557
-
558
- if (filtered.length === currentKeys.length) {
559
- return
560
- }
561
-
562
- await requestJson(endpoint, {
563
- method: 'PUT',
564
- headers: managementHeaders(managementKey),
565
- body: JSON.stringify(filtered),
566
- })
567
- }
568
-
569
- async function applyGhValue(kind: 'secret' | 'variable', name: string, repo: string, value: string): Promise<void> {
570
- if (kind === 'secret') {
571
- const child = Bun.spawn(['gh', 'secret', 'set', name, '--repo', repo], {
572
- stdin: new Blob([value]).stream(),
573
- stdout: 'pipe',
574
- stderr: 'pipe',
575
- env: process.env,
576
- })
577
-
578
- const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited])
579
-
580
- if (exitCode !== 0) {
581
- throw new Error(`gh secret set ${name} failed: ${stderr.trim()}`.trim())
582
- }
583
- return
584
- }
585
-
586
- const result = await runGh([kind, 'set', name, '--repo', repo, '--body', value])
587
- if (result.exitCode !== 0) {
588
- throw new Error(`gh ${kind} set ${name} failed: ${result.stderr.trim()}`.trim())
589
- }
590
- }
591
-
592
- function formatTemplateSummary(template: HarnessTemplate): string {
593
- const secretLines = template.secrets.map(secret => `- secret ${secret.name}`)
594
- const variableLines = template.variables.map(variable => `- variable ${variable.name}`)
595
- return [...secretLines, ...variableLines].join('\n')
596
- }
597
-
598
- function collectCollisions(
599
- template: HarnessTemplate,
600
- existingSecrets: string[],
601
- existingVariables: string[],
602
- ): string[] {
603
- const collisions: string[] = []
604
-
605
- for (const secret of template.secrets) {
606
- if (existingSecrets.includes(secret.name)) {
607
- collisions.push(`secret ${secret.name}`)
608
- }
609
- }
610
-
611
- for (const variable of template.variables) {
612
- if (existingVariables.includes(variable.name)) {
613
- collisions.push(`variable ${variable.name}`)
614
- }
615
- }
616
-
617
- return collisions
618
- }
619
-
620
- async function promptGenericSecretNames(): Promise<GenericSecretNames> {
621
- const apiKeySecretName = ensureSecretName(
622
- await promptValue(
623
- text({
624
- message: 'Name for the API key secret',
625
- placeholder: 'CLIPROXY_API_KEY',
626
- validate: value => {
627
- try {
628
- ensureSecretName(value ?? '', 'API key secret name')
629
- return undefined
630
- } catch (error) {
631
- return extractErrorMessage(error)
632
- }
633
- },
634
- }),
635
- 'Setup cancelled before choosing the generic API key secret name.',
636
- ),
637
- 'API key secret name',
638
- )
639
-
640
- const baseUrlSecretName = ensureSecretName(
641
- await promptValue(
642
- text({
643
- message: 'Name for the proxy base URL secret',
644
- placeholder: 'CLIPROXY_BASE_URL',
645
- validate: value => {
646
- try {
647
- ensureSecretName(value ?? '', 'Base URL secret name')
648
- return undefined
649
- } catch (error) {
650
- return extractErrorMessage(error)
651
- }
652
- },
653
- }),
654
- 'Setup cancelled before choosing the generic base URL secret name.',
655
- ),
656
- 'Base URL secret name',
657
- )
658
-
659
- return {apiKeySecretName, baseUrlSecretName}
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)}`
660
118
  }
661
119
 
662
- async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Promise<SetupPlan> {
120
+ async function buildInteractivePlan(
121
+ options: SetupOptions,
122
+ baseUrl: string,
123
+ promptsImpl: Required<RunSetupDeps>['prompts'],
124
+ ): Promise<SetupPlan> {
663
125
  const createKey = !options.key
664
126
  const keyName = createKey
665
- ? await promptValue(
127
+ ? await promptsImpl.promptValue(
666
128
  text({
667
129
  message: 'Name this new CLIProxyAPI key',
668
130
  placeholder: 'my-repo-ci',
@@ -674,7 +136,7 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
674
136
 
675
137
  const harness =
676
138
  options.harness ??
677
- (await promptValue(
139
+ (await promptsImpl.promptValue(
678
140
  select<Harness>({
679
141
  message: 'Choose the harness to configure',
680
142
  options: [
@@ -689,7 +151,7 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
689
151
  const repo = options.repo
690
152
  ? ensureRepoFormat(options.repo)
691
153
  : ensureRepoFormat(
692
- await promptValue(
154
+ await promptsImpl.promptValue(
693
155
  text({
694
156
  message: 'Target GitHub repository',
695
157
  placeholder: 'owner/repo',
@@ -706,6 +168,14 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
706
168
  ),
707
169
  )
708
170
 
171
+ let providers: ProviderId[] | undefined
172
+ let model: string | undefined
173
+
174
+ if (harness === 'opencode') {
175
+ providers = await promptForProviders()
176
+ model = await promptForModel(providers)
177
+ }
178
+
709
179
  const keyValue = options.key ?? buildApiKeyValue(keyName ?? 'cliproxy')
710
180
  const genericSecretNames = harness === 'generic' ? await promptGenericSecretNames() : undefined
711
181
 
@@ -715,258 +185,501 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
715
185
  keyValue,
716
186
  keyName,
717
187
  createKey,
718
- template: getHarnessTemplate(harness, {keyValue, baseUrl, genericSecretNames}),
188
+ template: getHarnessTemplate(harness, {keyValue, baseUrl, genericSecretNames, providers, model}),
719
189
  }
720
190
  }
721
191
 
722
- function buildNonInteractivePlan(options: SetupOptions, baseUrl: string): SetupPlan {
192
+ /**
193
+ * Returns true when the provider list includes anything beyond anthropic-only.
194
+ * Anthropic-only repos see no behavior change (G7 invariant).
195
+ */
196
+ export function requiresDestructiveProviderChangeConfirmation(providers: ProviderId[]): boolean {
197
+ return !(providers.length === 1 && providers[0] === 'anthropic')
198
+ }
199
+
200
+ // Deprecated: use requiresDestructiveProviderChangeConfirmation. Will be removed in a future major.
201
+ export const mustConfirmDestructive = requiresDestructiveProviderChangeConfirmation
202
+
203
+ export async function buildNonInteractivePlan(
204
+ options: SetupOptions,
205
+ baseUrl: string,
206
+ deps?: Pick<RunSetupDeps, 'validation'>,
207
+ ): Promise<SetupPlan> {
723
208
  const harness = harnessSchema.parse(options.harness)
724
209
  const repo = ensureRepoFormat(options.repo ?? '')
725
210
  const keyValue = options.key ?? ''
726
211
 
212
+ const providers: ProviderId[] = options.providers ? parseProviders(options.providers) : ['anthropic']
213
+
214
+ let model: string
215
+ if (options.model) {
216
+ model = options.model
217
+ } else if (providers.length === 1) {
218
+ model = PROVIDER_DEFAULTS[providers[0] as ProviderId]
219
+ } else {
220
+ // Unreachable: validateSetupOptions enforces model when providers.length > 1
221
+ throw new Error('Pass --model <provider/model-id> when selecting multiple providers.')
222
+ }
223
+
224
+ // --dry-run: skip verifyModelsAvailable and force check; return plan for preview
225
+ if (options.dryRun) {
226
+ return {
227
+ repo,
228
+ harness,
229
+ keyValue,
230
+ createKey: false,
231
+ template: getHarnessTemplate(harness, {keyValue: keyValue || 'sk-placeholder', baseUrl, providers, model}),
232
+ }
233
+ }
234
+
235
+ const verifyModels = deps?.validation?.verifyModelsAvailable ?? verifyModelsAvailable
236
+
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) {
240
+ throw new Error(
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).`,
242
+ )
243
+ }
244
+
245
+ await verifyModels(baseUrl, keyValue, providers, model)
246
+
727
247
  return {
728
248
  repo,
729
249
  harness,
730
250
  keyValue,
731
251
  createKey: false,
732
- template: getHarnessTemplate(harness, {keyValue, baseUrl}),
252
+ template: getHarnessTemplate(harness, {keyValue, baseUrl, providers, model}),
253
+ }
254
+ }
255
+
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
733
335
  }
734
- }
735
336
 
736
- export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
737
- cli
738
- .command(
739
- 'cliproxy setup',
740
- 'Interactively onboard a GitHub repository to CLIProxyAPI by creating or reusing a key and wiring the required GitHub secrets and variables.',
741
- )
742
- .option(
743
- '--key [key]',
744
- z
745
- .string()
746
- .describe(
747
- 'Existing CLIProxyAPI API key value. When provided, setup skips key creation and reuses this key for GitHub secrets.',
748
- ),
749
- )
750
- .option(
751
- '--repo [repo]',
752
- z.string().describe('Target GitHub repository in owner/repo format. Skips the repository prompt when provided.'),
753
- )
754
- .option(
755
- '--harness [harness]',
756
- harnessSchema.describe(
757
- 'Harness template to configure. Choose opencode, claude-code, or generic. Generic remains interactive-only.',
758
- ),
759
- )
760
- .example('# Run the interactive onboarding wizard')
761
- .example('infra cliproxy setup')
762
- .example('# Run non-interactively with an existing key')
763
- .example('infra cliproxy setup --key sk-test --repo owner/repo --harness opencode')
764
- .action(async options => {
765
- const interactive = Boolean(process.stdin.isTTY)
766
- const baseUrl = resolveBaseUrl()
337
+ validateSetupOptions(options, interactive)
767
338
 
768
- validateSetupOptions(options, interactive)
339
+ if (interactive) {
340
+ prompts.intro('CLIProxyAPI setup wizard')
341
+ }
769
342
 
770
- if (interactive) {
771
- intro('CLIProxyAPI setup wizard')
772
- }
343
+ try {
344
+ await withSpinner('Checking GitHub CLI availability', async () => {
345
+ await gh.assertGhInstalled()
346
+ await gh.assertGhAuthenticated()
347
+ })
773
348
 
774
- try {
775
- await withSpinner('Checking GitHub CLI availability', async () => {
776
- await assertGhInstalled()
777
- await assertGhAuthenticated()
778
- })
349
+ await withSpinner('Checking CLIProxyAPI reachability', async () => {
350
+ await validation.assertProxyReachable(baseUrl)
351
+ })
779
352
 
780
- await withSpinner('Checking CLIProxyAPI reachability', async () => {
781
- await assertProxyReachable(baseUrl)
782
- })
353
+ const plan = interactive
354
+ ? await buildInteractivePlan(options, baseUrl, prompts)
355
+ : await buildNonInteractivePlan(options, baseUrl, {validation})
783
356
 
784
- const plan = interactive
785
- ? await buildInteractivePlan(options, baseUrl)
786
- : buildNonInteractivePlan(options, baseUrl)
357
+ if (plan.createKey) {
358
+ mgmtKeyResolver()
359
+ }
787
360
 
788
- if (plan.createKey) {
789
- resolveManagementKey()
790
- }
361
+ await gh.withGhRetry(
362
+ `Checking GitHub access for ${plan.repo}`,
363
+ async () => {
364
+ await gh.assertRepoAccess(plan.repo)
365
+ },
366
+ interactive,
367
+ )
791
368
 
792
- await withGhRetry(
793
- `Checking GitHub access for ${plan.repo}`,
794
- async () => {
795
- await assertRepoAccess(plan.repo)
796
- },
797
- interactive,
798
- )
369
+ if (options.key) {
370
+ log.info('Using the provided API key value directly. No new CLIProxyAPI key will be created.')
371
+ }
799
372
 
800
- if (options.key) {
801
- log.info('Using the provided API key value directly. No new CLIProxyAPI key will be created.')
802
- }
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
+ )
803
385
 
804
- if (interactive) {
805
- note(
806
- [
807
- `Proxy: ${baseUrl}`,
808
- `Repository: ${plan.repo}`,
809
- `Harness: ${plan.harness}`,
810
- plan.createKey ? `New key name: ${plan.keyName}` : 'Using existing key value',
811
- 'GitHub values to write:',
812
- formatTemplateSummary(plan.template),
813
- ].join('\n'),
814
- 'Setup summary',
815
- )
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
+ )
816
395
 
817
- const shouldContinue = await promptValue(
818
- confirm({
819
- message: 'Proceed with GitHub secret and variable updates?',
820
- active: 'yes',
821
- inactive: 'no',
822
- initialValue: true,
823
- }),
824
- 'Setup cancelled before applying GitHub values.',
825
- )
396
+ if (!shouldContinue) {
397
+ cancelAndExit('No changes applied.')
398
+ }
399
+ }
826
400
 
827
- if (!shouldContinue) {
828
- cancelAndExit('No changes applied.')
829
- }
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,
406
+ )
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.')
830
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
+ )
427
+ }
428
+ }
831
429
 
832
- const [existingSecrets, existingVariables] = await withGhRetry(
833
- 'Checking existing GitHub secrets and variables',
834
- async () =>
835
- Promise.all([listExistingGhNames(plan.repo, 'secret'), listExistingGhNames(plan.repo, 'variable')]),
836
- interactive,
430
+ const collisions = collectCollisions(plan.template, existingSecrets, existingVariables)
431
+
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).`,
837
436
  )
838
- const collisions = collectCollisions(plan.template, existingSecrets, existingVariables)
437
+ }
839
438
 
840
- if (collisions.length > 0) {
841
- if (!interactive) {
842
- throw new Error(
843
- `Refusing to overwrite existing GitHub values in non-interactive mode: ${collisions.join(', ')}`,
844
- )
845
- }
439
+ if (!interactive && options.force) {
440
+ log.warn(`Overwriting existing GitHub values: ${collisions.join(', ')}`)
441
+ // proceed
442
+ }
846
443
 
847
- log.warn(`Existing GitHub values will be overwritten: ${collisions.join(', ')}`)
848
- const overwrite = await promptValue(
849
- confirm({
850
- message: 'Overwrite the existing GitHub values?',
851
- active: 'overwrite',
852
- inactive: 'cancel',
853
- initialValue: false,
854
- }),
855
- 'Setup cancelled instead of overwriting existing values.',
856
- )
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
+ )
857
455
 
858
- if (!overwrite) {
859
- cancelAndExit('Existing GitHub values left unchanged.')
860
- }
456
+ if (!overwrite) {
457
+ cancelAndExit('Existing GitHub values left unchanged.')
861
458
  }
459
+ }
460
+ }
862
461
 
863
- let keyCreatedByThisRun = false
864
- const managementKey = plan.createKey ? resolveManagementKey() : undefined
462
+ let keyCreatedByThisRun = false
463
+ const managementKey = plan.createKey ? mgmtKeyResolver() : undefined
865
464
 
866
- if (plan.createKey && managementKey) {
867
- await withSpinner('Creating a new CLIProxyAPI key', async () => {
868
- await createManagementApiKey(baseUrl, managementKey, plan.keyValue)
869
- keyCreatedByThisRun = true
870
- })
871
- }
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
+ })
470
+ }
872
471
 
873
- try {
874
- await withGhRetry(
875
- 'Writing GitHub secrets and variables',
876
- async spinnerInstance => {
877
- for (const secret of plan.template.secrets) {
878
- spinnerInstance.message(`Setting secret ${secret.name}`)
879
- await applyGhValue('secret', secret.name, plan.repo, secret.value)
880
- }
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
+ }
881
480
 
882
- for (const variable of plan.template.variables) {
883
- spinnerInstance.message(`Setting variable ${variable.name}`)
884
- await applyGhValue('variable', variable.name, plan.repo, variable.value)
885
- }
886
- },
887
- interactive,
888
- )
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
+ )
889
488
 
890
- await withSpinner('Verifying the new key through the proxy', async () => {
891
- await assertProxyKeyWorks(baseUrl, plan.keyValue)
892
- })
893
-
894
- if (plan.harness === 'opencode') {
895
- const workflow = await withGhRetry(
896
- `Checking ${plan.repo} fro-bot.yaml wiring`,
897
- async () => {
898
- return checkFroBotWorkflow(plan.repo)
899
- },
900
- interactive,
901
- )
489
+ await withSpinner('Verifying the new key through the proxy', async () => {
490
+ await validation.assertProxyKeyWorks(baseUrl, plan.keyValue)
491
+ })
902
492
 
903
- switch (workflow.kind) {
904
- case 'missing': {
905
- log.warn(
906
- `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.`,
907
- )
908
- break
909
- }
910
- case 'unreachable': {
911
- log.warn(
912
- `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.`,
913
- )
914
- break
915
- }
916
- case 'no-agent-step': {
917
- log.warn(
918
- `${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.`,
919
- )
920
- break
921
- }
922
- case 'analyzed': {
923
- for (const step of workflow.stepsWithGaps) {
924
- const missing = [...step.missingInputs]
925
- log.warn(
926
- [
927
- `${plan.repo} .github/workflows/fro-bot.yaml fro-bot/agent step #${step.stepOrdinal} is missing ${missing.length} required input${
928
- missing.length > 1 ? 's' : ''
929
- } (${missing.join(', ')}).`,
930
- `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'}.`,
931
- '',
932
- `Add under the 'with:' block of the 'fro-bot/agent' step:`,
933
- formatWorkflowSnippet(missing),
934
- ].join('\n'),
935
- )
936
- }
937
- break
938
- }
939
- default: {
940
- const _exhaustive: never = workflow
941
- throw new Error(`Unhandled FroBotWorkflowCheck kind: ${JSON.stringify(_exhaustive)}`)
942
- }
943
- }
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
+ )
501
+
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
944
520
  }
945
- } catch (mutationError) {
946
- if (keyCreatedByThisRun && managementKey) {
947
- try {
948
- await deleteManagementApiKey(baseUrl, managementKey, plan.keyValue)
949
- log.warn('Rolled back the newly created CLIProxyAPI key after failure.')
950
- } catch {
521
+ case 'analyzed': {
522
+ for (const step of workflow.stepsWithGaps) {
523
+ const missing = [...step.missingInputs]
951
524
  log.warn(
952
- 'Failed to roll back the newly created CLIProxyAPI key. Remove it manually via: infra cliproxy keys remove',
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'),
953
534
  )
954
535
  }
536
+ break
955
537
  }
956
- throw mutationError
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.')
549
+ } catch {
550
+ log.warn(
551
+ 'Failed to roll back the newly created CLIProxyAPI key. Remove it manually via: infra cliproxy keys remove',
552
+ )
553
+ }
554
+ }
555
+ throw mutationError
556
+ }
557
+
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,
957
574
  }
575
+ })
576
+
577
+ // Machine-parseable hook for MCP/agent consumers
578
+ ctx.console.log(`[smoke-test] kind=${smokeResult.kind}`)
958
579
 
959
- if (interactive) {
960
- outro(`Setup complete for ${plan.repo}. The ${plan.harness} harness can now use ${baseUrl}/v1.`)
961
- } else {
962
- log.success(`Setup complete for ${plan.repo}.`)
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
963
588
  }
964
- } catch (error) {
965
- const message = extractErrorMessage(error)
966
- if (interactive) {
967
- cancel(message)
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)}`)
968
596
  }
969
- throw error
970
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
610
+ }
611
+ }
612
+
613
+ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
614
+ cli
615
+ .command(
616
+ 'cliproxy setup',
617
+ 'Interactively onboard a GitHub repository to CLIProxyAPI by creating or reusing a key and wiring the required GitHub secrets and variables.',
618
+ )
619
+ .option(
620
+ '--key [key]',
621
+ z
622
+ .string()
623
+ .describe(
624
+ 'Existing CLIProxyAPI API key value. When provided, setup skips key creation and reuses this key for GitHub secrets.',
625
+ ),
626
+ )
627
+ .option(
628
+ '--repo [repo]',
629
+ z.string().describe('Target GitHub repository in owner/repo format. Skips the repository prompt when provided.'),
630
+ )
631
+ .option(
632
+ '--harness [harness]',
633
+ harnessSchema.describe(
634
+ 'Harness template to configure. Choose opencode, claude-code, or generic. Generic remains interactive-only.',
635
+ ),
636
+ )
637
+ .option(
638
+ '--providers [providers]',
639
+ z
640
+ .string()
641
+ .describe(
642
+ 'Comma-separated list of providers to enable. Default: anthropic. Supported values: anthropic, openai. Example: --providers anthropic,openai',
643
+ ),
644
+ )
645
+ .option(
646
+ '--model [model]',
647
+ z
648
+ .string()
649
+ .regex(MODEL_ID_RE)
650
+ .describe(
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',
652
+ ),
653
+ )
654
+ .option(
655
+ '--force',
656
+ z.boolean().optional().describe('Overwrite existing GitHub secrets and variables without prompting.'),
657
+ )
658
+ .option('--dry-run', z.boolean().optional().describe('Print the plan without applying any changes.'))
659
+ .option(
660
+ '--verify-smoke',
661
+ z.boolean().optional().describe('Run a smoke test against the proxy after setup completes.'),
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')
674
+ .example('# Run the interactive onboarding wizard')
675
+ .example('infra cliproxy setup')
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})
971
684
  })
972
685
  }