@marcusrbrown/infra 0.6.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
|
-
|
|
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(
|
|
304
|
+
value: JSON.stringify(authObj),
|
|
114
305
|
},
|
|
115
306
|
{
|
|
116
307
|
name: 'OPENCODE_CONFIG',
|
|
117
|
-
value: JSON.stringify({provider:
|
|
308
|
+
value: JSON.stringify({provider: providerConfig}),
|
|
118
309
|
},
|
|
119
310
|
{
|
|
120
311
|
name: 'OMO_PROVIDERS',
|
|
121
|
-
value:
|
|
312
|
+
value: omoProviders,
|
|
122
313
|
},
|
|
123
314
|
],
|
|
124
315
|
variables: [
|
|
125
316
|
{
|
|
126
317
|
name: 'FRO_BOT_MODEL',
|
|
127
|
-
value:
|
|
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
|
-
|
|
776
|
-
await
|
|
777
|
-
|
|
778
|
-
|
|
1365
|
+
if (!options.dryRun) {
|
|
1366
|
+
await withSpinner('Checking GitHub CLI availability', async () => {
|
|
1367
|
+
await assertGhInstalled()
|
|
1368
|
+
await assertGhAuthenticated()
|
|
1369
|
+
})
|
|
779
1370
|
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
859
|
-
|
|
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) {
|