@marcusrbrown/infra 0.5.0 → 0.7.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.
@@ -3,7 +3,7 @@
3
3
  import type {SpinnerResult} from '@clack/prompts'
4
4
  import type {goke} from 'goke'
5
5
 
6
- import {cancel, confirm, intro, isCancel, log, note, outro, select, spinner, text} from '@clack/prompts'
6
+ import {cancel, confirm, intro, isCancel, log, multiselect, note, outro, select, spinner, text} from '@clack/prompts'
7
7
  import {z} from 'zod'
8
8
 
9
9
  import {resolveManagementKey} from './config'
@@ -11,8 +11,6 @@ import {toStringArray} from './keys'
11
11
 
12
12
  const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
13
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
14
 
17
15
  const harnessSchema = z.enum(['opencode', 'claude-code', 'generic'])
18
16
  const ghRepoViewSchema = z.object({
@@ -23,10 +21,146 @@ const ghNameListSchema = z.array(z.object({name: z.string()}))
23
21
 
24
22
  export type Harness = z.infer<typeof harnessSchema>
25
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
+
26
154
  export interface SetupOptions {
27
155
  key?: string
28
156
  repo?: string
29
157
  harness?: Harness
158
+ /** Raw comma-separated provider list string (e.g. "anthropic,openai"). Use parseProviders() to validate. */
159
+ providers?: string
160
+ model?: string
161
+ force?: boolean
162
+ dryRun?: boolean
163
+ verifySmoke?: boolean
30
164
  }
31
165
 
32
166
  export interface SecretAssignment {
@@ -77,7 +211,26 @@ export function validateSetupOptions(options: SetupOptions, isInteractive: boole
77
211
  return
78
212
  }
79
213
 
80
- if (!options.key) {
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) {
81
234
  throw new Error('--key is required when stdin is not a TTY. Provide an existing CLIProxyAPI key value.')
82
235
  }
83
236
 
@@ -100,31 +253,69 @@ export function getHarnessTemplate(
100
253
  keyValue?: string
101
254
  baseUrl?: string
102
255
  genericSecretNames?: GenericSecretNames
256
+ providers?: ProviderId[]
257
+ model?: string
103
258
  } = {},
104
259
  ): HarnessTemplate {
105
260
  const keyValue = values.keyValue ?? 'sk-placeholder'
106
261
  const baseUrl = stripTrailingSlash(values.baseUrl ?? DEFAULT_CLIPROXY_URL)
107
262
 
108
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
+
109
300
  return {
110
301
  secrets: [
111
302
  {
112
303
  name: 'OPENCODE_AUTH_JSON',
113
- value: JSON.stringify({anthropic: {type: 'api', key: keyValue}}),
304
+ value: JSON.stringify(authObj),
114
305
  },
115
306
  {
116
307
  name: 'OPENCODE_CONFIG',
117
- value: JSON.stringify({provider: {anthropic: {options: {baseURL: `${baseUrl}/v1`}}}}),
308
+ value: JSON.stringify({provider: providerConfig}),
118
309
  },
119
310
  {
120
311
  name: 'OMO_PROVIDERS',
121
- value: DEFAULT_OMO_PROVIDERS,
312
+ value: omoProviders,
122
313
  },
123
314
  ],
124
315
  variables: [
125
316
  {
126
317
  name: 'FRO_BOT_MODEL',
127
- value: DEFAULT_FRO_BOT_MODEL,
318
+ value: model,
128
319
  },
129
320
  ],
130
321
  }
@@ -344,6 +535,11 @@ export type FroBotWorkflowCheck =
344
535
  // github-token and prompt are intentionally excluded from this check:
345
536
  // github-token is harness-agnostic (PAT wiring, not secret-routing) and prompt is
346
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.
347
543
  const REQUIRED_OPENCODE_INPUTS = ['auth-json', 'opencode-config', 'omo-providers', 'model'] as const
348
544
 
349
545
  /**
@@ -452,6 +648,82 @@ export function formatWorkflowSnippet(missingInputs: readonly string[]): string
452
648
  return missingInputs.map(input => ` ${inputMap[input]}`).join('\n')
453
649
  }
454
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(
660
+ 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
+
455
727
  async function assertProxyReachable(baseUrl: string): Promise<void> {
456
728
  try {
457
729
  const response = await fetch(baseUrl, {
@@ -706,6 +978,14 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
706
978
  ),
707
979
  )
708
980
 
981
+ let providers: ProviderId[] | undefined
982
+ let model: string | undefined
983
+
984
+ if (harness === 'opencode') {
985
+ providers = await promptForProviders()
986
+ model = await promptForModel(providers)
987
+ }
988
+
709
989
  const keyValue = options.key ?? buildApiKeyValue(keyName ?? 'cliproxy')
710
990
  const genericSecretNames = harness === 'generic' ? await promptGenericSecretNames() : undefined
711
991
 
@@ -715,21 +995,303 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
715
995
  keyValue,
716
996
  keyName,
717
997
  createKey,
718
- template: getHarnessTemplate(harness, {keyValue, baseUrl, genericSecretNames}),
998
+ template: getHarnessTemplate(harness, {keyValue, baseUrl, genericSecretNames, providers, model}),
999
+ }
1000
+ }
1001
+
1002
+ /**
1003
+ * Returns true when the provider list includes anything beyond anthropic-only.
1004
+ * Anthropic-only repos see no behavior change (G7 invariant).
1005
+ */
1006
+ export function mustConfirmDestructive(providers: ProviderId[]): boolean {
1007
+ return !(providers.length === 1 && providers[0] === 'anthropic')
1008
+ }
1009
+
1010
+ export interface DryRunPreviewOptions {
1011
+ repo: string
1012
+ harness: Harness
1013
+ providers: ProviderId[]
1014
+ model: string
1015
+ template: HarnessTemplate
1016
+ }
1017
+
1018
+ /**
1019
+ * Format a dry-run preview string. The proxy key value is NEVER included —
1020
+ * it is rendered as `<proxy-key>` in all positions.
1021
+ */
1022
+ export function formatDryRunPreview(opts: DryRunPreviewOptions): string {
1023
+ const {repo, harness, providers, model, template} = opts
1024
+
1025
+ const lines: string[] = [
1026
+ `Dry run: cliproxy setup --harness ${harness}`,
1027
+ `Repository: ${repo}`,
1028
+ `Providers: ${providers.join(', ')}`,
1029
+ `Model: ${model}`,
1030
+ 'Planned secrets:',
1031
+ ]
1032
+
1033
+ for (const secret of template.secrets) {
1034
+ const size = new TextEncoder().encode(secret.value).byteLength
1035
+ lines.push(` - ${secret.name} (${size} bytes)`)
1036
+ }
1037
+
1038
+ lines.push('Planned variables:')
1039
+ for (const variable of template.variables) {
1040
+ lines.push(` - ${variable.name} = ${variable.value}`)
719
1041
  }
1042
+
1043
+ lines.push('Proxy key (redacted): <proxy-key>')
1044
+ lines.push('No mutations will be performed.')
1045
+
1046
+ return lines.join('\n')
720
1047
  }
721
1048
 
722
- function buildNonInteractivePlan(options: SetupOptions, baseUrl: string): SetupPlan {
1049
+ export async function buildNonInteractivePlan(options: SetupOptions, baseUrl: string): Promise<SetupPlan> {
723
1050
  const harness = harnessSchema.parse(options.harness)
724
1051
  const repo = ensureRepoFormat(options.repo ?? '')
725
1052
  const keyValue = options.key ?? ''
726
1053
 
1054
+ const providers: ProviderId[] = options.providers ? parseProviders(options.providers) : ['anthropic']
1055
+
1056
+ let model: string
1057
+ if (options.model) {
1058
+ model = options.model
1059
+ } else if (providers.length === 1) {
1060
+ model = PROVIDER_DEFAULTS[providers[0] as ProviderId]
1061
+ } else {
1062
+ // Unreachable: validateSetupOptions enforces model when providers.length > 1
1063
+ throw new Error('Pass --model <provider/model-id> when selecting multiple providers.')
1064
+ }
1065
+
1066
+ // --dry-run: skip verifyModelsAvailable and force check; return plan for preview
1067
+ if (options.dryRun) {
1068
+ return {
1069
+ repo,
1070
+ harness,
1071
+ keyValue,
1072
+ createKey: false,
1073
+ template: getHarnessTemplate(harness, {keyValue: keyValue || 'sk-placeholder', baseUrl, providers, model}),
1074
+ }
1075
+ }
1076
+
1077
+ await verifyModelsAvailable(baseUrl, keyValue, providers, model)
1078
+
1079
+ // Destructive overwrite gate: non-anthropic-only requires --force in non-interactive mode
1080
+ if (mustConfirmDestructive(providers) && !options.force) {
1081
+ 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.',
1083
+ )
1084
+ }
1085
+
727
1086
  return {
728
1087
  repo,
729
1088
  harness,
730
1089
  keyValue,
731
1090
  createKey: false,
732
- template: getHarnessTemplate(harness, {keyValue, baseUrl}),
1091
+ template: getHarnessTemplate(harness, {keyValue, baseUrl, providers, model}),
1092
+ }
1093
+ }
1094
+
1095
+ // ─── Smoke test runner ────────────────────────────────────────────────────────
1096
+
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}
1101
+
1102
+ interface GhRunEntry {
1103
+ databaseId: number
1104
+ status: string
1105
+ conclusion: string | null
1106
+ url: string
1107
+ createdAt: string
1108
+ }
1109
+
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
+ }
1117
+
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
1143
+ }
1144
+ await new Promise(resolve => setTimeout(resolve, ms))
1145
+ }
1146
+
1147
+ const repoUrl = `https://github.com/${repo}`
1148
+
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},
1155
+ )
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
1165
+ }
1166
+ }
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
+
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
+ }
1189
+
1190
+ // ── Step 3: Poll for the new run ──────────────────────────────────────────
1191
+ let latestMatchedRun: GhRunEntry | undefined
1192
+
1193
+ for (const BACKOFF_M of BACKOFF_MS) {
1194
+ await delayFn(BACKOFF_M ?? 60_000)
1195
+
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[]
1220
+ }
1221
+ } catch {
1222
+ // Parse/network error — retry on next poll
1223
+ continue
1224
+ }
1225
+
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
+ })
1234
+
1235
+ if (candidates.length === 0) {
1236
+ // Our run not visible yet — keep polling
1237
+ continue
1238
+ }
1239
+
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
1243
+
1244
+ const {status, conclusion, url: runUrl} = matched
1245
+
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
+ }
1250
+
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)'
1270
+ }
1271
+ } catch {
1272
+ logNote = ' (log fetch failed, but run conclusion is success)'
1273
+ }
1274
+ return {kind: 'pass', message: `Smoke test passed${logNote}`, runUrl}
1275
+ }
1276
+
1277
+ return {kind: 'fail', message: `Run completed with conclusion=${conclusion ?? 'unknown'}`, runUrl}
1278
+ }
1279
+
1280
+ // Still in progress (queued, in_progress) — continue polling
1281
+ }
1282
+
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
+ }
1291
+
1292
+ return {
1293
+ kind: 'unverified',
1294
+ message: `Smoke test trigger not yet visible; check ${repoUrl}/actions`,
733
1295
  }
