@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.
@@ -0,0 +1,182 @@
1
+ import type {SetupOptions} from '../setup'
2
+
3
+ import {z} from 'zod'
4
+
5
+ import {parseProviders, type ProviderId} from './providers'
6
+
7
+ // Permissive schema: unknown fields preserved via passthrough()
8
+ const modelEntrySchema = z
9
+ .object({
10
+ id: z.string(),
11
+ owned_by: z.string(),
12
+ })
13
+ .passthrough()
14
+
15
+ const modelsResponseSchema = z
16
+ .object({
17
+ data: z.array(modelEntrySchema),
18
+ })
19
+ .passthrough()
20
+
21
+ export const MODEL_ID_RE = /^(?:anthropic|openai)\/[a-z\d](?:[a-z\d.\-]*[a-z\d])?$/
22
+
23
+ const HTTP_TIMEOUT_MS = 10_000
24
+
25
+ /** Local copy — avoids a circular import with setup.ts (validation → setup → validation). */
26
+ function extractErrorMessage(error: unknown): string {
27
+ return error instanceof Error ? error.message : String(error)
28
+ }
29
+
30
+ export function validateSetupOptions(options: SetupOptions, isInteractive: boolean): void {
31
+ if (isInteractive) {
32
+ return
33
+ }
34
+
35
+ // Validate providers/model first (independent of key/repo/harness)
36
+ if (options.providers) {
37
+ const providers = parseProviders(options.providers)
38
+
39
+ if (providers.length > 1 && !options.model) {
40
+ throw new Error('Pass --model <provider/model-id> when selecting multiple providers.')
41
+ }
42
+
43
+ if (options.model) {
44
+ const slashIndex = options.model.indexOf('/')
45
+ const prefix = slashIndex === -1 ? options.model : options.model.slice(0, slashIndex)
46
+ if (!providers.includes(prefix as ProviderId)) {
47
+ throw new Error(
48
+ `Model prefix ${prefix} does not match selected providers (${providers.join(', ')}). Valid prefixes: ${providers.join(', ')}/`,
49
+ )
50
+ }
51
+ }
52
+ }
53
+
54
+ if (!options.dryRun && !options.key) {
55
+ throw new Error('--key is required when stdin is not a TTY. Provide an existing CLIProxyAPI key value.')
56
+ }
57
+
58
+ if (!options.repo) {
59
+ throw new Error('--repo is required when stdin is not a TTY. Provide the target GitHub repository as owner/repo.')
60
+ }
61
+
62
+ if (!options.harness) {
63
+ throw new Error('--harness is required when stdin is not a TTY. Choose opencode or claude-code.')
64
+ }
65
+
66
+ if (options.harness === 'generic') {
67
+ throw new Error('--harness generic is interactive-only because it requires custom secret names.')
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Verifies that the required models are available on the proxy.
73
+ *
74
+ * Short-circuits immediately for anthropic-only setups — no fetch is made.
75
+ * Never echoes the Authorization header in any error message.
76
+ */
77
+ export async function verifyModelsAvailable(
78
+ baseUrl: string,
79
+ key: string,
80
+ providers: ProviderId[],
81
+ model: string,
82
+ ): Promise<void> {
83
+ // Anthropic-only: no fetch needed
84
+ if (providers.length === 1 && providers[0] === 'anthropic') {
85
+ return
86
+ }
87
+
88
+ const endpoint = `${baseUrl}/v1/models`
89
+ const response = await fetch(endpoint, {
90
+ headers: {Authorization: `Bearer ${key}`},
91
+ signal: AbortSignal.timeout(10_000),
92
+ })
93
+
94
+ if (response.status === 401 || response.status === 403) {
95
+ throw new Error('Proxy key rejected. Verify with `cliproxy keys list` or rerun setup to create a new one.')
96
+ }
97
+
98
+ if (!response.ok) {
99
+ const rawBody = await response.text()
100
+ // Redact any Authorization headers or sk-* token-shaped strings that the server might echo
101
+ const redacted = rawBody
102
+ .replaceAll(/Bearer\s+[^\s"]+/g, 'Bearer <redacted>')
103
+ .replaceAll(/sk-[\w.-]{8,}/g, 'sk-<redacted>')
104
+ const excerpt = redacted.slice(0, 200)
105
+ throw new Error(`/v1/models returned HTTP ${response.status}: ${excerpt}`)
106
+ }
107
+
108
+ const json = (await response.json()) as unknown
109
+
110
+ let parsed: z.infer<typeof modelsResponseSchema>
111
+ try {
112
+ parsed = modelsResponseSchema.parse(json)
113
+ } catch (error) {
114
+ const message =
115
+ error instanceof z.ZodError
116
+ ? error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; ')
117
+ : extractErrorMessage(error)
118
+ throw new Error(`Malformed /v1/models response: ${message}`)
119
+ }
120
+
121
+ const entries = parsed.data
122
+
123
+ // OpenAI presence check
124
+ if (providers.includes('openai')) {
125
+ const hasOpenAi = entries.some(e => e.owned_by === 'openai')
126
+ if (!hasOpenAi) {
127
+ throw new Error('No OpenAI models on proxy — is the Codex token loaded? Try `cliproxy login codex`.')
128
+ }
129
+ }
130
+
131
+ // Model presence check: strip provider prefix to get bare id
132
+ const slashIndex = model.indexOf('/')
133
+ const bareId = slashIndex === -1 ? model : model.slice(slashIndex + 1)
134
+ const providerPrefix = slashIndex >= 0 ? model.slice(0, slashIndex) : undefined
135
+
136
+ const modelPresent = entries.some(e => e.id === bareId)
137
+ if (!modelPresent) {
138
+ // List available ids for the matching provider only
139
+ const matchingIds = providerPrefix
140
+ ? entries.filter(e => e.owned_by === providerPrefix).map(e => e.id)
141
+ : entries.map(e => e.id)
142
+ const available = matchingIds.length > 0 ? matchingIds.join(', ') : '(none)'
143
+ throw new Error(`Model "${bareId}" not found on proxy. Available ${providerPrefix ?? 'models'}: ${available}`)
144
+ }
145
+ }
146
+
147
+ export async function assertProxyReachable(baseUrl: string): Promise<void> {
148
+ try {
149
+ const response = await fetch(baseUrl, {
150
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
151
+ })
152
+
153
+ if (!response.ok) {
154
+ throw new Error(`Proxy check failed for ${baseUrl}: HTTP ${response.status}. Is the proxy running and reachable?`)
155
+ }
156
+ } catch (error) {
157
+ if (error instanceof Error && error.message.startsWith('Proxy check failed')) {
158
+ throw error
159
+ }
160
+ throw new Error(`Unable to reach proxy at ${baseUrl}: ${extractErrorMessage(error)}`)
161
+ }
162
+ }
163
+
164
+ export async function assertProxyKeyWorks(baseUrl: string, keyValue: string): Promise<void> {
165
+ try {
166
+ const response = await fetch(`${baseUrl}/v1/models`, {
167
+ headers: {
168
+ authorization: `Bearer ${keyValue}`,
169
+ },
170
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
171
+ })
172
+
173
+ if (!response.ok) {
174
+ throw new Error(`Proxy key verification failed with HTTP ${response.status}`)
175
+ }
176
+ } catch (error) {
177
+ if (error instanceof Error && error.message.startsWith('Proxy key verification')) {
178
+ throw error
179
+ }
180
+ throw new Error(`Unable to verify proxy key at ${baseUrl}: ${extractErrorMessage(error)}`)
181
+ }
182
+ }
@@ -0,0 +1,341 @@
1
+ /// <reference types="bun" />
2
+
3
+ import {describe, expect, it} from 'bun:test'
4
+
5
+ import {
6
+ analyzeFroBotWorkflow,
7
+ findFroBotAgentStepBodies,
8
+ formatWorkflowSnippet,
9
+ interpretGhContentResult,
10
+ } from './workflow-analyzer'
11
+
12
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
13
+
14
+ const COMPLETE_WORKFLOW = ` - uses: fro-bot/agent@abc123
15
+ with:
16
+ github-token: \${{ secrets.FRO_BOT_PAT }}
17
+ auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
18
+ model: \${{ vars.FRO_BOT_MODEL }}
19
+ omo-providers: \${{ secrets.OMO_PROVIDERS }}
20
+ opencode-config: \${{ secrets.OPENCODE_CONFIG }}
21
+ prompt: \${{ env.PROMPT }}
22
+ `
23
+
24
+ const MISSING_OPENCODE_CONFIG_WORKFLOW = ` - uses: fro-bot/agent@abc123
25
+ with:
26
+ auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
27
+ github-token: \${{ secrets.FRO_BOT_PAT }}
28
+ model: \${{ vars.FRO_BOT_MODEL }}
29
+ omo-providers: \${{ secrets.OMO_PROVIDERS }}
30
+ prompt: \${{ env.PROMPT }}
31
+ `
32
+
33
+ // Regression fixture for PR #125 review: a sibling step has `model:` as an input,
34
+ // but the fro-bot/agent step is missing it. The step-scoped scan must still flag
35
+ // `model` as missing, otherwise the diagnostic is silently suppressed.
36
+ const SIBLING_STEP_SHADOWS_MODEL_INPUT = `name: ci
37
+ on: [push]
38
+ jobs:
39
+ run:
40
+ runs-on: ubuntu-latest
41
+ strategy:
42
+ matrix:
43
+ model: [opus, sonnet]
44
+ steps:
45
+ - uses: actions/some-ai-step@abc
46
+ with:
47
+ model: \${{ matrix.model }}
48
+ - name: Run Fro Bot
49
+ uses: fro-bot/agent@def
50
+ with:
51
+ auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
52
+ opencode-config: \${{ secrets.OPENCODE_CONFIG }}
53
+ omo-providers: \${{ secrets.OMO_PROVIDERS }}
54
+ `
55
+
56
+ const WORKFLOW_WITHOUT_FRO_BOT_AGENT = `name: ci
57
+ on: [push]
58
+ jobs:
59
+ build:
60
+ runs-on: ubuntu-latest
61
+ steps:
62
+ - uses: actions/checkout@v4
63
+ - run: echo hello
64
+ `
65
+
66
+ // Follow-up from PR #125 second review: the matchAll refactor must report gaps
67
+ // in any fro-bot/agent step, not just the first. Step #1 is complete, step #2 is
68
+ // missing opencode-config and model — the analyzer should flag only step #2.
69
+ const TWO_AGENT_STEPS_SECOND_BROKEN = `name: fro-bot
70
+ on: [pull_request, schedule]
71
+ jobs:
72
+ review:
73
+ runs-on: ubuntu-latest
74
+ steps:
75
+ - name: Run Fro Bot review
76
+ uses: fro-bot/agent@abc123
77
+ with:
78
+ auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
79
+ opencode-config: \${{ secrets.OPENCODE_CONFIG }}
80
+ omo-providers: \${{ secrets.OMO_PROVIDERS }}
81
+ model: \${{ vars.FRO_BOT_MODEL }}
82
+ dispatch:
83
+ runs-on: ubuntu-latest
84
+ steps:
85
+ - name: Run Fro Bot dispatch
86
+ uses: fro-bot/agent@abc123
87
+ with:
88
+ auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
89
+ omo-providers: \${{ secrets.OMO_PROVIDERS }}
90
+ `
91
+
92
+ // openai model prefix regression fixtures
93
+ const WORKFLOW_WITH_OPENAI_MODEL = ` - uses: fro-bot/agent@abc123
94
+ with:
95
+ github-token: \${{ secrets.FRO_BOT_PAT }}
96
+ auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
97
+ model: openai/gpt-5.4-mini
98
+ omo-providers: \${{ secrets.OMO_PROVIDERS }}
99
+ opencode-config: \${{ secrets.OPENCODE_CONFIG }}
100
+ prompt: \${{ env.PROMPT }}
101
+ `
102
+
103
+ // Dual-provider hints: omo-providers value contains "openai", model is openai/...
104
+ const WORKFLOW_WITH_DUAL_PROVIDER_HINTS = `name: fro-bot
105
+ on: [pull_request]
106
+ jobs:
107
+ review:
108
+ runs-on: ubuntu-latest
109
+ steps:
110
+ - name: Run Fro Bot
111
+ uses: fro-bot/agent@abc123
112
+ with:
113
+ github-token: \${{ secrets.FRO_BOT_PAT }}
114
+ auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
115
+ model: openai/gpt-5.4-mini
116
+ omo-providers: anthropic,openai
117
+ opencode-config: \${{ secrets.OPENCODE_CONFIG }}
118
+ prompt: \${{ env.PROMPT }}
119
+ `
120
+
121
+ // Missing opencode-config but with openai model prefix — gap detection must still fire
122
+ const MISSING_OPENCODE_CONFIG_OPENAI_MODEL_WORKFLOW = ` - uses: fro-bot/agent@abc123
123
+ with:
124
+ auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
125
+ github-token: \${{ secrets.FRO_BOT_PAT }}
126
+ model: openai/gpt-5.4-mini
127
+ omo-providers: \${{ secrets.OMO_PROVIDERS }}
128
+ prompt: \${{ env.PROMPT }}
129
+ `
130
+
131
+ // ─── Tests ────────────────────────────────────────────────────────────────────
132
+
133
+ describe('cliproxy setup helpers', () => {
134
+ describe('analyzeFroBotWorkflow', () => {
135
+ it('returns empty stepsWithGaps when all four inputs are wired', () => {
136
+ const result = analyzeFroBotWorkflow(COMPLETE_WORKFLOW)
137
+
138
+ expect(result.kind).toBe('analyzed')
139
+ if (result.kind !== 'analyzed') throw new Error('unreachable')
140
+ expect(result.stepsWithGaps).toEqual([])
141
+ })
142
+
143
+ it('detects a missing opencode-config input on step #1', () => {
144
+ const result = analyzeFroBotWorkflow(MISSING_OPENCODE_CONFIG_WORKFLOW)
145
+
146
+ expect(result.kind).toBe('analyzed')
147
+ if (result.kind !== 'analyzed') throw new Error('unreachable')
148
+ expect(result.stepsWithGaps).toHaveLength(1)
149
+ expect(result.stepsWithGaps[0]?.stepOrdinal).toBe(1)
150
+ expect([...(result.stepsWithGaps[0]?.missingInputs ?? [])]).toEqual(['opencode-config'])
151
+ })
152
+
153
+ it('flags model as missing even when a sibling step uses model: as an input', () => {
154
+ const result = analyzeFroBotWorkflow(SIBLING_STEP_SHADOWS_MODEL_INPUT)
155
+
156
+ expect(result.kind).toBe('analyzed')
157
+ if (result.kind !== 'analyzed') throw new Error('unreachable')
158
+ expect(result.stepsWithGaps).toHaveLength(1)
159
+ expect(result.stepsWithGaps[0]?.stepOrdinal).toBe(1)
160
+ expect([...(result.stepsWithGaps[0]?.missingInputs ?? [])]).toEqual(['model'])
161
+ })
162
+
163
+ it('returns kind no-agent-step when the workflow has no fro-bot/agent step', () => {
164
+ const result = analyzeFroBotWorkflow(WORKFLOW_WITHOUT_FRO_BOT_AGENT)
165
+
166
+ expect(result.kind).toBe('no-agent-step')
167
+ })
168
+
169
+ it('returns kind no-agent-step for empty content', () => {
170
+ const result = analyzeFroBotWorkflow('')
171
+
172
+ expect(result.kind).toBe('no-agent-step')
173
+ })
174
+
175
+ it('reports only the broken step when a workflow has two fro-bot/agent steps and one is complete', () => {
176
+ const result = analyzeFroBotWorkflow(TWO_AGENT_STEPS_SECOND_BROKEN)
177
+
178
+ expect(result.kind).toBe('analyzed')
179
+ if (result.kind !== 'analyzed') throw new Error('unreachable')
180
+ expect(result.stepsWithGaps).toHaveLength(1)
181
+ expect(result.stepsWithGaps[0]?.stepOrdinal).toBe(2)
182
+ expect([...(result.stepsWithGaps[0]?.missingInputs ?? [])]).toEqual(['opencode-config', 'model'])
183
+ })
184
+ })
185
+
186
+ describe('analyzer regression for openai model prefix', () => {
187
+ it('returns empty stepsWithGaps for a workflow with openai/... model and all four inputs', () => {
188
+ const result = analyzeFroBotWorkflow(WORKFLOW_WITH_OPENAI_MODEL)
189
+
190
+ expect(result.kind).toBe('analyzed')
191
+ if (result.kind !== 'analyzed') throw new Error('unreachable')
192
+ expect(result.stepsWithGaps).toEqual([])
193
+ })
194
+
195
+ it('returns empty stepsWithGaps for a dual-provider workflow with openai/... model', () => {
196
+ const result = analyzeFroBotWorkflow(WORKFLOW_WITH_DUAL_PROVIDER_HINTS)
197
+
198
+ expect(result.kind).toBe('analyzed')
199
+ if (result.kind !== 'analyzed') throw new Error('unreachable')
200
+ expect(result.stepsWithGaps).toEqual([])
201
+ })
202
+
203
+ it('detects missing opencode-config even when model is openai/...', () => {
204
+ const result = analyzeFroBotWorkflow(MISSING_OPENCODE_CONFIG_OPENAI_MODEL_WORKFLOW)
205
+
206
+ expect(result.kind).toBe('analyzed')
207
+ if (result.kind !== 'analyzed') throw new Error('unreachable')
208
+ expect(result.stepsWithGaps).toHaveLength(1)
209
+ expect(result.stepsWithGaps[0]?.stepOrdinal).toBe(1)
210
+ expect([...(result.stepsWithGaps[0]?.missingInputs ?? [])]).toEqual(['opencode-config'])
211
+ })
212
+
213
+ it('detects missing opencode-config when model is anthropic/... (sanity regression)', () => {
214
+ const result = analyzeFroBotWorkflow(MISSING_OPENCODE_CONFIG_WORKFLOW)
215
+
216
+ expect(result.kind).toBe('analyzed')
217
+ if (result.kind !== 'analyzed') throw new Error('unreachable')
218
+ expect(result.stepsWithGaps).toHaveLength(1)
219
+ expect(result.stepsWithGaps[0]?.stepOrdinal).toBe(1)
220
+ expect([...(result.stepsWithGaps[0]?.missingInputs ?? [])]).toEqual(['opencode-config'])
221
+ })
222
+
223
+ it('does not emit any enable-omo warning for openai model workflows', () => {
224
+ const openaiResult = analyzeFroBotWorkflow(WORKFLOW_WITH_OPENAI_MODEL)
225
+ const dualResult = analyzeFroBotWorkflow(WORKFLOW_WITH_DUAL_PROVIDER_HINTS)
226
+
227
+ // The analyzer result shape has no warning category — only stepsWithGaps.
228
+ // Verify the result object has exactly the expected keys (kind + stepsWithGaps).
229
+ expect(Object.keys(openaiResult)).toEqual(['kind', 'stepsWithGaps'])
230
+ expect(Object.keys(dualResult)).toEqual(['kind', 'stepsWithGaps'])
231
+ })
232
+
233
+ it('REQUIRED_OPENCODE_INPUTS covers exactly auth-json, opencode-config, omo-providers, model (no enable-omo)', () => {
234
+ // Infer the required inputs from fixture-based testing: a workflow with exactly
235
+ // these four inputs and no others (besides github-token and prompt) passes with zero gaps.
236
+ const result = analyzeFroBotWorkflow(WORKFLOW_WITH_OPENAI_MODEL)
237
+
238
+ expect(result.kind).toBe('analyzed')
239
+ if (result.kind !== 'analyzed') throw new Error('unreachable')
240
+ // Zero gaps confirms the four inputs in the fixture are sufficient — enable-omo is NOT required.
241
+ expect(result.stepsWithGaps).toEqual([])
242
+ })
243
+ })
244
+
245
+ describe('interpretGhContentResult', () => {
246
+ it('returns kind missing when stderr contains HTTP 404', () => {
247
+ const result = interpretGhContentResult({
248
+ exitCode: 1,
249
+ stdout: '',
250
+ stderr: 'gh: Not Found (HTTP 404)',
251
+ })
252
+
253
+ expect(result.kind).toBe('missing')
254
+ })
255
+
256
+ it('returns kind unreachable with the stderr reason on non-404 failures', () => {
257
+ const result = interpretGhContentResult({
258
+ exitCode: 1,
259
+ stdout: '',
260
+ stderr: 'gh: API rate limit exceeded',
261
+ })
262
+
263
+ expect(result.kind).toBe('unreachable')
264
+ if (result.kind !== 'unreachable') throw new Error('unreachable')
265
+ expect(result.reason).toBe('gh: API rate limit exceeded')
266
+ })
267
+
268
+ it('falls back to the exit code when stderr is empty on a non-404 failure', () => {
269
+ const result = interpretGhContentResult({exitCode: 2, stdout: '', stderr: ''})
270
+
271
+ expect(result.kind).toBe('unreachable')
272
+ if (result.kind !== 'unreachable') throw new Error('unreachable')
273
+ expect(result.reason).toBe('gh api exited with code 2')
274
+ })
275
+
276
+ it('delegates to analyzeFroBotWorkflow on a successful response', () => {
277
+ const result = interpretGhContentResult({
278
+ exitCode: 0,
279
+ stdout: COMPLETE_WORKFLOW,
280
+ stderr: '',
281
+ })
282
+
283
+ expect(result.kind).toBe('analyzed')
284
+ if (result.kind !== 'analyzed') throw new Error('unreachable')
285
+ expect(result.stepsWithGaps).toEqual([])
286
+ })
287
+ })
288
+
289
+ describe('formatWorkflowSnippet', () => {
290
+ it('renders snippet lines at 10-space indent so they can be pasted directly under with:', () => {
291
+ const snippet = formatWorkflowSnippet(['opencode-config', 'model'])
292
+
293
+ /* eslint-disable no-template-curly-in-string -- GitHub Actions expression syntax, not JS template literals */
294
+ const expected = [
295
+ ' opencode-config: ${{ secrets.OPENCODE_CONFIG }}',
296
+ ' model: ${{ vars.FRO_BOT_MODEL }}',
297
+ ].join('\n')
298
+ /* eslint-enable no-template-curly-in-string */
299
+ expect(snippet).toBe(expected)
300
+ })
301
+ })
302
+ })
303
+
304
+ describe('findFroBotAgentStepBodies', () => {
305
+ it('returns empty array for content with no fro-bot/agent step', () => {
306
+ const bodies = findFroBotAgentStepBodies(WORKFLOW_WITHOUT_FRO_BOT_AGENT)
307
+
308
+ expect(bodies).toEqual([])
309
+ })
310
+
311
+ it('returns empty array for empty content', () => {
312
+ const bodies = findFroBotAgentStepBodies('')
313
+
314
+ expect(bodies).toEqual([])
315
+ })
316
+
317
+ it('returns one entry for a single fro-bot/agent step', () => {
318
+ const bodies = findFroBotAgentStepBodies(COMPLETE_WORKFLOW)
319
+
320
+ expect(bodies).toHaveLength(1)
321
+ expect(bodies[0]?.stepOrdinal).toBe(1)
322
+ expect(bodies[0]?.body).toContain('fro-bot/agent@')
323
+ })
324
+
325
+ it('returns two entries for a workflow with two fro-bot/agent steps', () => {
326
+ const bodies = findFroBotAgentStepBodies(TWO_AGENT_STEPS_SECOND_BROKEN)
327
+
328
+ expect(bodies).toHaveLength(2)
329
+ expect(bodies[0]?.stepOrdinal).toBe(1)
330
+ expect(bodies[1]?.stepOrdinal).toBe(2)
331
+ })
332
+
333
+ it('scopes each body to only its own step — sibling step with: block is excluded', () => {
334
+ const bodies = findFroBotAgentStepBodies(SIBLING_STEP_SHADOWS_MODEL_INPUT)
335
+
336
+ // Only one fro-bot/agent step in this fixture
337
+ expect(bodies).toHaveLength(1)
338
+ // The sibling step's `model:` key must NOT appear in the fro-bot/agent step body
339
+ expect(bodies[0]?.body).not.toContain('matrix.model')
340
+ })
341
+ })
@@ -0,0 +1,137 @@
1
+ import type {CommandResult} from './gh'
2
+
3
+ import {runGh} from './gh'
4
+
5
+ // ─── Types ────────────────────────────────────────────────────────────────────
6
+
7
+ export type FroBotWorkflowCheck =
8
+ | {kind: 'missing'}
9
+ | {kind: 'unreachable'; reason: string}
10
+ | {kind: 'no-agent-step'}
11
+ | {
12
+ kind: 'analyzed'
13
+ stepsWithGaps: readonly {stepOrdinal: number; missingInputs: readonly string[]}[]
14
+ }
15
+
16
+ // ─── Constants ────────────────────────────────────────────────────────────────
17
+
18
+ // github-token and prompt are intentionally excluded from this check:
19
+ // github-token is harness-agnostic (PAT wiring, not secret-routing) and prompt is
20
+ // workflow-defined (the user's prompt body, not a harness default).
21
+ //
22
+ // NOTE: `enable-omo: true` is NOT a required input.
23
+ // For proxy-routed providers configured via OPENCODE_CONFIG.provider.<name>.options.baseURL,
24
+ // the fro-bot/agent action honors auth.json directly (regardless of oMo state).
25
+ // Source: fro-bot/agent@v0.44.3+ action.yaml lines 99-104; verified by librarian 2026-05-25.
26
+ const REQUIRED_OPENCODE_INPUTS = ['auth-json', 'opencode-config', 'omo-providers', 'model'] as const
27
+ type RequiredOpencodeInput = (typeof REQUIRED_OPENCODE_INPUTS)[number]
28
+
29
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Slice the workflow content into one entry per `fro-bot/agent` step. Handles
33
+ * both the `- name:\n uses: ...` and `- uses: ...` step shapes. Returns an
34
+ * empty array if no fro-bot/agent step is present.
35
+ *
36
+ * Step-scoped slicing prevents false-passes where a same-named input key in a
37
+ * sibling step (strategy.matrix, custom actions, reusable workflow with:
38
+ * blocks) could mask a genuine gap in fro-bot/agent's wiring.
39
+ */
40
+ export function findFroBotAgentStepBodies(content: string): {stepOrdinal: number; body: string}[] {
41
+ const bodies: {stepOrdinal: number; body: string}[] = []
42
+ const pattern = /^(\s*(?:-\s+)?)uses:\s*fro-bot\/agent@/gm
43
+
44
+ for (const match of content.matchAll(pattern)) {
45
+ if (match.index === undefined || match[1] === undefined) continue
46
+
47
+ const stepBodyIndent = match[1].length
48
+ const dashIndent = Math.max(0, stepBodyIndent - 2)
49
+ const lines = content.slice(match.index).split('\n')
50
+ const stepLines: string[] = [lines[0] ?? '']
51
+
52
+ for (let index = 1; index < lines.length; index += 1) {
53
+ const line = lines[index] ?? ''
54
+ if (!line.trim()) {
55
+ stepLines.push(line)
56
+ continue
57
+ }
58
+ const firstNonSpace = line.search(/\S/)
59
+ if (firstNonSpace === dashIndent && line.trimStart().startsWith('-')) break
60
+ if (firstNonSpace < dashIndent) break
61
+ stepLines.push(line)
62
+ }
63
+
64
+ bodies.push({stepOrdinal: bodies.length + 1, body: stepLines.join('\n')})
65
+ }
66
+
67
+ return bodies
68
+ }
69
+
70
+ // ─── Exports ──────────────────────────────────────────────────────────────────
71
+
72
+ export function analyzeFroBotWorkflow(workflowContent: string): FroBotWorkflowCheck {
73
+ const steps = findFroBotAgentStepBodies(workflowContent)
74
+
75
+ if (steps.length === 0) {
76
+ return {kind: 'no-agent-step'}
77
+ }
78
+
79
+ const stepsWithGaps = steps
80
+ .map(step => ({
81
+ stepOrdinal: step.stepOrdinal,
82
+ missingInputs: REQUIRED_OPENCODE_INPUTS.filter(input => {
83
+ const inputPattern = new RegExp(String.raw`^\s+${input}:`, 'm')
84
+ return !inputPattern.test(step.body)
85
+ }),
86
+ }))
87
+ .filter(step => step.missingInputs.length > 0)
88
+
89
+ return {kind: 'analyzed', stepsWithGaps}
90
+ }
91
+
92
+ /**
93
+ * Extracted pure helper: turn a `gh api /repos/.../contents/<file>` result into
94
+ * a FroBotWorkflowCheck. Separated from checkFroBotWorkflow so tests can exercise
95
+ * the 404-vs-transport-error logic without mocking Bun.spawn.
96
+ */
97
+ export function interpretGhContentResult(result: CommandResult): FroBotWorkflowCheck {
98
+ if (result.exitCode === 0) {
99
+ return analyzeFroBotWorkflow(result.stdout)
100
+ }
101
+
102
+ // gh prints `gh: Not Found (HTTP 404)` on 404; anything else is auth/network/5xx.
103
+ if (/HTTP 404/.test(result.stderr)) {
104
+ return {kind: 'missing'}
105
+ }
106
+
107
+ return {
108
+ kind: 'unreachable',
109
+ reason: result.stderr.trim() || `gh api exited with code ${result.exitCode}`,
110
+ }
111
+ }
112
+
113
+ export async function checkFroBotWorkflow(repo: string): Promise<FroBotWorkflowCheck> {
114
+ const result = await runGh([
115
+ 'api',
116
+ '--header',
117
+ 'Accept: application/vnd.github.raw',
118
+ `/repos/${repo}/contents/.github/workflows/fro-bot.yaml`,
119
+ ])
120
+
121
+ return interpretGhContentResult(result)
122
+ }
123
+
124
+ // Snippet uses 10-space indent to match the canonical `with:` block depth
125
+ // in marcusrbrown/infra/.github/workflows/fro-bot.yaml, so users can paste
126
+ // directly under their step's `with:` key without re-indenting.
127
+ export function formatWorkflowSnippet(missingInputs: readonly string[]): string {
128
+ /* eslint-disable no-template-curly-in-string -- GitHub Actions expression syntax, not JS template literals */
129
+ const inputMap = {
130
+ 'auth-json': 'auth-json: ${{ secrets.OPENCODE_AUTH_JSON }}',
131
+ 'opencode-config': 'opencode-config: ${{ secrets.OPENCODE_CONFIG }}',
132
+ 'omo-providers': 'omo-providers: ${{ secrets.OMO_PROVIDERS }}',
133
+ model: 'model: ${{ vars.FRO_BOT_MODEL }}',
134
+ } satisfies Record<RequiredOpencodeInput, string>
135
+ /* eslint-enable no-template-curly-in-string */
136
+ return missingInputs.map(input => ` ${(inputMap as Record<string, string>)[input]}`).join('\n')
137
+ }