@marcusrbrown/infra 0.7.0 → 0.8.1

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,159 @@
1
+ /// <reference types="bun" />
2
+
3
+ import {describe, expect, it} from 'bun:test'
4
+
5
+ import {formatDryRunPreview} from './preview'
6
+ import {getHarnessTemplate} from './templates'
7
+
8
+ const KEY = 'sk-placeholder'
9
+ const BASE_URL = 'https://cliproxy.fro.bot'
10
+
11
+ describe('formatDryRunPreview', () => {
12
+ it('renders the dry-run header with repo and providers', () => {
13
+ const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
14
+ const preview = formatDryRunPreview({
15
+ repo: 'owner/repo',
16
+ harness: 'opencode',
17
+ providers: ['anthropic'],
18
+ model: 'anthropic/claude-sonnet-4-6',
19
+ template,
20
+ })
21
+
22
+ expect(preview).toContain('Dry run: cliproxy setup --harness opencode')
23
+ expect(preview).toContain('Repository: owner/repo')
24
+ expect(preview).toContain('Providers: anthropic')
25
+ })
26
+
27
+ it('renders planned secrets with byte sizes', () => {
28
+ const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
29
+ const preview = formatDryRunPreview({
30
+ repo: 'owner/repo',
31
+ harness: 'opencode',
32
+ providers: ['anthropic'],
33
+ model: 'anthropic/claude-sonnet-4-6',
34
+ template,
35
+ })
36
+
37
+ expect(preview).toContain('Planned secrets:')
38
+ expect(preview).toContain('OPENCODE_AUTH_JSON')
39
+ expect(preview).toContain('OPENCODE_CONFIG')
40
+ expect(preview).toContain('OMO_PROVIDERS')
41
+ })
42
+
43
+ it('renders planned variables', () => {
44
+ const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
45
+ const preview = formatDryRunPreview({
46
+ repo: 'owner/repo',
47
+ harness: 'opencode',
48
+ providers: ['anthropic'],
49
+ model: 'anthropic/claude-sonnet-4-6',
50
+ template,
51
+ })
52
+
53
+ expect(preview).toContain('Planned variables:')
54
+ expect(preview).toContain('FRO_BOT_MODEL')
55
+ })
56
+
57
+ it('renders proxy key as <proxy-key> placeholder, NOT the actual key value', () => {
58
+ const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
59
+ const preview = formatDryRunPreview({
60
+ repo: 'owner/repo',
61
+ harness: 'opencode',
62
+ providers: ['anthropic'],
63
+ model: 'anthropic/claude-sonnet-4-6',
64
+ template,
65
+ })
66
+
67
+ expect(preview).toContain('<proxy-key>')
68
+ expect(preview).not.toContain(KEY)
69
+ })
70
+
71
+ it('renders "No mutations will be performed." footer', () => {
72
+ const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
73
+ const preview = formatDryRunPreview({
74
+ repo: 'owner/repo',
75
+ harness: 'opencode',
76
+ providers: ['anthropic'],
77
+ model: 'anthropic/claude-sonnet-4-6',
78
+ template,
79
+ })
80
+
81
+ expect(preview).toContain('No mutations will be performed.')
82
+ })
83
+
84
+ it('dual-provider preview lists both providers', () => {
85
+ const template = getHarnessTemplate('opencode', {
86
+ keyValue: KEY,
87
+ baseUrl: BASE_URL,
88
+ providers: ['anthropic', 'openai'],
89
+ model: 'openai/gpt-5.4-mini',
90
+ })
91
+ const preview = formatDryRunPreview({
92
+ repo: 'owner/repo',
93
+ harness: 'opencode',
94
+ providers: ['anthropic', 'openai'],
95
+ model: 'openai/gpt-5.4-mini',
96
+ template,
97
+ })
98
+
99
+ expect(preview).toContain('anthropic')
100
+ expect(preview).toContain('openai')
101
+ expect(preview).not.toContain(KEY)
102
+ })
103
+
104
+ it('secret values in preview do NOT contain the actual key value', () => {
105
+ // Even if the template has the key embedded in JSON, the preview must redact it
106
+ const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
107
+ const preview = formatDryRunPreview({
108
+ repo: 'owner/repo',
109
+ harness: 'opencode',
110
+ providers: ['anthropic'],
111
+ model: 'anthropic/claude-sonnet-4-6',
112
+ template,
113
+ })
114
+
115
+ // The actual key must not appear anywhere in the preview output
116
+ expect(preview).not.toContain(KEY)
117
+ })
118
+
119
+ // ── byte-count contract tests ────────────────────
120
+
121
+ it('anthropic-only: OPENCODE_AUTH_JSON is 51 bytes with sk-placeholder key', () => {
122
+ const template = getHarnessTemplate('opencode', {
123
+ keyValue: KEY,
124
+ baseUrl: BASE_URL,
125
+ providers: ['anthropic'],
126
+ model: 'anthropic/claude-sonnet-4-6',
127
+ })
128
+ const preview = formatDryRunPreview({
129
+ repo: 'owner/repo',
130
+ harness: 'opencode',
131
+ providers: ['anthropic'],
132
+ model: 'anthropic/claude-sonnet-4-6',
133
+ template,
134
+ })
135
+
136
+ expect(preview).toContain('Providers: anthropic')
137
+ expect(preview).toContain('OPENCODE_AUTH_JSON (51 bytes)')
138
+ expect(preview).toContain('<proxy-key>')
139
+ })
140
+
141
+ it('dual-provider: OPENCODE_AUTH_JSON is 98 bytes with sk-placeholder key', () => {
142
+ const template = getHarnessTemplate('opencode', {
143
+ keyValue: KEY,
144
+ baseUrl: BASE_URL,
145
+ providers: ['anthropic', 'openai'],
146
+ model: 'anthropic/claude-sonnet-4-6',
147
+ })
148
+ const preview = formatDryRunPreview({
149
+ repo: 'owner/repo',
150
+ harness: 'opencode',
151
+ providers: ['anthropic', 'openai'],
152
+ model: 'anthropic/claude-sonnet-4-6',
153
+ template,
154
+ })
155
+
156
+ expect(preview).toContain('Providers: anthropic, openai')
157
+ expect(preview).toContain('OPENCODE_AUTH_JSON (98 bytes)')
158
+ })
159
+ })
@@ -0,0 +1,41 @@
1
+ import type {ProviderId} from './providers'
2
+ import type {Harness, HarnessTemplate} from './templates'
3
+
4
+ export interface DryRunPreviewOptions {
5
+ repo: string
6
+ harness: Harness
7
+ providers: ProviderId[]
8
+ model: string
9
+ template: HarnessTemplate
10
+ }
11
+
12
+ /**
13
+ * Format a dry-run preview string. The proxy key value is NEVER included —
14
+ * it is rendered as `<proxy-key>` in all positions.
15
+ */
16
+ export function formatDryRunPreview(opts: DryRunPreviewOptions): string {
17
+ const {repo, harness, providers, model, template} = opts
18
+
19
+ const lines: string[] = [
20
+ `Dry run: cliproxy setup --harness ${harness}`,
21
+ `Repository: ${repo}`,
22
+ `Providers: ${providers.join(', ')}`,
23
+ `Model: ${model}`,
24
+ 'Planned secrets:',
25
+ ]
26
+
27
+ for (const secret of template.secrets) {
28
+ const size = new TextEncoder().encode(secret.value).byteLength
29
+ lines.push(` - ${secret.name} (${size} bytes)`)
30
+ }
31
+
32
+ lines.push('Planned variables:')
33
+ for (const variable of template.variables) {
34
+ lines.push(` - ${variable.name} = ${variable.value}`)
35
+ }
36
+
37
+ lines.push('Proxy key (redacted): <proxy-key>')
38
+ lines.push('No mutations will be performed.')
39
+
40
+ return lines.join('\n')
41
+ }
@@ -0,0 +1,58 @@
1
+ /// <reference types="bun" />
2
+
3
+ import {describe, expect, it} from 'bun:test'
4
+
5
+ import {buildApiKeyValue, ensureRepoFormat, ensureSecretName} from './prompts'
6
+
7
+ describe('ensureRepoFormat', () => {
8
+ it('happy path: returns owner/repo unchanged', () => {
9
+ expect(ensureRepoFormat('owner/repo')).toBe('owner/repo')
10
+ })
11
+
12
+ it('error path: throws on whitespace in value', () => {
13
+ expect(() => ensureRepoFormat('owner repo')).toThrow()
14
+ })
15
+
16
+ it('error path: throws on extra slash', () => {
17
+ expect(() => ensureRepoFormat('owner/repo/extra')).toThrow()
18
+ })
19
+
20
+ it('error path: throws on empty string', () => {
21
+ expect(() => ensureRepoFormat('')).toThrow()
22
+ })
23
+ })
24
+
25
+ describe('ensureSecretName', () => {
26
+ it('happy path: returns VALID_NAME unchanged', () => {
27
+ expect(ensureSecretName('VALID_NAME', 'secret')).toBe('VALID_NAME')
28
+ })
29
+
30
+ it('error path: throws on lowercase name', () => {
31
+ expect(() => ensureSecretName('lower_case', 'secret')).toThrow()
32
+ })
33
+
34
+ it('error path: throws on name with hyphens', () => {
35
+ expect(() => ensureSecretName('with-dash', 'secret')).toThrow()
36
+ })
37
+
38
+ it('error path: throws when name starts with a digit', () => {
39
+ expect(() => ensureSecretName('123_START_DIGIT', 'secret')).toThrow()
40
+ })
41
+ })
42
+
43
+ describe('buildApiKeyValue', () => {
44
+ it('happy path: slugifies key name and appends uuid suffix', () => {
45
+ const result = buildApiKeyValue('my repo ci!')
46
+ expect(result).toMatch(/^sk-my-repo-ci-[a-f0-9]+$/)
47
+ })
48
+
49
+ it('edge case: empty string falls back to cliproxy slug', () => {
50
+ const result = buildApiKeyValue('')
51
+ expect(result).toMatch(/^sk-cliproxy-[a-f0-9]+$/)
52
+ })
53
+
54
+ it('edge case: uppercased input is lowercased in slug', () => {
55
+ const result = buildApiKeyValue('UPPERCASE')
56
+ expect(result).toMatch(/^sk-uppercase-[a-f0-9]+$/)
57
+ })
58
+ })
@@ -0,0 +1,99 @@
1
+ /// <reference types="bun" />
2
+
3
+ import * as clack from '@clack/prompts'
4
+
5
+ export interface GenericSecretNames {
6
+ apiKeySecretName: string
7
+ baseUrlSecretName: string
8
+ }
9
+
10
+ export function ensureRepoFormat(value: string): string {
11
+ const trimmed = value.trim()
12
+ if (!/^[^/\s]+\/[^/\s]+$/.test(trimmed)) {
13
+ throw new Error('Repository must be in owner/repo format.')
14
+ }
15
+ return trimmed
16
+ }
17
+
18
+ export function ensureSecretName(value: string, label: string): string {
19
+ const trimmed = value.trim()
20
+ if (!/^[A-Z][A-Z0-9_]*$/.test(trimmed)) {
21
+ throw new Error(`${label} must be SCREAMING_SNAKE_CASE.`)
22
+ }
23
+ return trimmed
24
+ }
25
+
26
+ export function cancelAndExit(message = 'Setup cancelled.'): never {
27
+ clack.cancel(message)
28
+ process.exit(0)
29
+ }
30
+
31
+ export async function promptValue<T extends string | boolean>(
32
+ promise: Promise<T | symbol>,
33
+ cancelMessage?: string,
34
+ ): Promise<T> {
35
+ const value = await promise
36
+ if (clack.isCancel(value)) {
37
+ cancelAndExit(cancelMessage)
38
+ }
39
+ return value
40
+ }
41
+
42
+ export function buildApiKeyValue(keyName: string): string {
43
+ const slug = (
44
+ keyName
45
+ .trim()
46
+ .toLowerCase()
47
+ .match(/[a-z0-9]+/g) ?? []
48
+ )
49
+ .join('-')
50
+ .slice(0, 24)
51
+ const random = crypto.randomUUID().split('-').join('')
52
+ return `sk-${slug || 'cliproxy'}-${random}`
53
+ }
54
+
55
+ function extractErrorMessage(error: unknown): string {
56
+ return error instanceof Error ? error.message : String(error)
57
+ }
58
+
59
+ export async function promptGenericSecretNames(): Promise<GenericSecretNames> {
60
+ const apiKeySecretName = ensureSecretName(
61
+ await promptValue(
62
+ clack.text({
63
+ message: 'Name for the API key secret',
64
+ placeholder: 'CLIPROXY_API_KEY',
65
+ validate: value => {
66
+ try {
67
+ ensureSecretName(value ?? '', 'API key secret name')
68
+ return undefined
69
+ } catch (error) {
70
+ return extractErrorMessage(error)
71
+ }
72
+ },
73
+ }),
74
+ 'Setup cancelled before choosing the generic API key secret name.',
75
+ ),
76
+ 'API key secret name',
77
+ )
78
+
79
+ const baseUrlSecretName = ensureSecretName(
80
+ await promptValue(
81
+ clack.text({
82
+ message: 'Name for the proxy base URL secret',
83
+ placeholder: 'CLIPROXY_BASE_URL',
84
+ validate: value => {
85
+ try {
86
+ ensureSecretName(value ?? '', 'Base URL secret name')
87
+ return undefined
88
+ } catch (error) {
89
+ return extractErrorMessage(error)
90
+ }
91
+ },
92
+ }),
93
+ 'Setup cancelled before choosing the generic base URL secret name.',
94
+ ),
95
+ 'Base URL secret name',
96
+ )
97
+
98
+ return {apiKeySecretName, baseUrlSecretName}
99
+ }
@@ -0,0 +1,245 @@
1
+ /// <reference types="bun" />
2
+
3
+ import type {MultiSelectOptions, TextOptions} from '@clack/prompts'
4
+ import {describe, expect, it, spyOn} from 'bun:test'
5
+
6
+ import {parseProviders, promptForModel, promptForProviders} from './providers'
7
+
8
+ // Type helper: cast a concrete-typed clack implementation to the generic spy type.
9
+ // clack's multiselect/text/select are generic functions; Bun's spyOn preserves the
10
+ // generic signature, so mockImplementation requires the same generic. We provide a
11
+ // concrete instantiation and widen through `unknown` — this is safe because the
12
+ // concrete type is a structural subtype of the generic at the call site.
13
+ function asMultiselectImpl<V>(fn: (opts: MultiSelectOptions<V>) => Promise<V[] | symbol>) {
14
+ return fn as unknown as <Value>(opts: MultiSelectOptions<Value>) => Promise<Value[] | symbol>
15
+ }
16
+
17
+ function asTextImpl(fn: (opts: TextOptions) => Promise<string | symbol>) {
18
+ return fn as unknown as (opts: TextOptions) => Promise<string | symbol>
19
+ }
20
+
21
+ describe('option parsing', () => {
22
+ describe('parseProviders', () => {
23
+ it("parses \"anthropic,openai\" to ['anthropic', 'openai']", () => {
24
+ expect(parseProviders('anthropic,openai')).toEqual(['anthropic', 'openai'])
25
+ })
26
+
27
+ it('parses "openai" to [\'openai\']', () => {
28
+ expect(parseProviders('openai')).toEqual(['openai'])
29
+ })
30
+
31
+ it('parses "anthropic" to [\'anthropic\']', () => {
32
+ expect(parseProviders('anthropic')).toEqual(['anthropic'])
33
+ })
34
+
35
+ it('rejects duplicate providers with a "duplicate" error', () => {
36
+ expect(() => parseProviders('anthropic,anthropic')).toThrow(/duplicate/i)
37
+ })
38
+
39
+ it('rejects an empty string with a clear message', () => {
40
+ expect(() => parseProviders('')).toThrow()
41
+ })
42
+
43
+ it('rejects an unknown provider "claude" with an enum error', () => {
44
+ expect(() => parseProviders('claude')).toThrow()
45
+ })
46
+
47
+ it('trims whitespace around provider names', () => {
48
+ expect(parseProviders(' anthropic , openai ')).toEqual(['anthropic', 'openai'])
49
+ })
50
+
51
+ it('rejects "__proto__" (prototype-chain safety)', () => {
52
+ expect(() => parseProviders('__proto__')).toThrow()
53
+ })
54
+ })
55
+ })
56
+
57
+ describe('interactive provider/model prompts', () => {
58
+ // We spy on @clack/prompts functions directly since Bun's mock.module
59
+ // requires static hoisting. Instead we use spyOn on the imported module.
60
+ // The helpers call the clack functions via the module binding, so we
61
+ // intercept them via spyOn after importing.
62
+
63
+ // Note: Because providers.ts imports clack at module load time and calls the
64
+ // functions directly (not via a re-exported object), we need to use
65
+ // mock.module to intercept. However, Bun's mock.module must be called
66
+ // before the module is imported. Since providers.ts is already imported above,
67
+ // we test the helpers by injecting controlled behavior through the clack
68
+ // module mock at the describe level using beforeEach/afterEach with spyOn
69
+ // on the actual clack module exports.
70
+ //
71
+ // The approach: import clack directly and spyOn its exports.
72
+
73
+ describe('promptForProviders', () => {
74
+ it('happy path: anthropic-only selection returns [anthropic]', async () => {
75
+ const clack = await import('@clack/prompts')
76
+ // multiselect<Value> returns Promise<Value[] | symbol>; resolved value is string[] | symbol
77
+ const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(['anthropic'])
78
+
79
+ const result = await promptForProviders()
80
+
81
+ expect(result).toEqual(['anthropic'])
82
+ expect(multiselectSpy).toHaveBeenCalledTimes(1)
83
+
84
+ multiselectSpy.mockRestore()
85
+ })
86
+
87
+ it('happy path: both providers selected returns [anthropic, openai]', async () => {
88
+ const clack = await import('@clack/prompts')
89
+ const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(['anthropic', 'openai'])
90
+
91
+ const result = await promptForProviders()
92
+
93
+ expect(result).toEqual(['anthropic', 'openai'])
94
+
95
+ multiselectSpy.mockRestore()
96
+ })
97
+
98
+ it('edge case: empty selection re-prompts; multiselect called exactly twice', async () => {
99
+ const clack = await import('@clack/prompts')
100
+ let callCount = 0
101
+ const multiselectSpy = spyOn(clack, 'multiselect').mockImplementation(
102
+ asMultiselectImpl<string>(async () => {
103
+ callCount++
104
+ if (callCount === 1) return []
105
+ return ['anthropic']
106
+ }),
107
+ )
108
+
109
+ const result = await promptForProviders()
110
+
111
+ expect(result).toEqual(['anthropic'])
112
+ expect(multiselectSpy).toHaveBeenCalledTimes(2)
113
+
114
+ multiselectSpy.mockRestore()
115
+ })
116
+
117
+ it('edge case: cancel mid-flow causes process.exit(0)', async () => {
118
+ const clack = await import('@clack/prompts')
119
+ const cancelSymbol = Symbol('cancel')
120
+ const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(cancelSymbol)
121
+ const isCancelSpy = spyOn(clack, 'isCancel').mockImplementation(v => v === cancelSymbol)
122
+ const cancelSpy = spyOn(clack, 'cancel').mockImplementation(() => {})
123
+ const exitSpy = spyOn(process, 'exit').mockImplementation((_code?: number): never => {
124
+ throw new Error('process.exit called')
125
+ })
126
+
127
+ await expect(promptForProviders()).rejects.toThrow('process.exit called')
128
+
129
+ multiselectSpy.mockRestore()
130
+ isCancelSpy.mockRestore()
131
+ cancelSpy.mockRestore()
132
+ exitSpy.mockRestore()
133
+ })
134
+ })
135
+
136
+ describe('promptForModel', () => {
137
+ it('happy path: single anthropic provider returns anthropic/claude-sonnet-4-6 without prompting', async () => {
138
+ const clack = await import('@clack/prompts')
139
+ const selectSpy = spyOn(clack, 'select')
140
+
141
+ const result = await promptForModel(['anthropic'])
142
+
143
+ expect(result).toBe('anthropic/claude-sonnet-4-6')
144
+ expect(selectSpy).not.toHaveBeenCalled()
145
+
146
+ selectSpy.mockRestore()
147
+ })
148
+
149
+ it('happy path: single openai provider returns openai/gpt-5.4-mini without prompting', async () => {
150
+ const clack = await import('@clack/prompts')
151
+ const selectSpy = spyOn(clack, 'select')
152
+
153
+ const result = await promptForModel(['openai'])
154
+
155
+ expect(result).toBe('openai/gpt-5.4-mini')
156
+ expect(selectSpy).not.toHaveBeenCalled()
157
+
158
+ selectSpy.mockRestore()
159
+ })
160
+
161
+ it('happy path: both providers, operator picks openai/gpt-5.4-mini from select', async () => {
162
+ const clack = await import('@clack/prompts')
163
+ const selectSpy = spyOn(clack, 'select').mockResolvedValue('openai/gpt-5.4-mini')
164
+
165
+ const result = await promptForModel(['anthropic', 'openai'])
166
+
167
+ expect(result).toBe('openai/gpt-5.4-mini')
168
+ expect(selectSpy).toHaveBeenCalledTimes(1)
169
+
170
+ selectSpy.mockRestore()
171
+ })
172
+
173
+ it('happy path: both providers, operator picks anthropic/claude-sonnet-4-6 from select', async () => {
174
+ const clack = await import('@clack/prompts')
175
+ const selectSpy = spyOn(clack, 'select').mockResolvedValue('anthropic/claude-sonnet-4-6')
176
+
177
+ const result = await promptForModel(['anthropic', 'openai'])
178
+
179
+ expect(result).toBe('anthropic/claude-sonnet-4-6')
180
+
181
+ selectSpy.mockRestore()
182
+ })
183
+
184
+ it('happy path: operator picks "enter custom..." then types openai/gpt-5.4-mini', async () => {
185
+ const clack = await import('@clack/prompts')
186
+ const selectSpy = spyOn(clack, 'select').mockResolvedValue('__custom__')
187
+ const textSpy = spyOn(clack, 'text').mockResolvedValue('openai/gpt-5.4-mini')
188
+
189
+ const result = await promptForModel(['anthropic', 'openai'])
190
+
191
+ expect(result).toBe('openai/gpt-5.4-mini')
192
+ expect(textSpy).toHaveBeenCalledTimes(1)
193
+
194
+ selectSpy.mockRestore()
195
+ textSpy.mockRestore()
196
+ })
197
+
198
+ it('edge case: custom model entry fails regex then succeeds on second attempt', async () => {
199
+ const clack = await import('@clack/prompts')
200
+ const selectSpy = spyOn(clack, 'select').mockResolvedValue('__custom__')
201
+ let textCallCount = 0
202
+ const textSpy = spyOn(clack, 'text').mockImplementation(
203
+ asTextImpl(async () => {
204
+ textCallCount++
205
+ // Simulate the validate function being called inline by the mock
206
+ // The real clack text prompt calls validate internally; here we just
207
+ // return the value and let the helper's validate logic re-prompt.
208
+ // Since we can't simulate clack's internal validate loop, we test
209
+ // that the helper's validate function rejects bad input.
210
+ if (textCallCount === 1) {
211
+ // Return a bad value — the helper should detect this and re-prompt
212
+ return 'bad-model'
213
+ }
214
+ return 'openai/gpt-5.4-mini'
215
+ }),
216
+ )
217
+
218
+ const result = await promptForModel(['anthropic', 'openai'])
219
+
220
+ expect(result).toBe('openai/gpt-5.4-mini')
221
+ expect(textSpy.mock.calls.length).toBeGreaterThanOrEqual(1)
222
+
223
+ selectSpy.mockRestore()
224
+ textSpy.mockRestore()
225
+ })
226
+
227
+ it('edge case: cancel during model select causes process.exit(0)', async () => {
228
+ const clack = await import('@clack/prompts')
229
+ const cancelSymbol = Symbol('cancel')
230
+ const selectSpy = spyOn(clack, 'select').mockResolvedValue(cancelSymbol)
231
+ const isCancelSpy = spyOn(clack, 'isCancel').mockImplementation(v => v === cancelSymbol)
232
+ const cancelSpy = spyOn(clack, 'cancel').mockImplementation(() => {})
233
+ const exitSpy = spyOn(process, 'exit').mockImplementation((_code?: number): never => {
234
+ throw new Error('process.exit called')
235
+ })
236
+
237
+ await expect(promptForModel(['anthropic', 'openai'])).rejects.toThrow('process.exit called')
238
+
239
+ selectSpy.mockRestore()
240
+ isCancelSpy.mockRestore()
241
+ cancelSpy.mockRestore()
242
+ exitSpy.mockRestore()
243
+ })
244
+ })
245
+ })