734
1296
  }
735
1297
 
@@ -757,10 +1319,38 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
757
1319
  'Harness template to configure. Choose opencode, claude-code, or generic. Generic remains interactive-only.',
758
1320
  ),
759
1321
  )
1322
+ .option(
1323
+ '--providers [providers]',
1324
+ z
1325
+ .string()
1326
+ .describe(
1327
+ 'Comma-separated list of providers to enable. Supported values: anthropic, openai. Example: --providers anthropic,openai',
1328
+ ),
1329
+ )
1330
+ .option(
1331
+ '--model [model]',
1332
+ z
1333
+ .string()
1334
+ .regex(MODEL_ID_RE)
1335
+ .describe(
1336
+ 'Override the default model. Must be provider-prefixed and lowercase. Examples: anthropic/claude-sonnet-4-6, openai/gpt-4o',
1337
+ ),
1338
+ )
1339
+ .option(
1340
+ '--force',
1341
+ z.boolean().optional().describe('Overwrite existing GitHub secrets and variables without prompting.'),
1342
+ )
1343
+ .option('--dry-run', z.boolean().optional().describe('Print the plan without applying any changes.'))
1344
+ .option(
1345
+ '--verify-smoke',
1346
+ z.boolean().optional().describe('Run a smoke test against the proxy after setup completes.'),
1347
+ )
760
1348
  .example('# Run the interactive onboarding wizard')
