incur 0.0.0 → 0.0.2
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.
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/SKILL.md +664 -0
- package/dist/Cli.d.ts +255 -0
- package/dist/Cli.d.ts.map +1 -0
- package/dist/Cli.js +900 -0
- package/dist/Cli.js.map +1 -0
- package/dist/Errors.d.ts +92 -0
- package/dist/Errors.d.ts.map +1 -0
- package/dist/Errors.js +75 -0
- package/dist/Errors.js.map +1 -0
- package/dist/Formatter.d.ts +5 -0
- package/dist/Formatter.d.ts.map +1 -0
- package/dist/Formatter.js +91 -0
- package/dist/Formatter.js.map +1 -0
- package/dist/Help.d.ts +53 -0
- package/dist/Help.d.ts.map +1 -0
- package/dist/Help.js +231 -0
- package/dist/Help.js.map +1 -0
- package/dist/Mcp.d.ts +13 -0
- package/dist/Mcp.d.ts.map +1 -0
- package/dist/Mcp.js +140 -0
- package/dist/Mcp.js.map +1 -0
- package/dist/Parser.d.ts +24 -0
- package/dist/Parser.d.ts.map +1 -0
- package/dist/Parser.js +215 -0
- package/dist/Parser.js.map +1 -0
- package/dist/Register.d.ts +19 -0
- package/dist/Register.d.ts.map +1 -0
- package/dist/Register.js +2 -0
- package/dist/Register.js.map +1 -0
- package/dist/Schema.d.ts +4 -0
- package/dist/Schema.d.ts.map +1 -0
- package/dist/Schema.js +8 -0
- package/dist/Schema.js.map +1 -0
- package/dist/Skill.d.ts +29 -0
- package/dist/Skill.d.ts.map +1 -0
- package/dist/Skill.js +196 -0
- package/dist/Skill.js.map +1 -0
- package/dist/Skillgen.d.ts +3 -0
- package/dist/Skillgen.d.ts.map +1 -0
- package/dist/Skillgen.js +67 -0
- package/dist/Skillgen.js.map +1 -0
- package/dist/SyncMcp.d.ts +23 -0
- package/dist/SyncMcp.d.ts.map +1 -0
- package/dist/SyncMcp.js +100 -0
- package/dist/SyncMcp.js.map +1 -0
- package/dist/SyncSkills.d.ts +38 -0
- package/dist/SyncSkills.d.ts.map +1 -0
- package/dist/SyncSkills.js +163 -0
- package/dist/SyncSkills.js.map +1 -0
- package/dist/Typegen.d.ts +6 -0
- package/dist/Typegen.d.ts.map +1 -0
- package/dist/Typegen.js +92 -0
- package/dist/Typegen.js.map +1 -0
- package/dist/bin.d.ts +14 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +30 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/pm.d.ts +3 -0
- package/dist/internal/pm.d.ts.map +1 -0
- package/dist/internal/pm.js +11 -0
- package/dist/internal/pm.js.map +1 -0
- package/dist/internal/types.d.ts +11 -0
- package/dist/internal/types.d.ts.map +1 -0
- package/dist/internal/types.js +2 -0
- package/dist/internal/types.js.map +1 -0
- package/dist/internal/utils.d.ts +8 -0
- package/dist/internal/utils.d.ts.map +1 -0
- package/dist/internal/utils.js +51 -0
- package/dist/internal/utils.js.map +1 -0
- package/examples/npm/cli.ts +180 -0
- package/examples/npm/node_modules/.bin/incur.src +21 -0
- package/examples/npm/node_modules/.bin/tsx +21 -0
- package/examples/npm/package.json +14 -0
- package/examples/npm/tsconfig.json +9 -0
- package/examples/presto/cli.ts +246 -0
- package/examples/presto/node_modules/.bin/incur.src +21 -0
- package/examples/presto/node_modules/.bin/tsx +21 -0
- package/examples/presto/package.json +14 -0
- package/examples/presto/tsconfig.json +9 -0
- package/package.json +53 -2
- package/src/Cli.test-d.ts +135 -0
- package/src/Cli.test.ts +1373 -0
- package/src/Cli.ts +1470 -0
- package/src/Errors.test.ts +96 -0
- package/src/Errors.ts +139 -0
- package/src/Formatter.test.ts +245 -0
- package/src/Formatter.ts +106 -0
- package/src/Help.test.ts +124 -0
- package/src/Help.ts +302 -0
- package/src/Mcp.test.ts +254 -0
- package/src/Mcp.ts +195 -0
- package/src/Parser.test-d.ts +45 -0
- package/src/Parser.test.ts +118 -0
- package/src/Parser.ts +247 -0
- package/src/Register.ts +18 -0
- package/src/Schema.test.ts +125 -0
- package/src/Schema.ts +8 -0
- package/src/Skill.test.ts +293 -0
- package/src/Skill.ts +253 -0
- package/src/Skillgen.ts +66 -0
- package/src/SyncMcp.test.ts +75 -0
- package/src/SyncMcp.ts +132 -0
- package/src/SyncSkills.test.ts +92 -0
- package/src/SyncSkills.ts +205 -0
- package/src/Typegen.test.ts +150 -0
- package/src/Typegen.ts +107 -0
- package/src/bin.ts +33 -0
- package/src/e2e.test.ts +1710 -0
- package/src/index.ts +14 -0
- package/src/internal/pm.test.ts +38 -0
- package/src/internal/pm.ts +8 -0
- package/src/internal/types.ts +22 -0
- package/src/internal/utils.ts +50 -0
- package/src/tsconfig.json +8 -0
package/src/e2e.test.ts
ADDED
|
@@ -0,0 +1,1710 @@
|
|
|
1
|
+
import { Cli, Errors, Skill, Typegen, z } from 'incur'
|
|
2
|
+
|
|
3
|
+
let __mockSkillsHash: string | undefined
|
|
4
|
+
|
|
5
|
+
vi.mock('./SyncSkills.js', async (importOriginal) => {
|
|
6
|
+
const actual = await importOriginal<typeof import('./SyncSkills.js')>()
|
|
7
|
+
return { ...actual, readHash: () => __mockSkillsHash }
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
describe('routing', () => {
|
|
11
|
+
test('top-level command', async () => {
|
|
12
|
+
const { output } = await serve(createApp(), ['ping'])
|
|
13
|
+
expect(output).toMatchInlineSnapshot(`
|
|
14
|
+
"pong: true
|
|
15
|
+
"
|
|
16
|
+
`)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('group command', async () => {
|
|
20
|
+
const { output } = await serve(createApp(), ['auth', 'logout'])
|
|
21
|
+
expect(output).toMatchInlineSnapshot(`
|
|
22
|
+
"loggedOut: true
|
|
23
|
+
"
|
|
24
|
+
`)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('nested group command (3 levels deep)', async () => {
|
|
28
|
+
const { output } = await serve(createApp(), ['project', 'deploy', 'status', 'd-456'])
|
|
29
|
+
expect(output).toMatchInlineSnapshot(`
|
|
30
|
+
"deployId: d-456
|
|
31
|
+
status: running
|
|
32
|
+
progress: 75
|
|
33
|
+
"
|
|
34
|
+
`)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('mounted leaf CLI as single command', async () => {
|
|
38
|
+
const { output } = await serve(createApp(), ['config'])
|
|
39
|
+
expect(output).toMatchInlineSnapshot(`
|
|
40
|
+
"apiUrl: "https://api.example.com"
|
|
41
|
+
timeout: 30
|
|
42
|
+
debug: false
|
|
43
|
+
"
|
|
44
|
+
`)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('mounted leaf CLI with args', async () => {
|
|
48
|
+
const { output } = await serve(createApp(), ['config', 'apiUrl'])
|
|
49
|
+
expect(output).toMatchInlineSnapshot(`
|
|
50
|
+
"key: apiUrl
|
|
51
|
+
value: some-value
|
|
52
|
+
"
|
|
53
|
+
`)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('unknown top-level command', async () => {
|
|
57
|
+
const { output, exitCode } = await serve(createApp(), ['nonexistent'])
|
|
58
|
+
expect(exitCode).toBe(1)
|
|
59
|
+
expect(output).toMatchInlineSnapshot(`
|
|
60
|
+
"Error: 'nonexistent' is not a command. See 'app --help' for a list of available commands.
|
|
61
|
+
"
|
|
62
|
+
`)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('unknown subcommand lists available', async () => {
|
|
66
|
+
const { output, exitCode } = await serve(createApp(), ['auth', 'whoami'])
|
|
67
|
+
expect(exitCode).toBe(1)
|
|
68
|
+
expect(output).toMatchInlineSnapshot(`
|
|
69
|
+
"Error: 'whoami' is not a command. See 'app auth --help' for a list of available commands.
|
|
70
|
+
"
|
|
71
|
+
`)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('unknown nested subcommand', async () => {
|
|
75
|
+
const { output, exitCode } = await serve(createApp(), ['project', 'deploy', 'nope'])
|
|
76
|
+
expect(exitCode).toBe(1)
|
|
77
|
+
expect(output).toMatchInlineSnapshot(`
|
|
78
|
+
"Error: 'nope' is not a command. See 'app project deploy --help' for a list of available commands.
|
|
79
|
+
"
|
|
80
|
+
`)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('args and options', () => {
|
|
85
|
+
test('positional args in order', async () => {
|
|
86
|
+
const { output } = await serve(createApp(), ['echo', 'hello'])
|
|
87
|
+
expect(output).toMatchInlineSnapshot(`
|
|
88
|
+
"result[1]: hello
|
|
89
|
+
"
|
|
90
|
+
`)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('--flag value form', async () => {
|
|
94
|
+
const { output } = await serve(createApp(), ['echo', 'hello', '--upper', '--prefix', '>>'])
|
|
95
|
+
expect(output).toMatchInlineSnapshot(`
|
|
96
|
+
"result[1]: >> HELLO
|
|
97
|
+
"
|
|
98
|
+
`)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('multiple options combined', async () => {
|
|
102
|
+
const { output } = await serve(createApp(), ['echo', 'hi', '--upper', '--prefix', '!'])
|
|
103
|
+
expect(output).toMatchInlineSnapshot(`
|
|
104
|
+
"result[1]: ! HI
|
|
105
|
+
"
|
|
106
|
+
`)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('--no-flag negation for booleans', async () => {
|
|
110
|
+
const { output } = await serve(createApp(), [
|
|
111
|
+
'project',
|
|
112
|
+
'list',
|
|
113
|
+
'--no-archived',
|
|
114
|
+
'--format',
|
|
115
|
+
'json',
|
|
116
|
+
])
|
|
117
|
+
const parsed = json(output)
|
|
118
|
+
expect(parsed.items.every((i: any) => !i.archived)).toBe(true)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('array option collects multiple values', async () => {
|
|
122
|
+
const { output } = await serve(createApp(), [
|
|
123
|
+
'auth',
|
|
124
|
+
'login',
|
|
125
|
+
'--scopes',
|
|
126
|
+
'read',
|
|
127
|
+
'--scopes',
|
|
128
|
+
'write',
|
|
129
|
+
'--verbose',
|
|
130
|
+
'--format',
|
|
131
|
+
'json',
|
|
132
|
+
])
|
|
133
|
+
const parsed = json(output)
|
|
134
|
+
expect(parsed.data.scopes).toMatchInlineSnapshot(`
|
|
135
|
+
[
|
|
136
|
+
"read",
|
|
137
|
+
"write",
|
|
138
|
+
]
|
|
139
|
+
`)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('number coercion from argv strings', async () => {
|
|
143
|
+
const { output } = await serve(createApp(), [
|
|
144
|
+
'project',
|
|
145
|
+
'list',
|
|
146
|
+
'--limit',
|
|
147
|
+
'5',
|
|
148
|
+
'--verbose',
|
|
149
|
+
'--format',
|
|
150
|
+
'json',
|
|
151
|
+
])
|
|
152
|
+
const parsed = json(output)
|
|
153
|
+
expect(parsed.data.total).toBe(1)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('enum validation fails for invalid value', async () => {
|
|
157
|
+
const { output, exitCode } = await serve(createApp(), ['project', 'list', '--sort', 'invalid'])
|
|
158
|
+
expect(exitCode).toBe(1)
|
|
159
|
+
expect(output).toContain('Error')
|
|
160
|
+
expect(output).toContain('sort')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('missing required arg fails validation', async () => {
|
|
164
|
+
const { output, exitCode } = await serve(createApp(), ['project', 'get'])
|
|
165
|
+
expect(exitCode).toBe(1)
|
|
166
|
+
expect(output).toContain('Error: missing required argument <id>')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('unknown flag returns error', async () => {
|
|
170
|
+
const { output, exitCode } = await serve(createApp(), ['ping', '--unknown-flag'])
|
|
171
|
+
expect(exitCode).toBe(1)
|
|
172
|
+
expect(output).toMatchInlineSnapshot(`
|
|
173
|
+
"Error: Unknown flag: --unknown-flag
|
|
174
|
+
"
|
|
175
|
+
`)
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('output formats', () => {
|
|
180
|
+
test('default TOON format', async () => {
|
|
181
|
+
const { output } = await serve(createApp(), ['ping'])
|
|
182
|
+
expect(output).toMatchInlineSnapshot(`
|
|
183
|
+
"pong: true
|
|
184
|
+
"
|
|
185
|
+
`)
|
|
186
|
+
expect(() => json(output)).toThrow()
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('--format json', async () => {
|
|
190
|
+
const { output } = await serve(createApp(), ['ping', '--format', 'json'])
|
|
191
|
+
expect(output).toMatchInlineSnapshot(`
|
|
192
|
+
"{
|
|
193
|
+
"pong": true
|
|
194
|
+
}
|
|
195
|
+
"
|
|
196
|
+
`)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('--json shorthand', async () => {
|
|
200
|
+
const { output } = await serve(createApp(), ['ping', '--json'])
|
|
201
|
+
expect(output).toMatchInlineSnapshot(`
|
|
202
|
+
"{
|
|
203
|
+
"pong": true
|
|
204
|
+
}
|
|
205
|
+
"
|
|
206
|
+
`)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('--format yaml', async () => {
|
|
210
|
+
const { output } = await serve(createApp(), ['ping', '--format', 'yaml'])
|
|
211
|
+
expect(output).toMatchInlineSnapshot(`
|
|
212
|
+
"pong: true
|
|
213
|
+
"
|
|
214
|
+
`)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('CLI-level default format', async () => {
|
|
218
|
+
const cli = Cli.create('test', { format: 'json' })
|
|
219
|
+
cli.command('ping', { run: () => ({ pong: true }) })
|
|
220
|
+
const { output } = await serve(cli, ['ping'])
|
|
221
|
+
expect(output).toMatchInlineSnapshot(`
|
|
222
|
+
"{
|
|
223
|
+
"pong": true
|
|
224
|
+
}
|
|
225
|
+
"
|
|
226
|
+
`)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('command-level default format', async () => {
|
|
230
|
+
const cli = Cli.create('test')
|
|
231
|
+
cli.command('ping', { format: 'json', run: () => ({ pong: true }) })
|
|
232
|
+
const { output } = await serve(cli, ['ping'])
|
|
233
|
+
expect(output).toMatchInlineSnapshot(`
|
|
234
|
+
"{
|
|
235
|
+
"pong": true
|
|
236
|
+
}
|
|
237
|
+
"
|
|
238
|
+
`)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('command-level format overrides CLI-level', async () => {
|
|
242
|
+
const cli = Cli.create('test', { format: 'yaml' })
|
|
243
|
+
cli.command('ping', { format: 'json', run: () => ({ pong: true }) })
|
|
244
|
+
const { output } = await serve(cli, ['ping'])
|
|
245
|
+
expect(output).toMatchInlineSnapshot(`
|
|
246
|
+
"{
|
|
247
|
+
"pong": true
|
|
248
|
+
}
|
|
249
|
+
"
|
|
250
|
+
`)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('--format flag overrides command-level default', async () => {
|
|
254
|
+
const cli = Cli.create('test')
|
|
255
|
+
cli.command('ping', { format: 'json', run: () => ({ pong: true }) })
|
|
256
|
+
const { output } = await serve(cli, ['ping', '--format', 'yaml'])
|
|
257
|
+
expect(output).toMatchInlineSnapshot(`
|
|
258
|
+
"pong: true
|
|
259
|
+
"
|
|
260
|
+
`)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('--verbose full envelope', async () => {
|
|
264
|
+
const { output } = await serve(createApp(), ['ping', '--verbose'])
|
|
265
|
+
expect(output).toMatchInlineSnapshot(`
|
|
266
|
+
"ok: true
|
|
267
|
+
data:
|
|
268
|
+
pong: true
|
|
269
|
+
meta:
|
|
270
|
+
command: ping
|
|
271
|
+
duration: <stripped>
|
|
272
|
+
"
|
|
273
|
+
`)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test('--verbose --format json full envelope', async () => {
|
|
277
|
+
const { output } = await serve(createApp(), ['ping', '--verbose', '--format', 'json'])
|
|
278
|
+
expect(json(output)).toMatchInlineSnapshot(`
|
|
279
|
+
{
|
|
280
|
+
"data": {
|
|
281
|
+
"pong": true,
|
|
282
|
+
},
|
|
283
|
+
"meta": {
|
|
284
|
+
"command": "ping",
|
|
285
|
+
"duration": "0ms",
|
|
286
|
+
},
|
|
287
|
+
"ok": true,
|
|
288
|
+
}
|
|
289
|
+
`)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
test('nested command path in verbose meta', async () => {
|
|
293
|
+
const { output } = await serve(createApp(), [
|
|
294
|
+
'project',
|
|
295
|
+
'deploy',
|
|
296
|
+
'status',
|
|
297
|
+
'd-1',
|
|
298
|
+
'--verbose',
|
|
299
|
+
'--format',
|
|
300
|
+
'json',
|
|
301
|
+
])
|
|
302
|
+
expect(json(output)).toMatchInlineSnapshot(`
|
|
303
|
+
{
|
|
304
|
+
"data": {
|
|
305
|
+
"deployId": "d-1",
|
|
306
|
+
"progress": 75,
|
|
307
|
+
"status": "running",
|
|
308
|
+
},
|
|
309
|
+
"meta": {
|
|
310
|
+
"command": "project deploy status",
|
|
311
|
+
"duration": "0ms",
|
|
312
|
+
},
|
|
313
|
+
"ok": true,
|
|
314
|
+
}
|
|
315
|
+
`)
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
describe('error handling', () => {
|
|
320
|
+
test('thrown Error shows human-readable message', async () => {
|
|
321
|
+
const { output, exitCode } = await serve(createApp(), ['explode'])
|
|
322
|
+
expect(exitCode).toBe(1)
|
|
323
|
+
expect(output).toMatchInlineSnapshot(`
|
|
324
|
+
"Error: kaboom
|
|
325
|
+
"
|
|
326
|
+
`)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
test('IncurError preserves code and retryable', async () => {
|
|
330
|
+
const { output, exitCode } = await serve(createApp(), ['explode-clac', '--format', 'json'])
|
|
331
|
+
expect(exitCode).toBe(1)
|
|
332
|
+
expect(json(output)).toMatchInlineSnapshot(`
|
|
333
|
+
{
|
|
334
|
+
"code": "QUOTA_EXCEEDED",
|
|
335
|
+
"message": "Rate limit exceeded",
|
|
336
|
+
"retryable": true,
|
|
337
|
+
}
|
|
338
|
+
`)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test('error() sentinel returns error envelope', async () => {
|
|
342
|
+
const { output, exitCode } = await serve(createApp(), [
|
|
343
|
+
'auth',
|
|
344
|
+
'status',
|
|
345
|
+
'--verbose',
|
|
346
|
+
'--format',
|
|
347
|
+
'json',
|
|
348
|
+
])
|
|
349
|
+
expect(exitCode).toBe(1)
|
|
350
|
+
expect(json(output)).toMatchInlineSnapshot(`
|
|
351
|
+
{
|
|
352
|
+
"error": {
|
|
353
|
+
"code": "NOT_AUTHENTICATED",
|
|
354
|
+
"message": "Not logged in",
|
|
355
|
+
"retryable": false,
|
|
356
|
+
},
|
|
357
|
+
"meta": {
|
|
358
|
+
"command": "auth status",
|
|
359
|
+
"cta": {
|
|
360
|
+
"commands": [
|
|
361
|
+
{
|
|
362
|
+
"command": "app auth login",
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
"description": "Suggested commands:",
|
|
366
|
+
},
|
|
367
|
+
"duration": "0ms",
|
|
368
|
+
},
|
|
369
|
+
"ok": false,
|
|
370
|
+
}
|
|
371
|
+
`)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
test('IncurError in nested command', async () => {
|
|
375
|
+
const { output, exitCode } = await serve(createApp(), [
|
|
376
|
+
'project',
|
|
377
|
+
'delete',
|
|
378
|
+
'p1',
|
|
379
|
+
'--format',
|
|
380
|
+
'json',
|
|
381
|
+
])
|
|
382
|
+
expect(exitCode).toBe(1)
|
|
383
|
+
expect(json(output)).toMatchInlineSnapshot(`
|
|
384
|
+
{
|
|
385
|
+
"code": "CONFIRMATION_REQUIRED",
|
|
386
|
+
"message": "Use --force to delete project p1",
|
|
387
|
+
"retryable": true,
|
|
388
|
+
}
|
|
389
|
+
`)
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
test('validation error includes field errors', async () => {
|
|
393
|
+
const { output, exitCode } = await serve(createApp(), ['validate-fail', '--format', 'json'])
|
|
394
|
+
expect(exitCode).toBe(1)
|
|
395
|
+
const parsed = json(output)
|
|
396
|
+
expect(parsed.fieldErrors.length).toBeGreaterThan(0)
|
|
397
|
+
expect(parsed.fieldErrors[0].path).toBe('email')
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
test('command not found returns error envelope', async () => {
|
|
401
|
+
const { output, exitCode } = await serve(createApp(), [
|
|
402
|
+
'nonexistent',
|
|
403
|
+
'--verbose',
|
|
404
|
+
'--format',
|
|
405
|
+
'json',
|
|
406
|
+
])
|
|
407
|
+
expect(exitCode).toBe(1)
|
|
408
|
+
expect(json(output)).toMatchInlineSnapshot(`
|
|
409
|
+
{
|
|
410
|
+
"error": {
|
|
411
|
+
"code": "COMMAND_NOT_FOUND",
|
|
412
|
+
"message": "'nonexistent' is not a command. See 'app --help' for a list of available commands.",
|
|
413
|
+
},
|
|
414
|
+
"meta": {
|
|
415
|
+
"command": "nonexistent",
|
|
416
|
+
"duration": "0ms",
|
|
417
|
+
},
|
|
418
|
+
"ok": false,
|
|
419
|
+
}
|
|
420
|
+
`)
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
test('error envelope respects --format json', async () => {
|
|
424
|
+
const { output, exitCode } = await serve(createApp(), ['explode', '--format', 'json'])
|
|
425
|
+
expect(exitCode).toBe(1)
|
|
426
|
+
expect(json(output)).toMatchInlineSnapshot(`
|
|
427
|
+
{
|
|
428
|
+
"code": "UNKNOWN",
|
|
429
|
+
"message": "kaboom",
|
|
430
|
+
}
|
|
431
|
+
`)
|
|
432
|
+
})
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
describe('cta', () => {
|
|
436
|
+
test('ok() with string CTAs', async () => {
|
|
437
|
+
const { output } = await serve(createApp(), ['auth', 'login', '--verbose', '--format', 'json'])
|
|
438
|
+
expect(json(output).meta.cta).toMatchInlineSnapshot(`
|
|
439
|
+
{
|
|
440
|
+
"commands": [
|
|
441
|
+
{
|
|
442
|
+
"command": "app auth status",
|
|
443
|
+
},
|
|
444
|
+
],
|
|
445
|
+
"description": "Verify your session:",
|
|
446
|
+
}
|
|
447
|
+
`)
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
test('ok() with object CTAs including descriptions', async () => {
|
|
451
|
+
const { output } = await serve(createApp(), [
|
|
452
|
+
'project',
|
|
453
|
+
'create',
|
|
454
|
+
'MyProject',
|
|
455
|
+
'--verbose',
|
|
456
|
+
'--format',
|
|
457
|
+
'json',
|
|
458
|
+
])
|
|
459
|
+
expect(json(output).meta.cta).toMatchInlineSnapshot(`
|
|
460
|
+
{
|
|
461
|
+
"commands": [
|
|
462
|
+
{
|
|
463
|
+
"command": "app project get p-new",
|
|
464
|
+
"description": "View "MyProject"",
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
"command": "app project list",
|
|
468
|
+
},
|
|
469
|
+
],
|
|
470
|
+
"description": "Suggested commands:",
|
|
471
|
+
}
|
|
472
|
+
`)
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
test('error() with CTA', async () => {
|
|
476
|
+
const { output } = await serve(createApp(), ['auth', 'status', '--verbose', '--format', 'json'])
|
|
477
|
+
expect(json(output).meta.cta).toMatchInlineSnapshot(`
|
|
478
|
+
{
|
|
479
|
+
"commands": [
|
|
480
|
+
{
|
|
481
|
+
"command": "app auth login",
|
|
482
|
+
},
|
|
483
|
+
],
|
|
484
|
+
"description": "Suggested commands:",
|
|
485
|
+
}
|
|
486
|
+
`)
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
test('plain return omits CTA', async () => {
|
|
490
|
+
const { output } = await serve(createApp(), ['ping', '--verbose', '--format', 'json'])
|
|
491
|
+
expect(json(output).meta.cta).toBeUndefined()
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
test('dynamic CTA list from data', async () => {
|
|
495
|
+
const { output } = await serve(createApp(), [
|
|
496
|
+
'project',
|
|
497
|
+
'list',
|
|
498
|
+
'--archived',
|
|
499
|
+
'--verbose',
|
|
500
|
+
'--format',
|
|
501
|
+
'json',
|
|
502
|
+
])
|
|
503
|
+
expect(json(output).meta.cta).toMatchInlineSnapshot(`
|
|
504
|
+
{
|
|
505
|
+
"commands": [
|
|
506
|
+
{
|
|
507
|
+
"command": "app project get p1",
|
|
508
|
+
"description": "View "Alpha"",
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
"command": "app project get p2",
|
|
512
|
+
"description": "View "Beta"",
|
|
513
|
+
},
|
|
514
|
+
],
|
|
515
|
+
"description": "Suggested commands:",
|
|
516
|
+
}
|
|
517
|
+
`)
|
|
518
|
+
})
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
describe('async', () => {
|
|
522
|
+
test('async handler resolves', async () => {
|
|
523
|
+
const { output } = await serve(createApp(), ['slow'])
|
|
524
|
+
expect(output).toMatchInlineSnapshot(`
|
|
525
|
+
"done: true
|
|
526
|
+
"
|
|
527
|
+
`)
|
|
528
|
+
})
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
describe('streaming', () => {
|
|
532
|
+
test('default streams toon per chunk (human)', async () => {
|
|
533
|
+
const { output } = await serve(createApp(), ['stream'])
|
|
534
|
+
expect(output).toMatchInlineSnapshot(`
|
|
535
|
+
"content: hello
|
|
536
|
+
content: world
|
|
537
|
+
"
|
|
538
|
+
`)
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
test('default streams toon per chunk (--verbose)', async () => {
|
|
542
|
+
const { output } = await serve(createApp(), ['stream', '--verbose'])
|
|
543
|
+
expect(output).toMatchInlineSnapshot(`
|
|
544
|
+
"content: hello
|
|
545
|
+
content: world
|
|
546
|
+
"
|
|
547
|
+
`)
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
test('--format json buffers all chunks', async () => {
|
|
551
|
+
const { output } = await serve(createApp(), ['stream', '--format', 'json'])
|
|
552
|
+
expect(json(output)).toMatchInlineSnapshot(`
|
|
553
|
+
[
|
|
554
|
+
{
|
|
555
|
+
"content": "hello",
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
"content": "world",
|
|
559
|
+
},
|
|
560
|
+
]
|
|
561
|
+
`)
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
test('--format json --verbose buffers with envelope', async () => {
|
|
565
|
+
const { output } = await serve(createApp(), ['stream', '--verbose', '--format', 'json'])
|
|
566
|
+
expect(json(output)).toMatchInlineSnapshot(`
|
|
567
|
+
{
|
|
568
|
+
"data": [
|
|
569
|
+
{
|
|
570
|
+
"content": "hello",
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
"content": "world",
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
"meta": {
|
|
577
|
+
"command": "stream",
|
|
578
|
+
"duration": "0ms",
|
|
579
|
+
},
|
|
580
|
+
"ok": true,
|
|
581
|
+
}
|
|
582
|
+
`)
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
test('--format jsonl explicit', async () => {
|
|
586
|
+
const { output } = await serve(createApp(), ['stream', '--format', 'jsonl'])
|
|
587
|
+
const lines = output
|
|
588
|
+
.trim()
|
|
589
|
+
.split('\n')
|
|
590
|
+
.map((l) => JSON.parse(l))
|
|
591
|
+
expect(lines[0]).toEqual({ type: 'chunk', data: { content: 'hello' } })
|
|
592
|
+
expect(lines[1]).toEqual({ type: 'chunk', data: { content: 'world' } })
|
|
593
|
+
expect(lines[2].type).toBe('done')
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
test('ok() CTA in jsonl done record', async () => {
|
|
597
|
+
const { output } = await serve(createApp(), ['stream-ok', '--format', 'jsonl'])
|
|
598
|
+
const lines = output
|
|
599
|
+
.trim()
|
|
600
|
+
.split('\n')
|
|
601
|
+
.map((l) => JSON.parse(l))
|
|
602
|
+
const done = lines.find((l: any) => l.type === 'done')
|
|
603
|
+
expect(done.meta.cta).toMatchInlineSnapshot(`
|
|
604
|
+
{
|
|
605
|
+
"commands": [
|
|
606
|
+
{
|
|
607
|
+
"command": "app ping",
|
|
608
|
+
},
|
|
609
|
+
],
|
|
610
|
+
"description": "Suggested commands:",
|
|
611
|
+
}
|
|
612
|
+
`)
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
test('ok() CTA shows after toon stream', async () => {
|
|
616
|
+
const { output } = await serve(createApp(), ['stream-ok'])
|
|
617
|
+
expect(output).toContain('n: 1')
|
|
618
|
+
expect(output).toContain('n: 2')
|
|
619
|
+
expect(output).toContain('Suggested commands:')
|
|
620
|
+
expect(output).toContain('app ping')
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
test('error() mid-stream in jsonl', async () => {
|
|
624
|
+
const { output, exitCode } = await serve(createApp(), ['stream-error', '--format', 'jsonl'])
|
|
625
|
+
const lines = output
|
|
626
|
+
.trim()
|
|
627
|
+
.split('\n')
|
|
628
|
+
.map((l) => JSON.parse(l))
|
|
629
|
+
expect(lines[0]).toEqual({ type: 'chunk', data: { n: 1 } })
|
|
630
|
+
expect(lines[1]).toMatchInlineSnapshot(`
|
|
631
|
+
{
|
|
632
|
+
"error": {
|
|
633
|
+
"code": "STREAM_FAIL",
|
|
634
|
+
"message": "broke mid-stream",
|
|
635
|
+
},
|
|
636
|
+
"ok": false,
|
|
637
|
+
"type": "error",
|
|
638
|
+
}
|
|
639
|
+
`)
|
|
640
|
+
expect(exitCode).toBe(1)
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
test('error() mid-stream in toon', async () => {
|
|
644
|
+
const { output, exitCode } = await serve(createApp(), ['stream-error'])
|
|
645
|
+
expect(output).toContain('n: 1')
|
|
646
|
+
expect(output).toContain('Error (STREAM_FAIL): broke mid-stream')
|
|
647
|
+
expect(exitCode).toBe(1)
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
test('thrown error mid-stream in jsonl', async () => {
|
|
651
|
+
const { output, exitCode } = await serve(createApp(), ['stream-throw', '--format', 'jsonl'])
|
|
652
|
+
const lines = output
|
|
653
|
+
.trim()
|
|
654
|
+
.split('\n')
|
|
655
|
+
.map((l) => JSON.parse(l))
|
|
656
|
+
expect(lines[0]).toEqual({ type: 'chunk', data: { n: 1 } })
|
|
657
|
+
expect(lines[1]).toMatchInlineSnapshot(`
|
|
658
|
+
{
|
|
659
|
+
"error": {
|
|
660
|
+
"code": "UNKNOWN",
|
|
661
|
+
"message": "stream kaboom",
|
|
662
|
+
},
|
|
663
|
+
"ok": false,
|
|
664
|
+
"type": "error",
|
|
665
|
+
}
|
|
666
|
+
`)
|
|
667
|
+
expect(exitCode).toBe(1)
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
test('thrown error mid-stream in toon', async () => {
|
|
671
|
+
const { output, exitCode } = await serve(createApp(), ['stream-throw'])
|
|
672
|
+
expect(output).toContain('n: 1')
|
|
673
|
+
expect(output).toContain('Error: stream kaboom')
|
|
674
|
+
expect(exitCode).toBe(1)
|
|
675
|
+
})
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
describe('help', () => {
|
|
679
|
+
test('root help (no args)', async () => {
|
|
680
|
+
const { output, exitCode } = await serve(createApp(), [])
|
|
681
|
+
expect(exitCode).toBeUndefined()
|
|
682
|
+
expect(output).toMatchInlineSnapshot(`
|
|
683
|
+
"app — A comprehensive CLI application for testing.
|
|
684
|
+
v3.5.0
|
|
685
|
+
|
|
686
|
+
Usage: app <command>
|
|
687
|
+
|
|
688
|
+
Commands:
|
|
689
|
+
auth Authentication commands
|
|
690
|
+
config Show current configuration
|
|
691
|
+
echo Echo back arguments
|
|
692
|
+
explode Always fails
|
|
693
|
+
explode-clac Fails with IncurError
|
|
694
|
+
ping Health check
|
|
695
|
+
project Manage projects
|
|
696
|
+
slow Async command
|
|
697
|
+
stream Stream chunks
|
|
698
|
+
stream-error Stream with mid-stream error
|
|
699
|
+
stream-ok Stream with ok() return
|
|
700
|
+
stream-throw Stream that throws
|
|
701
|
+
validate-fail Fails validation
|
|
702
|
+
|
|
703
|
+
Built-in Commands:
|
|
704
|
+
mcp add Register as an MCP server
|
|
705
|
+
skills add Sync skill files to your agent
|
|
706
|
+
|
|
707
|
+
Global Options:
|
|
708
|
+
--format <toon|json|yaml|md|jsonl> Output format
|
|
709
|
+
--help Show help
|
|
710
|
+
--llms Print LLM-readable manifest
|
|
711
|
+
--mcp Start as MCP stdio server
|
|
712
|
+
--verbose Show full output envelope
|
|
713
|
+
--version Show version
|
|
714
|
+
"
|
|
715
|
+
`)
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
test('--help on root', async () => {
|
|
719
|
+
const { output } = await serve(createApp(), ['--help'])
|
|
720
|
+
expect(output).toContain('Usage: app <command>')
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
test('group help (no subcommand)', async () => {
|
|
724
|
+
const { output, exitCode } = await serve(createApp(), ['auth'])
|
|
725
|
+
expect(exitCode).toBeUndefined()
|
|
726
|
+
expect(output).toMatchInlineSnapshot(`
|
|
727
|
+
"app auth — Authentication commands
|
|
728
|
+
|
|
729
|
+
Usage: app auth <command>
|
|
730
|
+
|
|
731
|
+
Commands:
|
|
732
|
+
login Log in to the service
|
|
733
|
+
logout Log out of the service
|
|
734
|
+
status Show authentication status
|
|
735
|
+
|
|
736
|
+
Global Options:
|
|
737
|
+
--format <toon|json|yaml|md|jsonl> Output format
|
|
738
|
+
--help Show help
|
|
739
|
+
--llms Print LLM-readable manifest
|
|
740
|
+
--verbose Show full output envelope
|
|
741
|
+
"
|
|
742
|
+
`)
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
test('nested group help', async () => {
|
|
746
|
+
const { output, exitCode } = await serve(createApp(), ['project', 'deploy'])
|
|
747
|
+
expect(exitCode).toBeUndefined()
|
|
748
|
+
expect(output).toMatchInlineSnapshot(`
|
|
749
|
+
"app project deploy — Deployment commands
|
|
750
|
+
|
|
751
|
+
Usage: app project deploy <command>
|
|
752
|
+
|
|
753
|
+
Commands:
|
|
754
|
+
create Create a deployment
|
|
755
|
+
rollback Rollback a deployment
|
|
756
|
+
status Check deployment status
|
|
757
|
+
|
|
758
|
+
Global Options:
|
|
759
|
+
--format <toon|json|yaml|md|jsonl> Output format
|
|
760
|
+
--help Show help
|
|
761
|
+
--llms Print LLM-readable manifest
|
|
762
|
+
--verbose Show full output envelope
|
|
763
|
+
"
|
|
764
|
+
`)
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
test('--help on leaf command', async () => {
|
|
768
|
+
const { output } = await serve(createApp(), ['project', 'list', '--help'])
|
|
769
|
+
expect(output).toMatchInlineSnapshot(`
|
|
770
|
+
"app project list — List projects
|
|
771
|
+
|
|
772
|
+
Usage: app project list [options]
|
|
773
|
+
|
|
774
|
+
Options:
|
|
775
|
+
--limit, -l <number> Max results (default: 20)
|
|
776
|
+
--sort, -s <value> Sort field (default: name)
|
|
777
|
+
--archived <boolean> Include archived (default: false)
|
|
778
|
+
|
|
779
|
+
Global Options:
|
|
780
|
+
--format <toon|json|yaml|md|jsonl> Output format
|
|
781
|
+
--help Show help
|
|
782
|
+
--llms Print LLM-readable manifest
|
|
783
|
+
--verbose Show full output envelope
|
|
784
|
+
"
|
|
785
|
+
`)
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
test('--help includes examples', async () => {
|
|
789
|
+
const { output } = await serve(createApp(), ['project', 'deploy', 'create', '--help'])
|
|
790
|
+
expect(output).toMatchInlineSnapshot(`
|
|
791
|
+
"app project deploy create — Create a deployment
|
|
792
|
+
|
|
793
|
+
Usage: app project deploy create <env> [options]
|
|
794
|
+
|
|
795
|
+
Arguments:
|
|
796
|
+
env Target environment
|
|
797
|
+
|
|
798
|
+
Options:
|
|
799
|
+
--branch, -b <string> Branch to deploy (default: main)
|
|
800
|
+
--dry-run <boolean> Dry run mode (default: false)
|
|
801
|
+
|
|
802
|
+
Examples:
|
|
803
|
+
$ app project deploy create staging Deploy staging from main
|
|
804
|
+
$ app project deploy create production --branch release --dryRun true Dry run a production deploy
|
|
805
|
+
|
|
806
|
+
Global Options:
|
|
807
|
+
--format <toon|json|yaml|md|jsonl> Output format
|
|
808
|
+
--help Show help
|
|
809
|
+
--llms Print LLM-readable manifest
|
|
810
|
+
--verbose Show full output envelope
|
|
811
|
+
"
|
|
812
|
+
`)
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
test('--help on group shows group help', async () => {
|
|
816
|
+
const { output } = await serve(createApp(), ['project', '--help'])
|
|
817
|
+
expect(output).toContain('app project')
|
|
818
|
+
expect(output).toContain('deploy')
|
|
819
|
+
expect(output).toContain('list')
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
test('--version', async () => {
|
|
823
|
+
const { output } = await serve(createApp(), ['--version'])
|
|
824
|
+
expect(output).toMatchInlineSnapshot(`
|
|
825
|
+
"3.5.0
|
|
826
|
+
"
|
|
827
|
+
`)
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
test('--help takes precedence over --version', async () => {
|
|
831
|
+
const { output } = await serve(createApp(), ['--help', '--version'])
|
|
832
|
+
expect(output).toContain('Usage: app <command>')
|
|
833
|
+
expect(output).toContain('v3.5.0')
|
|
834
|
+
})
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
describe('--llms', () => {
|
|
838
|
+
test('json manifest lists all leaf commands sorted', async () => {
|
|
839
|
+
const { output } = await serve(createApp(), ['--llms', '--format', 'json'])
|
|
840
|
+
const manifest = json(output)
|
|
841
|
+
expect(manifest.version).toBe('incur.v1')
|
|
842
|
+
const names = manifest.commands.map((c: any) => c.name)
|
|
843
|
+
expect(names).toMatchInlineSnapshot(`
|
|
844
|
+
[
|
|
845
|
+
"auth login",
|
|
846
|
+
"auth logout",
|
|
847
|
+
"auth status",
|
|
848
|
+
"config",
|
|
849
|
+
"echo",
|
|
850
|
+
"explode",
|
|
851
|
+
"explode-clac",
|
|
852
|
+
"ping",
|
|
853
|
+
"project create",
|
|
854
|
+
"project delete",
|
|
855
|
+
"project deploy create",
|
|
856
|
+
"project deploy rollback",
|
|
857
|
+
"project deploy status",
|
|
858
|
+
"project get",
|
|
859
|
+
"project list",
|
|
860
|
+
"slow",
|
|
861
|
+
"stream",
|
|
862
|
+
"stream-error",
|
|
863
|
+
"stream-ok",
|
|
864
|
+
"stream-throw",
|
|
865
|
+
"validate-fail",
|
|
866
|
+
]
|
|
867
|
+
`)
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
test('manifest includes schema.args and schema.options separately', async () => {
|
|
871
|
+
const { output } = await serve(createApp(), ['--llms', '--format', 'json'])
|
|
872
|
+
const projectList = json(output).commands.find((c: any) => c.name === 'project list')
|
|
873
|
+
expect(projectList.schema.options.properties).toMatchInlineSnapshot(`
|
|
874
|
+
{
|
|
875
|
+
"archived": {
|
|
876
|
+
"default": false,
|
|
877
|
+
"description": "Include archived",
|
|
878
|
+
"type": "boolean",
|
|
879
|
+
},
|
|
880
|
+
"limit": {
|
|
881
|
+
"default": 20,
|
|
882
|
+
"description": "Max results",
|
|
883
|
+
"type": "number",
|
|
884
|
+
},
|
|
885
|
+
"sort": {
|
|
886
|
+
"default": "name",
|
|
887
|
+
"description": "Sort field",
|
|
888
|
+
"enum": [
|
|
889
|
+
"name",
|
|
890
|
+
"created",
|
|
891
|
+
"updated",
|
|
892
|
+
],
|
|
893
|
+
"type": "string",
|
|
894
|
+
},
|
|
895
|
+
}
|
|
896
|
+
`)
|
|
897
|
+
expect(projectList.schema.args).toBeUndefined()
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
test('manifest includes schema.output', async () => {
|
|
901
|
+
const { output } = await serve(createApp(), ['--llms', '--format', 'json'])
|
|
902
|
+
const projectGet = json(output).commands.find((c: any) => c.name === 'project get')
|
|
903
|
+
expect(projectGet.schema.output).toMatchInlineSnapshot(`
|
|
904
|
+
{
|
|
905
|
+
"additionalProperties": false,
|
|
906
|
+
"properties": {
|
|
907
|
+
"description": {
|
|
908
|
+
"type": "string",
|
|
909
|
+
},
|
|
910
|
+
"id": {
|
|
911
|
+
"type": "string",
|
|
912
|
+
},
|
|
913
|
+
"members": {
|
|
914
|
+
"items": {
|
|
915
|
+
"additionalProperties": false,
|
|
916
|
+
"properties": {
|
|
917
|
+
"role": {
|
|
918
|
+
"type": "string",
|
|
919
|
+
},
|
|
920
|
+
"userId": {
|
|
921
|
+
"type": "string",
|
|
922
|
+
},
|
|
923
|
+
},
|
|
924
|
+
"required": [
|
|
925
|
+
"userId",
|
|
926
|
+
"role",
|
|
927
|
+
],
|
|
928
|
+
"type": "object",
|
|
929
|
+
},
|
|
930
|
+
"type": "array",
|
|
931
|
+
},
|
|
932
|
+
"name": {
|
|
933
|
+
"type": "string",
|
|
934
|
+
},
|
|
935
|
+
},
|
|
936
|
+
"required": [
|
|
937
|
+
"id",
|
|
938
|
+
"name",
|
|
939
|
+
"description",
|
|
940
|
+
"members",
|
|
941
|
+
],
|
|
942
|
+
"type": "object",
|
|
943
|
+
}
|
|
944
|
+
`)
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
test('manifest omits schema when no schemas defined', async () => {
|
|
948
|
+
const { output } = await serve(createApp(), ['--llms', '--format', 'json'])
|
|
949
|
+
const ping = json(output).commands.find((c: any) => c.name === 'ping')
|
|
950
|
+
expect(ping.schema).toBeUndefined()
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
test('scoped --llms to group', async () => {
|
|
954
|
+
const { output } = await serve(createApp(), ['auth', '--llms', '--format', 'json'])
|
|
955
|
+
const names = json(output).commands.map((c: any) => c.name)
|
|
956
|
+
expect(names).toMatchInlineSnapshot(`
|
|
957
|
+
[
|
|
958
|
+
"auth login",
|
|
959
|
+
"auth logout",
|
|
960
|
+
"auth status",
|
|
961
|
+
]
|
|
962
|
+
`)
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
test('scoped --llms to nested group', async () => {
|
|
966
|
+
const { output } = await serve(createApp(), ['project', 'deploy', '--llms', '--format', 'json'])
|
|
967
|
+
const names = json(output).commands.map((c: any) => c.name)
|
|
968
|
+
expect(names).toMatchInlineSnapshot(`
|
|
969
|
+
[
|
|
970
|
+
"project deploy create",
|
|
971
|
+
"project deploy rollback",
|
|
972
|
+
"project deploy status",
|
|
973
|
+
]
|
|
974
|
+
`)
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
test('default --llms outputs markdown', async () => {
|
|
978
|
+
const { output } = await serve(createApp(), ['--llms'])
|
|
979
|
+
expect(output).toContain('# app')
|
|
980
|
+
expect(output).toContain('auth login')
|
|
981
|
+
expect(output).toContain('project list')
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
test('--llms markdown includes argument tables', async () => {
|
|
985
|
+
const { output } = await serve(createApp(), ['project', '--llms'])
|
|
986
|
+
expect(output).toContain('Arguments')
|
|
987
|
+
expect(output).toContain('`id`')
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
test('--llms markdown includes options tables', async () => {
|
|
991
|
+
const { output } = await serve(createApp(), ['project', '--llms'])
|
|
992
|
+
expect(output).toContain('Options')
|
|
993
|
+
expect(output).toContain('`--limit`')
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
test('--llms json includes examples on commands', async () => {
|
|
997
|
+
const { output } = await serve(createApp(), ['project', 'deploy', '--llms', '--format', 'json'])
|
|
998
|
+
const deployCreate = json(output).commands.find((c: any) => c.name === 'project deploy create')
|
|
999
|
+
expect(deployCreate.examples).toMatchInlineSnapshot(`
|
|
1000
|
+
[
|
|
1001
|
+
{
|
|
1002
|
+
"command": "project deploy create staging",
|
|
1003
|
+
"description": "Deploy staging from main",
|
|
1004
|
+
},
|
|
1005
|
+
{
|
|
1006
|
+
"command": "project deploy create production --branch release --dryRun true",
|
|
1007
|
+
"description": "Dry run a production deploy",
|
|
1008
|
+
},
|
|
1009
|
+
]
|
|
1010
|
+
`)
|
|
1011
|
+
})
|
|
1012
|
+
|
|
1013
|
+
test('--llms json omits examples when not defined', async () => {
|
|
1014
|
+
const { output } = await serve(createApp(), ['--llms', '--format', 'json'])
|
|
1015
|
+
const ping = json(output).commands.find((c: any) => c.name === 'ping')
|
|
1016
|
+
expect(ping.examples).toBeUndefined()
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
test('--llms markdown includes examples section', async () => {
|
|
1020
|
+
const { output } = await serve(createApp(), ['--llms'])
|
|
1021
|
+
expect(output).toContain('Examples')
|
|
1022
|
+
expect(output).toContain('Deploy staging from main')
|
|
1023
|
+
expect(output).toContain('app project deploy create staging')
|
|
1024
|
+
})
|
|
1025
|
+
|
|
1026
|
+
test('--llms markdown includes output tables', async () => {
|
|
1027
|
+
const { output } = await serve(createApp(), ['project', '--llms'])
|
|
1028
|
+
expect(output).toContain('Output')
|
|
1029
|
+
})
|
|
1030
|
+
|
|
1031
|
+
test('--llms --format yaml', async () => {
|
|
1032
|
+
const { output } = await serve(createApp(), ['--llms', '--format', 'yaml'])
|
|
1033
|
+
expect(output).toContain('version: incur.v1')
|
|
1034
|
+
})
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
describe('typegen', () => {
|
|
1038
|
+
test('generates correct .d.ts for entire CLI', () => {
|
|
1039
|
+
expect(Typegen.fromCli(createApp())).toMatchInlineSnapshot(`
|
|
1040
|
+
"declare module 'incur' {
|
|
1041
|
+
interface Register {
|
|
1042
|
+
commands: {
|
|
1043
|
+
'auth login': { args: {}; options: { hostname: string; web: boolean; scopes: string[] } }
|
|
1044
|
+
'auth logout': { args: {}; options: {} }
|
|
1045
|
+
'auth status': { args: {}; options: {} }
|
|
1046
|
+
'config': { args: { key: string }; options: {} }
|
|
1047
|
+
'echo': { args: { message: string; repeat: number }; options: { upper: boolean; prefix: string } }
|
|
1048
|
+
'explode': { args: {}; options: {} }
|
|
1049
|
+
'explode-clac': { args: {}; options: {} }
|
|
1050
|
+
'ping': { args: {}; options: {} }
|
|
1051
|
+
'project create': { args: { name: string }; options: { description: string; private: boolean } }
|
|
1052
|
+
'project delete': { args: { id: string }; options: { force: boolean } }
|
|
1053
|
+
'project deploy create': { args: { env: string }; options: { branch: string; dryRun: boolean } }
|
|
1054
|
+
'project deploy rollback': { args: { deployId: string }; options: {} }
|
|
1055
|
+
'project deploy status': { args: { deployId: string }; options: {} }
|
|
1056
|
+
'project get': { args: { id: string }; options: {} }
|
|
1057
|
+
'project list': { args: {}; options: { limit: number; sort: "name" | "created" | "updated"; archived: boolean } }
|
|
1058
|
+
'slow': { args: {}; options: {} }
|
|
1059
|
+
'stream': { args: {}; options: {} }
|
|
1060
|
+
'stream-error': { args: {}; options: {} }
|
|
1061
|
+
'stream-ok': { args: {}; options: {} }
|
|
1062
|
+
'stream-throw': { args: {}; options: {} }
|
|
1063
|
+
'validate-fail': { args: { email: string; age: number }; options: {} }
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
"
|
|
1068
|
+
`)
|
|
1069
|
+
})
|
|
1070
|
+
})
|
|
1071
|
+
|
|
1072
|
+
describe('composition', () => {
|
|
1073
|
+
test('multiple groups on same parent', async () => {
|
|
1074
|
+
const cli = createApp()
|
|
1075
|
+
const { output: o1 } = await serve(cli, ['auth', 'logout'])
|
|
1076
|
+
expect(o1).toMatchInlineSnapshot(`
|
|
1077
|
+
"loggedOut: true
|
|
1078
|
+
"
|
|
1079
|
+
`)
|
|
1080
|
+
const { output: o2 } = await serve(cli, ['project', 'list', '--format', 'json'])
|
|
1081
|
+
expect(json(o2).items).toBeDefined()
|
|
1082
|
+
const { output: o3 } = await serve(cli, ['ping'])
|
|
1083
|
+
expect(o3).toMatchInlineSnapshot(`
|
|
1084
|
+
"pong: true
|
|
1085
|
+
"
|
|
1086
|
+
`)
|
|
1087
|
+
})
|
|
1088
|
+
|
|
1089
|
+
test('deeply nested deploy commands work alongside siblings', async () => {
|
|
1090
|
+
const cli = createApp()
|
|
1091
|
+
const { output: o1 } = await serve(cli, ['project', 'deploy', 'create', 'staging'])
|
|
1092
|
+
expect(o1).toMatchInlineSnapshot(`
|
|
1093
|
+
"deployId: d-123
|
|
1094
|
+
url: "https://staging.example.com"
|
|
1095
|
+
status: pending
|
|
1096
|
+
"
|
|
1097
|
+
`)
|
|
1098
|
+
const { output: o2 } = await serve(cli, ['project', 'list', '--format', 'json'])
|
|
1099
|
+
expect(json(o2).items).toBeDefined()
|
|
1100
|
+
})
|
|
1101
|
+
|
|
1102
|
+
test('leaf CLI mounted alongside groups', async () => {
|
|
1103
|
+
const cli = createApp()
|
|
1104
|
+
const { output: o1 } = await serve(cli, ['config'])
|
|
1105
|
+
expect(o1).toMatchInlineSnapshot(`
|
|
1106
|
+
"apiUrl: "https://api.example.com"
|
|
1107
|
+
timeout: 30
|
|
1108
|
+
debug: false
|
|
1109
|
+
"
|
|
1110
|
+
`)
|
|
1111
|
+
const { output: o2 } = await serve(cli, ['auth', 'logout'])
|
|
1112
|
+
expect(o2).toMatchInlineSnapshot(`
|
|
1113
|
+
"loggedOut: true
|
|
1114
|
+
"
|
|
1115
|
+
`)
|
|
1116
|
+
})
|
|
1117
|
+
|
|
1118
|
+
test('create with single options object', async () => {
|
|
1119
|
+
const cli = Cli.create({
|
|
1120
|
+
name: 'one-shot',
|
|
1121
|
+
description: 'Single object form',
|
|
1122
|
+
run: () => ({ result: 42 }),
|
|
1123
|
+
})
|
|
1124
|
+
expect(cli.name).toBe('one-shot')
|
|
1125
|
+
const { output } = await serve(cli, [])
|
|
1126
|
+
expect(output).toMatchInlineSnapshot(`
|
|
1127
|
+
"result: 42
|
|
1128
|
+
"
|
|
1129
|
+
`)
|
|
1130
|
+
})
|
|
1131
|
+
})
|
|
1132
|
+
|
|
1133
|
+
describe('edge cases', () => {
|
|
1134
|
+
test('command with only options (no args)', async () => {
|
|
1135
|
+
const { output } = await serve(createApp(), [
|
|
1136
|
+
'project',
|
|
1137
|
+
'list',
|
|
1138
|
+
'--limit',
|
|
1139
|
+
'1',
|
|
1140
|
+
'--format',
|
|
1141
|
+
'json',
|
|
1142
|
+
])
|
|
1143
|
+
expect(json(output)).toMatchInlineSnapshot(`
|
|
1144
|
+
{
|
|
1145
|
+
"cta": {
|
|
1146
|
+
"commands": [
|
|
1147
|
+
{
|
|
1148
|
+
"command": "app project get p1",
|
|
1149
|
+
"description": "View "Alpha"",
|
|
1150
|
+
},
|
|
1151
|
+
],
|
|
1152
|
+
"description": "Suggested commands:",
|
|
1153
|
+
},
|
|
1154
|
+
"items": [
|
|
1155
|
+
{
|
|
1156
|
+
"archived": false,
|
|
1157
|
+
"id": "p1",
|
|
1158
|
+
"name": "Alpha",
|
|
1159
|
+
},
|
|
1160
|
+
],
|
|
1161
|
+
"total": 1,
|
|
1162
|
+
}
|
|
1163
|
+
`)
|
|
1164
|
+
})
|
|
1165
|
+
|
|
1166
|
+
test('command with only args (no options)', async () => {
|
|
1167
|
+
const { output } = await serve(createApp(), ['project', 'get', 'p1', '--format', 'json'])
|
|
1168
|
+
expect(json(output)).toMatchInlineSnapshot(`
|
|
1169
|
+
{
|
|
1170
|
+
"description": "Main project",
|
|
1171
|
+
"id": "p1",
|
|
1172
|
+
"members": [
|
|
1173
|
+
{
|
|
1174
|
+
"role": "admin",
|
|
1175
|
+
"userId": "u1",
|
|
1176
|
+
},
|
|
1177
|
+
],
|
|
1178
|
+
"name": "Alpha",
|
|
1179
|
+
}
|
|
1180
|
+
`)
|
|
1181
|
+
})
|
|
1182
|
+
|
|
1183
|
+
test('command with no schemas at all', async () => {
|
|
1184
|
+
const { output } = await serve(createApp(), ['ping', '--format', 'json'])
|
|
1185
|
+
expect(json(output)).toMatchInlineSnapshot(`
|
|
1186
|
+
{
|
|
1187
|
+
"pong": true,
|
|
1188
|
+
}
|
|
1189
|
+
`)
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1192
|
+
test('optional arg can be omitted', async () => {
|
|
1193
|
+
const { output } = await serve(createApp(), ['config', '--format', 'json'])
|
|
1194
|
+
expect(json(output)).toMatchInlineSnapshot(`
|
|
1195
|
+
{
|
|
1196
|
+
"apiUrl": "https://api.example.com",
|
|
1197
|
+
"debug": false,
|
|
1198
|
+
"timeout": 30,
|
|
1199
|
+
}
|
|
1200
|
+
`)
|
|
1201
|
+
})
|
|
1202
|
+
|
|
1203
|
+
test('--force passes through to handler', async () => {
|
|
1204
|
+
const { output } = await serve(createApp(), [
|
|
1205
|
+
'project',
|
|
1206
|
+
'delete',
|
|
1207
|
+
'p1',
|
|
1208
|
+
'--force',
|
|
1209
|
+
'--format',
|
|
1210
|
+
'json',
|
|
1211
|
+
])
|
|
1212
|
+
expect(json(output)).toMatchInlineSnapshot(`
|
|
1213
|
+
{
|
|
1214
|
+
"deleted": true,
|
|
1215
|
+
"id": "p1",
|
|
1216
|
+
}
|
|
1217
|
+
`)
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1220
|
+
test('flag order does not matter', async () => {
|
|
1221
|
+
const { output } = await serve(createApp(), [
|
|
1222
|
+
'--format',
|
|
1223
|
+
'json',
|
|
1224
|
+
'project',
|
|
1225
|
+
'deploy',
|
|
1226
|
+
'create',
|
|
1227
|
+
'prod',
|
|
1228
|
+
'--branch',
|
|
1229
|
+
'release',
|
|
1230
|
+
'--verbose',
|
|
1231
|
+
])
|
|
1232
|
+
expect(json(output)).toMatchInlineSnapshot(`
|
|
1233
|
+
{
|
|
1234
|
+
"data": {
|
|
1235
|
+
"deployId": "d-123",
|
|
1236
|
+
"status": "pending",
|
|
1237
|
+
"url": "https://prod.example.com",
|
|
1238
|
+
},
|
|
1239
|
+
"meta": {
|
|
1240
|
+
"command": "project deploy create",
|
|
1241
|
+
"duration": "0ms",
|
|
1242
|
+
},
|
|
1243
|
+
"ok": true,
|
|
1244
|
+
}
|
|
1245
|
+
`)
|
|
1246
|
+
})
|
|
1247
|
+
|
|
1248
|
+
test('empty argv on router shows help', async () => {
|
|
1249
|
+
const { output, exitCode } = await serve(createApp(), [])
|
|
1250
|
+
expect(exitCode).toBeUndefined()
|
|
1251
|
+
expect(output).toContain('Usage: app <command>')
|
|
1252
|
+
})
|
|
1253
|
+
})
|
|
1254
|
+
|
|
1255
|
+
describe('env', () => {
|
|
1256
|
+
test('env vars passed to handler', async () => {
|
|
1257
|
+
const { output } = await serve(
|
|
1258
|
+
createApp(),
|
|
1259
|
+
['auth', 'login', '--verbose', '--format', 'json'],
|
|
1260
|
+
{ env: { AUTH_HOST: 'custom.example.com' } },
|
|
1261
|
+
)
|
|
1262
|
+
expect(json(output).data.hostname).toBe('custom.example.com')
|
|
1263
|
+
})
|
|
1264
|
+
|
|
1265
|
+
test('env defaults applied when var is unset', async () => {
|
|
1266
|
+
const { output } = await serve(
|
|
1267
|
+
createApp(),
|
|
1268
|
+
['auth', 'login', '--verbose', '--format', 'json'],
|
|
1269
|
+
{ env: {} },
|
|
1270
|
+
)
|
|
1271
|
+
expect(json(output).data.hostname).toBe('api.example.com')
|
|
1272
|
+
})
|
|
1273
|
+
|
|
1274
|
+
test('--help shows env vars section', async () => {
|
|
1275
|
+
const { output } = await serve(createApp(), ['auth', 'login', '--help'])
|
|
1276
|
+
expect(output).toMatchInlineSnapshot(`
|
|
1277
|
+
"app auth login — Log in to the service
|
|
1278
|
+
|
|
1279
|
+
Usage: app auth login [options]
|
|
1280
|
+
|
|
1281
|
+
Options:
|
|
1282
|
+
--hostname, -h <string> API hostname (default: api.example.com)
|
|
1283
|
+
--web, -w <boolean> Open browser (default: false)
|
|
1284
|
+
--scopes <array> OAuth scopes
|
|
1285
|
+
|
|
1286
|
+
Environment Variables:
|
|
1287
|
+
AUTH_TOKEN Pre-existing auth token
|
|
1288
|
+
AUTH_HOST Auth server hostname (default: api.example.com)
|
|
1289
|
+
|
|
1290
|
+
Global Options:
|
|
1291
|
+
--format <toon|json|yaml|md|jsonl> Output format
|
|
1292
|
+
--help Show help
|
|
1293
|
+
--llms Print LLM-readable manifest
|
|
1294
|
+
--verbose Show full output envelope
|
|
1295
|
+
"
|
|
1296
|
+
`)
|
|
1297
|
+
})
|
|
1298
|
+
|
|
1299
|
+
test('--llms json includes schema.env', async () => {
|
|
1300
|
+
const { output } = await serve(createApp(), ['auth', '--llms', '--format', 'json'])
|
|
1301
|
+
const login = json(output).commands.find((c: any) => c.name === 'auth login')
|
|
1302
|
+
expect(login.schema.env.properties).toMatchInlineSnapshot(`
|
|
1303
|
+
{
|
|
1304
|
+
"AUTH_HOST": {
|
|
1305
|
+
"default": "api.example.com",
|
|
1306
|
+
"description": "Auth server hostname",
|
|
1307
|
+
"type": "string",
|
|
1308
|
+
},
|
|
1309
|
+
"AUTH_TOKEN": {
|
|
1310
|
+
"description": "Pre-existing auth token",
|
|
1311
|
+
"type": "string",
|
|
1312
|
+
},
|
|
1313
|
+
}
|
|
1314
|
+
`)
|
|
1315
|
+
})
|
|
1316
|
+
|
|
1317
|
+
test('--llms markdown includes env vars table', async () => {
|
|
1318
|
+
const { output } = await serve(createApp(), ['auth', '--llms'])
|
|
1319
|
+
expect(output).toContain('Environment Variables')
|
|
1320
|
+
expect(output).toContain('`AUTH_TOKEN`')
|
|
1321
|
+
expect(output).toContain('`AUTH_HOST`')
|
|
1322
|
+
})
|
|
1323
|
+
})
|
|
1324
|
+
|
|
1325
|
+
describe('skills staleness', () => {
|
|
1326
|
+
let stderrSpy: ReturnType<typeof vi.spyOn>
|
|
1327
|
+
|
|
1328
|
+
beforeEach(() => {
|
|
1329
|
+
stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
|
1330
|
+
__mockSkillsHash = undefined
|
|
1331
|
+
})
|
|
1332
|
+
|
|
1333
|
+
afterEach(() => {
|
|
1334
|
+
stderrSpy.mockRestore()
|
|
1335
|
+
})
|
|
1336
|
+
|
|
1337
|
+
test('warns when running a command with stale skills', async () => {
|
|
1338
|
+
__mockSkillsHash = '0000000000000000'
|
|
1339
|
+
const { output } = await serve(createApp(), ['ping'])
|
|
1340
|
+
expect(output).toContain('pong: true')
|
|
1341
|
+
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Skills are out of date.'))
|
|
1342
|
+
})
|
|
1343
|
+
|
|
1344
|
+
test('no warning when skills hash matches', async () => {
|
|
1345
|
+
// Use a simple CLI where we can compute the exact hash
|
|
1346
|
+
const cli = Cli.create('tool', { version: '1.0.0' })
|
|
1347
|
+
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
|
|
1348
|
+
__mockSkillsHash = Skill.hash([{ name: 'ping', description: 'Health check' }])
|
|
1349
|
+
|
|
1350
|
+
const { output } = await serve(cli, ['ping'])
|
|
1351
|
+
expect(output).toContain('pong: true')
|
|
1352
|
+
expect(stderrSpy).not.toHaveBeenCalled()
|
|
1353
|
+
})
|
|
1354
|
+
|
|
1355
|
+
test('no warning on first use (no hash stored)', async () => {
|
|
1356
|
+
__mockSkillsHash = undefined
|
|
1357
|
+
const { output } = await serve(createApp(), ['ping'])
|
|
1358
|
+
expect(output).toContain('pong: true')
|
|
1359
|
+
expect(stderrSpy).not.toHaveBeenCalled()
|
|
1360
|
+
})
|
|
1361
|
+
|
|
1362
|
+
test('no warning for --llms', async () => {
|
|
1363
|
+
__mockSkillsHash = '0000000000000000'
|
|
1364
|
+
await serve(createApp(), ['--llms'])
|
|
1365
|
+
expect(stderrSpy).not.toHaveBeenCalled()
|
|
1366
|
+
})
|
|
1367
|
+
|
|
1368
|
+
test('no warning for --mcp', async () => {
|
|
1369
|
+
__mockSkillsHash = '0000000000000000'
|
|
1370
|
+
// --mcp starts a server that reads stdin, so we can't easily test it here.
|
|
1371
|
+
// Instead verify it doesn't reach the staleness check by checking --version
|
|
1372
|
+
await serve(createApp(), ['--version'])
|
|
1373
|
+
expect(stderrSpy).not.toHaveBeenCalled()
|
|
1374
|
+
})
|
|
1375
|
+
})
|
|
1376
|
+
|
|
1377
|
+
async function serve(
|
|
1378
|
+
cli: { serve: Cli.Cli['serve'] },
|
|
1379
|
+
argv: string[],
|
|
1380
|
+
options: Cli.serve.Options = {},
|
|
1381
|
+
) {
|
|
1382
|
+
let output = ''
|
|
1383
|
+
let exitCode: number | undefined
|
|
1384
|
+
await cli.serve(argv, {
|
|
1385
|
+
stdout(s) {
|
|
1386
|
+
output += s
|
|
1387
|
+
},
|
|
1388
|
+
exit(code) {
|
|
1389
|
+
exitCode = code
|
|
1390
|
+
},
|
|
1391
|
+
...options,
|
|
1392
|
+
})
|
|
1393
|
+
return {
|
|
1394
|
+
output: output.replace(/duration: \d+ms/g, 'duration: <stripped>'),
|
|
1395
|
+
exitCode,
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function json(raw: string) {
|
|
1400
|
+
return JSON.parse(raw)
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function createApp() {
|
|
1404
|
+
const auth = Cli.create('auth', { description: 'Authentication commands' })
|
|
1405
|
+
.command('login', {
|
|
1406
|
+
description: 'Log in to the service',
|
|
1407
|
+
env: z.object({
|
|
1408
|
+
AUTH_TOKEN: z.string().optional().describe('Pre-existing auth token'),
|
|
1409
|
+
AUTH_HOST: z.string().default('api.example.com').describe('Auth server hostname'),
|
|
1410
|
+
}),
|
|
1411
|
+
options: z.object({
|
|
1412
|
+
hostname: z.string().default('api.example.com').describe('API hostname'),
|
|
1413
|
+
web: z.boolean().default(false).describe('Open browser'),
|
|
1414
|
+
scopes: z.array(z.string()).default([]).describe('OAuth scopes'),
|
|
1415
|
+
}),
|
|
1416
|
+
alias: { hostname: 'h', web: 'w' },
|
|
1417
|
+
run({ env, options, ok }) {
|
|
1418
|
+
return ok(
|
|
1419
|
+
{ hostname: env.AUTH_HOST, scopes: options.scopes },
|
|
1420
|
+
{
|
|
1421
|
+
cta: {
|
|
1422
|
+
description: 'Verify your session:',
|
|
1423
|
+
commands: ['auth status'],
|
|
1424
|
+
},
|
|
1425
|
+
},
|
|
1426
|
+
)
|
|
1427
|
+
},
|
|
1428
|
+
})
|
|
1429
|
+
.command('logout', {
|
|
1430
|
+
description: 'Log out of the service',
|
|
1431
|
+
run({ ok }) {
|
|
1432
|
+
return ok({ loggedOut: true })
|
|
1433
|
+
},
|
|
1434
|
+
})
|
|
1435
|
+
.command('status', {
|
|
1436
|
+
description: 'Show authentication status',
|
|
1437
|
+
output: z.object({ loggedIn: z.boolean(), hostname: z.string(), user: z.string() }),
|
|
1438
|
+
run({ error }) {
|
|
1439
|
+
return error({
|
|
1440
|
+
code: 'NOT_AUTHENTICATED',
|
|
1441
|
+
message: 'Not logged in',
|
|
1442
|
+
retryable: false,
|
|
1443
|
+
cta: { commands: ['auth login'] },
|
|
1444
|
+
})
|
|
1445
|
+
},
|
|
1446
|
+
})
|
|
1447
|
+
|
|
1448
|
+
const project = Cli.create('project', { description: 'Manage projects' })
|
|
1449
|
+
.command('list', {
|
|
1450
|
+
description: 'List projects',
|
|
1451
|
+
options: z.object({
|
|
1452
|
+
limit: z.number().default(20).describe('Max results'),
|
|
1453
|
+
sort: z.enum(['name', 'created', 'updated']).default('name').describe('Sort field'),
|
|
1454
|
+
archived: z.boolean().default(false).describe('Include archived'),
|
|
1455
|
+
}),
|
|
1456
|
+
alias: { limit: 'l', sort: 's' },
|
|
1457
|
+
|
|
1458
|
+
output: z.object({
|
|
1459
|
+
items: z.array(
|
|
1460
|
+
z.object({
|
|
1461
|
+
id: z.string(),
|
|
1462
|
+
name: z.string(),
|
|
1463
|
+
archived: z.boolean(),
|
|
1464
|
+
}),
|
|
1465
|
+
),
|
|
1466
|
+
total: z.number(),
|
|
1467
|
+
}),
|
|
1468
|
+
run({ options, ok }) {
|
|
1469
|
+
const items = [
|
|
1470
|
+
{ id: 'p1', name: 'Alpha', archived: false },
|
|
1471
|
+
{ id: 'p2', name: 'Beta', archived: true },
|
|
1472
|
+
].filter((p) => options.archived || !p.archived)
|
|
1473
|
+
return ok(
|
|
1474
|
+
{ items, total: items.length },
|
|
1475
|
+
{
|
|
1476
|
+
cta: {
|
|
1477
|
+
commands: items.map((p) => ({
|
|
1478
|
+
command: `project get ${p.id}`,
|
|
1479
|
+
description: `View "${p.name}"`,
|
|
1480
|
+
})),
|
|
1481
|
+
},
|
|
1482
|
+
},
|
|
1483
|
+
)
|
|
1484
|
+
},
|
|
1485
|
+
})
|
|
1486
|
+
.command('get', {
|
|
1487
|
+
description: 'Get a project by ID',
|
|
1488
|
+
args: z.object({ id: z.string().describe('Project ID') }),
|
|
1489
|
+
output: z.object({
|
|
1490
|
+
id: z.string(),
|
|
1491
|
+
name: z.string(),
|
|
1492
|
+
description: z.string(),
|
|
1493
|
+
members: z.array(z.object({ userId: z.string(), role: z.string() })),
|
|
1494
|
+
}),
|
|
1495
|
+
run({ args, ok }) {
|
|
1496
|
+
return ok({
|
|
1497
|
+
id: args.id,
|
|
1498
|
+
name: 'Alpha',
|
|
1499
|
+
description: 'Main project',
|
|
1500
|
+
members: [{ userId: 'u1', role: 'admin' }],
|
|
1501
|
+
})
|
|
1502
|
+
},
|
|
1503
|
+
})
|
|
1504
|
+
.command('create', {
|
|
1505
|
+
description: 'Create a new project',
|
|
1506
|
+
args: z.object({ name: z.string().describe('Project name') }),
|
|
1507
|
+
options: z.object({
|
|
1508
|
+
description: z.string().default('').describe('Project description'),
|
|
1509
|
+
private: z.boolean().default(false).describe('Private project'),
|
|
1510
|
+
}),
|
|
1511
|
+
alias: { description: 'd' },
|
|
1512
|
+
|
|
1513
|
+
output: z.object({ id: z.string(), url: z.string() }),
|
|
1514
|
+
run({ args, ok }) {
|
|
1515
|
+
return ok(
|
|
1516
|
+
{ id: 'p-new', url: 'https://example.com/projects/p-new' },
|
|
1517
|
+
{
|
|
1518
|
+
cta: {
|
|
1519
|
+
commands: [
|
|
1520
|
+
{ command: 'project get p-new', description: `View "${args.name}"` },
|
|
1521
|
+
'project list',
|
|
1522
|
+
],
|
|
1523
|
+
},
|
|
1524
|
+
},
|
|
1525
|
+
)
|
|
1526
|
+
},
|
|
1527
|
+
})
|
|
1528
|
+
.command('delete', {
|
|
1529
|
+
description: 'Delete a project',
|
|
1530
|
+
args: z.object({ id: z.string().describe('Project ID') }),
|
|
1531
|
+
options: z.object({
|
|
1532
|
+
force: z.boolean().default(false).describe('Skip confirmation'),
|
|
1533
|
+
}),
|
|
1534
|
+
alias: { force: 'f' },
|
|
1535
|
+
|
|
1536
|
+
run({ args, options }) {
|
|
1537
|
+
if (!options.force)
|
|
1538
|
+
throw new Errors.IncurError({
|
|
1539
|
+
code: 'CONFIRMATION_REQUIRED',
|
|
1540
|
+
message: `Use --force to delete project ${args.id}`,
|
|
1541
|
+
retryable: true,
|
|
1542
|
+
})
|
|
1543
|
+
return { deleted: true, id: args.id }
|
|
1544
|
+
},
|
|
1545
|
+
})
|
|
1546
|
+
|
|
1547
|
+
const deploy = Cli.create('deploy', { description: 'Deployment commands' })
|
|
1548
|
+
.command('create', {
|
|
1549
|
+
description: 'Create a deployment',
|
|
1550
|
+
args: z.object({ env: z.string().describe('Target environment') }),
|
|
1551
|
+
options: z.object({
|
|
1552
|
+
branch: z.string().default('main').describe('Branch to deploy'),
|
|
1553
|
+
dryRun: z.boolean().default(false).describe('Dry run mode'),
|
|
1554
|
+
}),
|
|
1555
|
+
alias: { branch: 'b' },
|
|
1556
|
+
|
|
1557
|
+
output: z.object({ deployId: z.string(), url: z.string(), status: z.string() }),
|
|
1558
|
+
examples: [
|
|
1559
|
+
{ description: 'Deploy staging from main', args: { env: 'staging' } },
|
|
1560
|
+
{
|
|
1561
|
+
description: 'Dry run a production deploy',
|
|
1562
|
+
args: { env: 'production' },
|
|
1563
|
+
options: { branch: 'release', dryRun: true },
|
|
1564
|
+
},
|
|
1565
|
+
],
|
|
1566
|
+
run({ args, options, ok }) {
|
|
1567
|
+
return ok({
|
|
1568
|
+
deployId: 'd-123',
|
|
1569
|
+
url: `https://${args.env}.example.com`,
|
|
1570
|
+
status: options.dryRun ? 'dry-run' : 'pending',
|
|
1571
|
+
})
|
|
1572
|
+
},
|
|
1573
|
+
})
|
|
1574
|
+
.command('status', {
|
|
1575
|
+
description: 'Check deployment status',
|
|
1576
|
+
args: z.object({ deployId: z.string().describe('Deployment ID') }),
|
|
1577
|
+
|
|
1578
|
+
output: z.object({ deployId: z.string(), status: z.string(), progress: z.number() }),
|
|
1579
|
+
run({ args }) {
|
|
1580
|
+
return { deployId: args.deployId, status: 'running', progress: 75 }
|
|
1581
|
+
},
|
|
1582
|
+
})
|
|
1583
|
+
.command('rollback', {
|
|
1584
|
+
description: 'Rollback a deployment',
|
|
1585
|
+
args: z.object({ deployId: z.string().describe('Deployment ID') }),
|
|
1586
|
+
|
|
1587
|
+
run({ args }) {
|
|
1588
|
+
return { rolledBack: true, deployId: args.deployId }
|
|
1589
|
+
},
|
|
1590
|
+
})
|
|
1591
|
+
|
|
1592
|
+
project.command(deploy)
|
|
1593
|
+
|
|
1594
|
+
const config = Cli.create('config', {
|
|
1595
|
+
description: 'Show current configuration',
|
|
1596
|
+
args: z.object({ key: z.string().optional().describe('Config key to show') }),
|
|
1597
|
+
run({ args }) {
|
|
1598
|
+
if (args.key) return { key: args.key, value: 'some-value' }
|
|
1599
|
+
return { apiUrl: 'https://api.example.com', timeout: 30, debug: false }
|
|
1600
|
+
},
|
|
1601
|
+
})
|
|
1602
|
+
|
|
1603
|
+
const cli = Cli.create('app', {
|
|
1604
|
+
version: '3.5.0',
|
|
1605
|
+
description: 'A comprehensive CLI application for testing.',
|
|
1606
|
+
})
|
|
1607
|
+
|
|
1608
|
+
cli.command('ping', {
|
|
1609
|
+
description: 'Health check',
|
|
1610
|
+
run() {
|
|
1611
|
+
return { pong: true }
|
|
1612
|
+
},
|
|
1613
|
+
})
|
|
1614
|
+
|
|
1615
|
+
cli.command('echo', {
|
|
1616
|
+
description: 'Echo back arguments',
|
|
1617
|
+
args: z.object({
|
|
1618
|
+
message: z.string().describe('Message to echo'),
|
|
1619
|
+
repeat: z.number().optional().describe('Times to repeat'),
|
|
1620
|
+
}),
|
|
1621
|
+
options: z.object({
|
|
1622
|
+
upper: z.boolean().default(false).describe('Uppercase output'),
|
|
1623
|
+
prefix: z.string().default('').describe('Prefix string'),
|
|
1624
|
+
}),
|
|
1625
|
+
alias: { upper: 'u', prefix: 'p' },
|
|
1626
|
+
run({ args, options }) {
|
|
1627
|
+
const count = args.repeat ?? 1
|
|
1628
|
+
let msg = options.prefix ? `${options.prefix} ${args.message}` : args.message
|
|
1629
|
+
if (options.upper) msg = msg.toUpperCase()
|
|
1630
|
+
return { result: Array(count).fill(msg) }
|
|
1631
|
+
},
|
|
1632
|
+
})
|
|
1633
|
+
|
|
1634
|
+
cli.command('slow', {
|
|
1635
|
+
description: 'Async command',
|
|
1636
|
+
async run() {
|
|
1637
|
+
await new Promise((r) => setTimeout(r, 5))
|
|
1638
|
+
return { done: true }
|
|
1639
|
+
},
|
|
1640
|
+
})
|
|
1641
|
+
|
|
1642
|
+
cli.command('explode', {
|
|
1643
|
+
description: 'Always fails',
|
|
1644
|
+
run() {
|
|
1645
|
+
throw new Error('kaboom')
|
|
1646
|
+
},
|
|
1647
|
+
})
|
|
1648
|
+
|
|
1649
|
+
cli.command('explode-clac', {
|
|
1650
|
+
description: 'Fails with IncurError',
|
|
1651
|
+
run() {
|
|
1652
|
+
throw new Errors.IncurError({
|
|
1653
|
+
code: 'QUOTA_EXCEEDED',
|
|
1654
|
+
message: 'Rate limit exceeded',
|
|
1655
|
+
retryable: true,
|
|
1656
|
+
hint: 'Wait 60 seconds',
|
|
1657
|
+
})
|
|
1658
|
+
},
|
|
1659
|
+
})
|
|
1660
|
+
|
|
1661
|
+
cli.command('validate-fail', {
|
|
1662
|
+
description: 'Fails validation',
|
|
1663
|
+
args: z.object({
|
|
1664
|
+
email: z.string().email().describe('Email address'),
|
|
1665
|
+
age: z.number().min(0).max(150).describe('Age'),
|
|
1666
|
+
}),
|
|
1667
|
+
run({ args }) {
|
|
1668
|
+
return args
|
|
1669
|
+
},
|
|
1670
|
+
})
|
|
1671
|
+
|
|
1672
|
+
cli.command('stream', {
|
|
1673
|
+
description: 'Stream chunks',
|
|
1674
|
+
async *run() {
|
|
1675
|
+
yield { content: 'hello' }
|
|
1676
|
+
yield { content: 'world' }
|
|
1677
|
+
},
|
|
1678
|
+
})
|
|
1679
|
+
|
|
1680
|
+
cli.command('stream-ok', {
|
|
1681
|
+
description: 'Stream with ok() return',
|
|
1682
|
+
async *run({ ok }) {
|
|
1683
|
+
yield { n: 1 }
|
|
1684
|
+
yield { n: 2 }
|
|
1685
|
+
return ok(undefined as any, { cta: { commands: ['ping'] } })
|
|
1686
|
+
},
|
|
1687
|
+
})
|
|
1688
|
+
|
|
1689
|
+
cli.command('stream-error', {
|
|
1690
|
+
description: 'Stream with mid-stream error',
|
|
1691
|
+
async *run({ error }) {
|
|
1692
|
+
yield { n: 1 }
|
|
1693
|
+
return error({ code: 'STREAM_FAIL', message: 'broke mid-stream' })
|
|
1694
|
+
},
|
|
1695
|
+
})
|
|
1696
|
+
|
|
1697
|
+
cli.command('stream-throw', {
|
|
1698
|
+
description: 'Stream that throws',
|
|
1699
|
+
async *run() {
|
|
1700
|
+
yield { n: 1 }
|
|
1701
|
+
throw new Error('stream kaboom')
|
|
1702
|
+
},
|
|
1703
|
+
})
|
|
1704
|
+
|
|
1705
|
+
cli.command(auth)
|
|
1706
|
+
cli.command(project)
|
|
1707
|
+
cli.command(config)
|
|
1708
|
+
|
|
1709
|
+
return cli
|
|
1710
|
+
}
|