761
1349
  .example('infra cliproxy setup')
762
1350
  .example('# Run non-interactively with an existing key')
763
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')
764
1354
  .action(async options => {
765
1355
  const interactive = Boolean(process.stdin.isTTY)
766
1356
  const baseUrl = resolveBaseUrl()
@@ -772,18 +1362,35 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
772
1362
  }
773
1363
 
774
1364
  try {
775
- await withSpinner('Checking GitHub CLI availability', async () => {
776
- await assertGhInstalled()
777
- await assertGhAuthenticated()
778
- })
1365
+ if (!options.dryRun) {
1366
+ await withSpinner('Checking GitHub CLI availability', async () => {
1367
+ await assertGhInstalled()
1368
+ await assertGhAuthenticated()
1369
+ })
779
1370
 
780
- await withSpinner('Checking CLIProxyAPI reachability', async () => {
781
- await assertProxyReachable(baseUrl)
782
- })
1371
+ await withSpinner('Checking CLIProxyAPI reachability', async () => {
1372
+ await assertProxyReachable(baseUrl)
1373
+ })
1374
+ }
783
1375
 
784
1376
  const plan = interactive
785
1377
  ? await buildInteractivePlan(options, baseUrl)
786
- : buildNonInteractivePlan(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
+ }
787
1394
 
788
1395
  if (plan.createKey) {
789
1396
  resolveManagementKey()
@@ -838,25 +1445,32 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
838
1445
  const collisions = collectCollisions(plan.template, existingSecrets, existingVariables)
839
1446
 
840
1447
  if (collisions.length > 0) {
841
- if (!interactive) {
1448
+ if (!interactive && !options.force) {
842
1449
  throw new Error(
843
- `Refusing to overwrite existing GitHub values in non-interactive mode: ${collisions.join(', ')}`,
1450
+ `Refusing to overwrite existing GitHub values in non-interactive mode: ${collisions.join(', ')}. Pass --force to confirm.`,
844
1451
  )
845
1452
  }
846
1453
 
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
- )
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
+ )
857
1470
 
858
- if (!overwrite) {
859
- cancelAndExit('Existing GitHub values left unchanged.')
1471
+ if (!overwrite) {
1472
+ cancelAndExit('Existing GitHub values left unchanged.')
1473
+ }
860
1474
  }
861
1475
  }
862
1476
 
@@ -961,6 +1575,39 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
961
1575
  } else {
962
1576
  log.success(`Setup complete for ${plan.repo}.`)
963
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
+ }
964
1611
  } catch (error) {
965
1612
  const message = extractErrorMessage(error)
966
1613
  if (interactive) {