incur 0.3.4 → 0.3.6
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/README.md +62 -1
- package/dist/Cli.d.ts +17 -7
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +435 -365
- package/dist/Cli.js.map +1 -1
- package/dist/Completions.d.ts +1 -2
- package/dist/Completions.d.ts.map +1 -1
- package/dist/Completions.js.map +1 -1
- package/dist/Filter.js +0 -18
- package/dist/Filter.js.map +1 -1
- package/dist/Help.d.ts +6 -0
- package/dist/Help.d.ts.map +1 -1
- package/dist/Help.js +35 -22
- package/dist/Help.js.map +1 -1
- package/dist/Mcp.d.ts +25 -5
- package/dist/Mcp.d.ts.map +1 -1
- package/dist/Mcp.js +61 -69
- package/dist/Mcp.js.map +1 -1
- package/dist/Parser.d.ts +2 -0
- package/dist/Parser.d.ts.map +1 -1
- package/dist/Parser.js +69 -37
- package/dist/Parser.js.map +1 -1
- package/dist/Skill.d.ts.map +1 -1
- package/dist/Skill.js +5 -1
- package/dist/Skill.js.map +1 -1
- package/dist/SyncSkills.d.ts.map +1 -1
- package/dist/SyncSkills.js +10 -1
- package/dist/SyncSkills.js.map +1 -1
- package/dist/bin.d.ts +1 -0
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +17 -2
- package/dist/bin.js.map +1 -1
- package/dist/internal/command.d.ts +118 -0
- package/dist/internal/command.d.ts.map +1 -0
- package/dist/internal/command.js +276 -0
- package/dist/internal/command.js.map +1 -0
- package/dist/internal/configSchema.d.ts +8 -0
- package/dist/internal/configSchema.d.ts.map +1 -0
- package/dist/internal/configSchema.js +57 -0
- package/dist/internal/configSchema.js.map +1 -0
- package/dist/internal/helpers.d.ts +5 -0
- package/dist/internal/helpers.d.ts.map +1 -0
- package/dist/internal/helpers.js +9 -0
- package/dist/internal/helpers.js.map +1 -0
- package/examples/npm/.npmrc.json +21 -0
- package/examples/npm/config.schema.json +137 -0
- package/package.json +1 -1
- package/src/Cli.test-d.ts +39 -0
- package/src/Cli.test.ts +704 -6
- package/src/Cli.ts +551 -448
- package/src/Completions.test.ts +35 -9
- package/src/Completions.ts +1 -2
- package/src/Filter.ts +0 -17
- package/src/Help.test.ts +77 -0
- package/src/Help.ts +39 -21
- package/src/Mcp.test.ts +143 -0
- package/src/Mcp.ts +92 -84
- package/src/Parser.test-d.ts +22 -0
- package/src/Parser.test.ts +89 -0
- package/src/Parser.ts +86 -35
- package/src/Skill.ts +5 -1
- package/src/SyncSkills.ts +11 -1
- package/src/bin.ts +21 -2
- package/src/e2e.test.ts +30 -17
- package/src/internal/command.ts +428 -0
- package/src/internal/configSchema.test.ts +193 -0
- package/src/internal/configSchema.ts +66 -0
- package/src/internal/helpers.ts +9 -0
package/src/Cli.test.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { Cli, Errors, z } from 'incur'
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { homedir, tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
2
5
|
|
|
3
6
|
const originalIsTTY = process.stdout.isTTY
|
|
4
7
|
beforeAll(() => {
|
|
@@ -37,6 +40,42 @@ async function serve(
|
|
|
37
40
|
}
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
function createConfigCli(flag?: string) {
|
|
44
|
+
const project = Cli.create('project').command('list', {
|
|
45
|
+
options: z.object({
|
|
46
|
+
label: z.array(z.string()).default([]),
|
|
47
|
+
limit: z.number().default(10),
|
|
48
|
+
}),
|
|
49
|
+
run(c) {
|
|
50
|
+
return c.options
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const cli = Cli.create('test', {
|
|
55
|
+
config: flag !== undefined ? { flag } : {},
|
|
56
|
+
options: z.object({
|
|
57
|
+
rootValue: z.string().default('root-default'),
|
|
58
|
+
}),
|
|
59
|
+
run(c) {
|
|
60
|
+
return c.options
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
cli.command('echo', {
|
|
65
|
+
options: z.object({
|
|
66
|
+
prefix: z.string().default(''),
|
|
67
|
+
upper: z.boolean().default(false),
|
|
68
|
+
}),
|
|
69
|
+
run(c) {
|
|
70
|
+
return c.options
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
cli.command(project)
|
|
75
|
+
|
|
76
|
+
return cli
|
|
77
|
+
}
|
|
78
|
+
|
|
40
79
|
describe('create', () => {
|
|
41
80
|
test('returns cli instance with name', () => {
|
|
42
81
|
const cli = Cli.create('test')
|
|
@@ -62,6 +101,518 @@ describe('command', () => {
|
|
|
62
101
|
})
|
|
63
102
|
})
|
|
64
103
|
|
|
104
|
+
describe('config defaults', () => {
|
|
105
|
+
let cwd: string
|
|
106
|
+
let dir: string
|
|
107
|
+
|
|
108
|
+
beforeEach(async () => {
|
|
109
|
+
cwd = process.cwd()
|
|
110
|
+
dir = await mkdtemp(join(tmpdir(), 'incur-config-'))
|
|
111
|
+
process.chdir(dir)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
afterEach(async () => {
|
|
115
|
+
process.chdir(cwd)
|
|
116
|
+
await rm(dir, { force: true, recursive: true })
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('auto-loads <cli>.json for leaf commands', async () => {
|
|
120
|
+
await writeFile(
|
|
121
|
+
join(dir, 'test.json'),
|
|
122
|
+
JSON.stringify({
|
|
123
|
+
commands: {
|
|
124
|
+
echo: {
|
|
125
|
+
options: {
|
|
126
|
+
prefix: 'cfg',
|
|
127
|
+
upper: true,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const { output } = await serve(createConfigCli(), ['echo', '--json'])
|
|
135
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'cfg', upper: true })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('ignores a missing auto config file', async () => {
|
|
139
|
+
const { output } = await serve(createConfigCli(), ['echo', '--json'])
|
|
140
|
+
expect(JSON.parse(output)).toEqual({ prefix: '', upper: false })
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('root options coexist with subcommand keys', async () => {
|
|
144
|
+
await writeFile(
|
|
145
|
+
join(dir, 'test.json'),
|
|
146
|
+
JSON.stringify({
|
|
147
|
+
options: { rootValue: 'cfg-root' },
|
|
148
|
+
commands: {
|
|
149
|
+
echo: { options: { prefix: 'cfg' } },
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
const rootResult = await serve(createConfigCli(), ['--json'])
|
|
155
|
+
expect(JSON.parse(rootResult.output)).toEqual({ rootValue: 'cfg-root' })
|
|
156
|
+
|
|
157
|
+
const echoResult = await serve(createConfigCli(), ['echo', '--json'])
|
|
158
|
+
expect(JSON.parse(echoResult.output)).toEqual({ prefix: 'cfg', upper: false })
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('walks nested command sections in config tree', async () => {
|
|
162
|
+
await writeFile(
|
|
163
|
+
join(dir, 'test.json'),
|
|
164
|
+
JSON.stringify({
|
|
165
|
+
commands: {
|
|
166
|
+
project: {
|
|
167
|
+
commands: {
|
|
168
|
+
list: {
|
|
169
|
+
options: {
|
|
170
|
+
label: ['cfg'],
|
|
171
|
+
limit: 25,
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const { output } = await serve(createConfigCli(), ['project', 'list', '--json'])
|
|
181
|
+
expect(JSON.parse(output)).toEqual({ label: ['cfg'], limit: 25 })
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('uses an explicit --config path instead of the auto file', async () => {
|
|
185
|
+
await writeFile(
|
|
186
|
+
join(dir, 'test.json'),
|
|
187
|
+
JSON.stringify({
|
|
188
|
+
commands: { echo: { options: { prefix: 'auto' } } },
|
|
189
|
+
}),
|
|
190
|
+
)
|
|
191
|
+
await writeFile(
|
|
192
|
+
join(dir, 'custom.json'),
|
|
193
|
+
JSON.stringify({
|
|
194
|
+
commands: { echo: { options: { prefix: 'custom', upper: true } } },
|
|
195
|
+
}),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
const { output } = await serve(createConfigCli('config'), [
|
|
199
|
+
'echo',
|
|
200
|
+
'--config',
|
|
201
|
+
'custom.json',
|
|
202
|
+
'--json',
|
|
203
|
+
])
|
|
204
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'custom', upper: true })
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('--no-config disables earlier config flags, and a later --config wins again', async () => {
|
|
208
|
+
await writeFile(
|
|
209
|
+
join(dir, 'one.json'),
|
|
210
|
+
JSON.stringify({
|
|
211
|
+
commands: { echo: { options: { prefix: 'one' } } },
|
|
212
|
+
}),
|
|
213
|
+
)
|
|
214
|
+
await writeFile(
|
|
215
|
+
join(dir, 'two.json'),
|
|
216
|
+
JSON.stringify({
|
|
217
|
+
commands: { echo: { options: { prefix: 'two' } } },
|
|
218
|
+
}),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
const first = await serve(createConfigCli('config'), [
|
|
222
|
+
'echo',
|
|
223
|
+
'--config',
|
|
224
|
+
'one.json',
|
|
225
|
+
'--no-config',
|
|
226
|
+
'--json',
|
|
227
|
+
])
|
|
228
|
+
expect(JSON.parse(first.output)).toEqual({ prefix: '', upper: false })
|
|
229
|
+
|
|
230
|
+
const second = await serve(createConfigCli('config'), [
|
|
231
|
+
'echo',
|
|
232
|
+
'--config',
|
|
233
|
+
'one.json',
|
|
234
|
+
'--no-config',
|
|
235
|
+
'--config=two.json',
|
|
236
|
+
'--json',
|
|
237
|
+
])
|
|
238
|
+
expect(JSON.parse(second.output)).toEqual({ prefix: 'two', upper: false })
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('fails when an explicit config file is missing', async () => {
|
|
242
|
+
const { exitCode, output } = await serve(createConfigCli('config'), [
|
|
243
|
+
'echo',
|
|
244
|
+
'--config',
|
|
245
|
+
'missing.json',
|
|
246
|
+
])
|
|
247
|
+
expect(exitCode).toBe(1)
|
|
248
|
+
expect(output).toContain('Config file not found')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('fails on invalid JSON config files', async () => {
|
|
252
|
+
await writeFile(join(dir, 'test.json'), '{ invalid')
|
|
253
|
+
|
|
254
|
+
const { exitCode, output } = await serve(createConfigCli(), ['echo'])
|
|
255
|
+
expect(exitCode).toBe(1)
|
|
256
|
+
expect(output).toContain('Invalid JSON config file')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('fails when the config file top level is not an object', async () => {
|
|
260
|
+
await writeFile(join(dir, 'test.json'), JSON.stringify(['bad']))
|
|
261
|
+
|
|
262
|
+
const { exitCode, output } = await serve(createConfigCli(), ['echo'])
|
|
263
|
+
expect(exitCode).toBe(1)
|
|
264
|
+
expect(output).toContain('expected a top-level object')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('fails when the selected config section is not an object', async () => {
|
|
268
|
+
await writeFile(join(dir, 'test.json'), JSON.stringify({ commands: { echo: true } }))
|
|
269
|
+
|
|
270
|
+
const { exitCode, output } = await serve(createConfigCli(), ['echo'])
|
|
271
|
+
expect(exitCode).toBe(1)
|
|
272
|
+
expect(output).toContain("Invalid config section for 'echo'")
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('fails validation when config option values are invalid', async () => {
|
|
276
|
+
await writeFile(
|
|
277
|
+
join(dir, 'test.json'),
|
|
278
|
+
JSON.stringify({
|
|
279
|
+
commands: { echo: { options: { upper: 'nope' } } },
|
|
280
|
+
}),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
const { exitCode, output } = await serve(createConfigCli(), ['echo'])
|
|
284
|
+
expect(exitCode).toBe(1)
|
|
285
|
+
expect(output).toContain('VALIDATION_ERROR')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('argv overrides invalid config values at the CLI layer', async () => {
|
|
289
|
+
await writeFile(
|
|
290
|
+
join(dir, 'test.json'),
|
|
291
|
+
JSON.stringify({
|
|
292
|
+
commands: { echo: { options: { prefix: 123 } } },
|
|
293
|
+
}),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
const { output } = await serve(createConfigCli(), ['echo', '--prefix', 'cli', '--json'])
|
|
297
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'cli', upper: false })
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('built-in commands ignore config loading', async () => {
|
|
301
|
+
await writeFile(join(dir, 'test.json'), '{ invalid')
|
|
302
|
+
|
|
303
|
+
const { output, exitCode } = await serve(createConfigCli(), ['--help'])
|
|
304
|
+
expect(exitCode).toBeUndefined()
|
|
305
|
+
expect(output).toContain('Global Options:')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test('config without flag does not reserve --config', async () => {
|
|
309
|
+
const cli = Cli.create('test', { config: {} })
|
|
310
|
+
cli.command('echo', {
|
|
311
|
+
options: z.object({ config: z.string().default('') }),
|
|
312
|
+
run(c) {
|
|
313
|
+
return c.options
|
|
314
|
+
},
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
const { output } = await serve(cli, ['echo', '--config', 'my-value', '--json'])
|
|
318
|
+
expect(JSON.parse(output)).toEqual({ config: 'my-value' })
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
test('--help shows config flags only when flag name is set', async () => {
|
|
322
|
+
const { output } = await serve(createConfigCli('config'), ['--help'])
|
|
323
|
+
expect(output).toContain('--config <path>')
|
|
324
|
+
expect(output).toContain('--no-config')
|
|
325
|
+
|
|
326
|
+
const { output: noFlagOutput } = await serve(createConfigCli(), ['--help'])
|
|
327
|
+
expect(noFlagOutput).not.toContain('--config')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
test('custom flag name is used for config path override', async () => {
|
|
331
|
+
await writeFile(
|
|
332
|
+
join(dir, 'custom.json'),
|
|
333
|
+
JSON.stringify({
|
|
334
|
+
commands: { echo: { options: { prefix: 'custom' } } },
|
|
335
|
+
}),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
const { output } = await serve(createConfigCli('settings'), [
|
|
339
|
+
'echo',
|
|
340
|
+
'--settings',
|
|
341
|
+
'custom.json',
|
|
342
|
+
'--json',
|
|
343
|
+
])
|
|
344
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'custom', upper: false })
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('searches files list in order, first match wins', async () => {
|
|
348
|
+
await writeFile(
|
|
349
|
+
join(dir, '.testrc.json'),
|
|
350
|
+
JSON.stringify({ commands: { echo: { options: { prefix: 'rc' } } } }),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
const cli = Cli.create('test', {
|
|
354
|
+
config: { files: ['test.json', '.testrc.json'] },
|
|
355
|
+
})
|
|
356
|
+
cli.command('echo', {
|
|
357
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
358
|
+
run: (c) => c.options,
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
const { output } = await serve(cli, ['echo', '--json'])
|
|
362
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'rc' })
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
test('files: [] disables auto-discovery', async () => {
|
|
366
|
+
await writeFile(
|
|
367
|
+
join(dir, 'test.json'),
|
|
368
|
+
JSON.stringify({ commands: { echo: { options: { prefix: 'should-not-load' } } } }),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
const cli = Cli.create('test', {
|
|
372
|
+
config: { files: [] },
|
|
373
|
+
})
|
|
374
|
+
cli.command('echo', {
|
|
375
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
376
|
+
run: (c) => c.options,
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
const { output } = await serve(cli, ['echo', '--json'])
|
|
380
|
+
expect(JSON.parse(output)).toEqual({ prefix: '' })
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
test('files supports ~ for home directory', async () => {
|
|
384
|
+
const configDir = join(homedir(), '.config', 'test-incur-files-tilde')
|
|
385
|
+
await mkdir(configDir, { recursive: true })
|
|
386
|
+
try {
|
|
387
|
+
await writeFile(
|
|
388
|
+
join(configDir, 'config.json'),
|
|
389
|
+
JSON.stringify({ commands: { echo: { options: { prefix: 'home' } } } }),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
const cli = Cli.create('test', {
|
|
393
|
+
config: { files: ['test.json', '~/.config/test-incur-files-tilde/config.json'] },
|
|
394
|
+
})
|
|
395
|
+
cli.command('echo', {
|
|
396
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
397
|
+
run: (c) => c.options,
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
const { output } = await serve(cli, ['echo', '--json'])
|
|
401
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'home' })
|
|
402
|
+
} finally {
|
|
403
|
+
await rm(configDir, { recursive: true, force: true })
|
|
404
|
+
}
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
test('explicit --flag overrides files list', async () => {
|
|
408
|
+
await writeFile(
|
|
409
|
+
join(dir, '.testrc.json'),
|
|
410
|
+
JSON.stringify({ commands: { echo: { options: { prefix: 'rc' } } } }),
|
|
411
|
+
)
|
|
412
|
+
await writeFile(
|
|
413
|
+
join(dir, 'override.json'),
|
|
414
|
+
JSON.stringify({ commands: { echo: { options: { prefix: 'override' } } } }),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
const cli = Cli.create('test', {
|
|
418
|
+
config: { flag: 'config', files: ['.testrc.json'] },
|
|
419
|
+
})
|
|
420
|
+
cli.command('echo', {
|
|
421
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
422
|
+
run: (c) => c.options,
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
const { output } = await serve(cli, ['echo', '--config', 'override.json', '--json'])
|
|
426
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'override' })
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
test('custom loader replaces JSON parsing', async () => {
|
|
430
|
+
await writeFile(join(dir, 'test.ini'), 'prefix=ini-value')
|
|
431
|
+
|
|
432
|
+
const cli = Cli.create('test', {
|
|
433
|
+
config: {
|
|
434
|
+
files: ['test.ini'],
|
|
435
|
+
async loader(path) {
|
|
436
|
+
if (!path) return undefined
|
|
437
|
+
const raw = await readFile(path, 'utf8')
|
|
438
|
+
const obj: Record<string, unknown> = {}
|
|
439
|
+
for (const line of raw.split('\n')) {
|
|
440
|
+
const eq = line.indexOf('=')
|
|
441
|
+
if (eq !== -1) obj[line.slice(0, eq).trim()] = line.slice(eq + 1).trim()
|
|
442
|
+
}
|
|
443
|
+
return { commands: { echo: { options: obj } } }
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
})
|
|
447
|
+
cli.command('echo', {
|
|
448
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
449
|
+
run: (c) => c.options,
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
const { output } = await serve(cli, ['echo', '--json'])
|
|
453
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'ini-value' })
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
test('loader with files: [] receives undefined path', async () => {
|
|
457
|
+
const cli = Cli.create('test', {
|
|
458
|
+
config: {
|
|
459
|
+
files: [],
|
|
460
|
+
loader: async (path) => {
|
|
461
|
+
expect(path).toBeUndefined()
|
|
462
|
+
return { commands: { echo: { options: { prefix: 'from-loader' } } } }
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
})
|
|
466
|
+
cli.command('echo', {
|
|
467
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
468
|
+
run: (c) => c.options,
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
const { output } = await serve(cli, ['echo', '--json'])
|
|
472
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'from-loader' })
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
test('loader returning undefined applies no defaults', async () => {
|
|
476
|
+
const cli = Cli.create('test', {
|
|
477
|
+
config: { files: [], loader: async () => undefined },
|
|
478
|
+
})
|
|
479
|
+
cli.command('echo', {
|
|
480
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
481
|
+
run: (c) => c.options,
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
const { output } = await serve(cli, ['echo', '--json'])
|
|
485
|
+
expect(JSON.parse(output)).toEqual({ prefix: '' })
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
test('--no-flag skips loader entirely', async () => {
|
|
489
|
+
let loaderCalled = false
|
|
490
|
+
const cli = Cli.create('test', {
|
|
491
|
+
config: {
|
|
492
|
+
flag: 'config',
|
|
493
|
+
files: [],
|
|
494
|
+
loader: async () => {
|
|
495
|
+
loaderCalled = true
|
|
496
|
+
return { commands: { echo: { options: { prefix: 'should-not-load' } } } }
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
})
|
|
500
|
+
cli.command('echo', {
|
|
501
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
502
|
+
run: (c) => c.options,
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
const { output } = await serve(cli, ['echo', '--no-config', '--json'])
|
|
506
|
+
expect(JSON.parse(output)).toEqual({ prefix: '' })
|
|
507
|
+
expect(loaderCalled).toBe(false)
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
test('loader errors propagate', async () => {
|
|
511
|
+
const cli = Cli.create('test', {
|
|
512
|
+
config: {
|
|
513
|
+
files: [],
|
|
514
|
+
loader: async () => {
|
|
515
|
+
throw new Error('Remote config server unreachable')
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
})
|
|
519
|
+
cli.command('echo', {
|
|
520
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
521
|
+
run: (c) => c.options,
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
const { exitCode, output } = await serve(cli, ['echo'])
|
|
525
|
+
expect(exitCode).toBe(1)
|
|
526
|
+
expect(output).toContain('Remote config server unreachable')
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
test('--no-flag disables auto-discovery without prior --flag', async () => {
|
|
530
|
+
await writeFile(
|
|
531
|
+
join(dir, 'test.json'),
|
|
532
|
+
JSON.stringify({ commands: { echo: { options: { prefix: 'auto-loaded' } } } }),
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
const { output } = await serve(createConfigCli('config'), ['echo', '--no-config', '--json'])
|
|
536
|
+
expect(JSON.parse(output)).toEqual({ prefix: '', upper: false })
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
test('--config without a value produces an error', async () => {
|
|
540
|
+
const { exitCode, output } = await serve(createConfigCli('config'), ['echo', '--config'])
|
|
541
|
+
expect(exitCode).toBe(1)
|
|
542
|
+
expect(output).toContain('Missing value for flag')
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
test('--config= (empty value) produces an error', async () => {
|
|
546
|
+
const { exitCode, output } = await serve(createConfigCli('config'), ['echo', '--config='])
|
|
547
|
+
expect(exitCode).toBe(1)
|
|
548
|
+
expect(output).toContain('Missing value for flag')
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
test('--no-settings works with custom flag name', async () => {
|
|
552
|
+
await writeFile(
|
|
553
|
+
join(dir, 'test.json'),
|
|
554
|
+
JSON.stringify({ commands: { echo: { options: { prefix: 'auto' } } } }),
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
const { output } = await serve(createConfigCli('settings'), ['echo', '--no-settings', '--json'])
|
|
558
|
+
expect(JSON.parse(output)).toEqual({ prefix: '', upper: false })
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
test('camelCase config keys are accepted at cli level', async () => {
|
|
562
|
+
const cli = Cli.create('test', { config: {} })
|
|
563
|
+
cli.command('echo', {
|
|
564
|
+
options: z.object({ saveDev: z.boolean().default(false) }),
|
|
565
|
+
run: (c) => c.options,
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
await writeFile(
|
|
569
|
+
join(dir, 'test.json'),
|
|
570
|
+
JSON.stringify({ commands: { echo: { options: { 'save-dev': true } } } }),
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
const { output } = await serve(cli, ['echo', '--json'])
|
|
574
|
+
expect(JSON.parse(output)).toEqual({ saveDev: true })
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
test('config defaults with only subcommand namespaces yields no option defaults', async () => {
|
|
578
|
+
await writeFile(
|
|
579
|
+
join(dir, 'test.json'),
|
|
580
|
+
JSON.stringify({
|
|
581
|
+
commands: {
|
|
582
|
+
echo: { options: { prefix: 'child' } },
|
|
583
|
+
project: { commands: { list: { options: { limit: 50 } } } },
|
|
584
|
+
},
|
|
585
|
+
}),
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
const rootResult = await serve(createConfigCli(), ['--json'])
|
|
589
|
+
expect(JSON.parse(rootResult.output)).toEqual({ rootValue: 'root-default' })
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
test('explicit --flag path is forwarded to custom loader', async () => {
|
|
593
|
+
await writeFile(join(dir, 'custom.dat'), 'prefix=custom-loader')
|
|
594
|
+
|
|
595
|
+
const cli = Cli.create('test', {
|
|
596
|
+
config: {
|
|
597
|
+
flag: 'config',
|
|
598
|
+
async loader(path) {
|
|
599
|
+
if (!path) return undefined
|
|
600
|
+
const raw = await readFile(path, 'utf8')
|
|
601
|
+
const [, value] = raw.split('=')
|
|
602
|
+
return { commands: { echo: { options: { prefix: value!.trim() } } } }
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
})
|
|
606
|
+
cli.command('echo', {
|
|
607
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
608
|
+
run: (c) => c.options,
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
const { output } = await serve(cli, ['echo', '--config', 'custom.dat', '--json'])
|
|
612
|
+
expect(JSON.parse(output)).toEqual({ prefix: 'custom-loader' })
|
|
613
|
+
})
|
|
614
|
+
})
|
|
615
|
+
|
|
65
616
|
describe('serve', () => {
|
|
66
617
|
test('outputs data only by default', async () => {
|
|
67
618
|
const cli = Cli.create('test')
|
|
@@ -1350,7 +1901,7 @@ describe('help', () => {
|
|
|
1350
1901
|
Commands:
|
|
1351
1902
|
ping Health check
|
|
1352
1903
|
|
|
1353
|
-
|
|
1904
|
+
Integrations:
|
|
1354
1905
|
completions Generate shell completion script
|
|
1355
1906
|
mcp add Register as MCP server
|
|
1356
1907
|
skills add Sync skill files to agents
|
|
@@ -1388,7 +1939,7 @@ describe('help', () => {
|
|
|
1388
1939
|
Commands:
|
|
1389
1940
|
ping Health check
|
|
1390
1941
|
|
|
1391
|
-
|
|
1942
|
+
Integrations:
|
|
1392
1943
|
completions Generate shell completion script
|
|
1393
1944
|
mcp add Register as MCP server
|
|
1394
1945
|
skills add Sync skill files to agents
|
|
@@ -1551,7 +2102,7 @@ describe('help', () => {
|
|
|
1551
2102
|
Commands:
|
|
1552
2103
|
ping Ping
|
|
1553
2104
|
|
|
1554
|
-
|
|
2105
|
+
Integrations:
|
|
1555
2106
|
completions Generate shell completion script
|
|
1556
2107
|
mcp add Register as MCP server
|
|
1557
2108
|
skills add Sync skill files to agents
|
|
@@ -1733,7 +2284,7 @@ describe('env', () => {
|
|
|
1733
2284
|
--verbose Show full output envelope
|
|
1734
2285
|
|
|
1735
2286
|
Environment Variables:
|
|
1736
|
-
API_TOKEN Auth token (set:
|
|
2287
|
+
API_TOKEN Auth token (set: ****cret)
|
|
1737
2288
|
API_URL API URL (default: https://api.example.com)
|
|
1738
2289
|
"
|
|
1739
2290
|
`)
|
|
@@ -1742,7 +2293,7 @@ describe('env', () => {
|
|
|
1742
2293
|
process.env.API_URL = 'https://custom.example.com'
|
|
1743
2294
|
const { output: output2 } = await serve(cli, ['deploy', '--help'])
|
|
1744
2295
|
expect(output2).toContain(
|
|
1745
|
-
'API_URL API URL (set:
|
|
2296
|
+
'API_URL API URL (set: ****.com, default: https://api.example.com)',
|
|
1746
2297
|
)
|
|
1747
2298
|
} finally {
|
|
1748
2299
|
delete process.env.API_TOKEN
|
|
@@ -1770,7 +2321,7 @@ describe('env', () => {
|
|
|
1770
2321
|
const { output: output2 } = await serve(cli, ['deploy', '--help'], {
|
|
1771
2322
|
env: { API_TOKEN: 'secret' },
|
|
1772
2323
|
})
|
|
1773
|
-
expect(output2).toContain('set:
|
|
2324
|
+
expect(output2).toContain('set: ****cret')
|
|
1774
2325
|
})
|
|
1775
2326
|
|
|
1776
2327
|
test('--llms json includes schema.env', async () => {
|
|
@@ -1838,6 +2389,76 @@ describe('env', () => {
|
|
|
1838
2389
|
})
|
|
1839
2390
|
})
|
|
1840
2391
|
|
|
2392
|
+
describe('built-in commands', () => {
|
|
2393
|
+
test('bare completions shows help', async () => {
|
|
2394
|
+
const cli = Cli.create('test')
|
|
2395
|
+
cli.command('ping', { run: () => ({ pong: true }) })
|
|
2396
|
+
const { output } = await serve(cli, ['completions'])
|
|
2397
|
+
expect(output).toContain('Generate shell completion script')
|
|
2398
|
+
})
|
|
2399
|
+
|
|
2400
|
+
test('completions --help shows help', async () => {
|
|
2401
|
+
const cli = Cli.create('test')
|
|
2402
|
+
cli.command('ping', { run: () => ({ pong: true }) })
|
|
2403
|
+
const { output } = await serve(cli, ['completions', '--help'])
|
|
2404
|
+
expect(output).toContain('test completions')
|
|
2405
|
+
expect(output).toContain('Generate shell completion script')
|
|
2406
|
+
})
|
|
2407
|
+
|
|
2408
|
+
test('bare mcp shows help with subcommands', async () => {
|
|
2409
|
+
const cli = Cli.create('test')
|
|
2410
|
+
cli.command('ping', { run: () => ({ pong: true }) })
|
|
2411
|
+
const { output } = await serve(cli, ['mcp'])
|
|
2412
|
+
expect(output).toContain('test mcp')
|
|
2413
|
+
expect(output).toContain('Register as MCP server')
|
|
2414
|
+
expect(output).toContain('add')
|
|
2415
|
+
})
|
|
2416
|
+
|
|
2417
|
+
test('mcp --help shows help with subcommands', async () => {
|
|
2418
|
+
const cli = Cli.create('test')
|
|
2419
|
+
cli.command('ping', { run: () => ({ pong: true }) })
|
|
2420
|
+
const { output } = await serve(cli, ['mcp', '--help'])
|
|
2421
|
+
expect(output).toContain('test mcp')
|
|
2422
|
+
expect(output).toContain('add')
|
|
2423
|
+
})
|
|
2424
|
+
|
|
2425
|
+
test('mcp add --help shows options', async () => {
|
|
2426
|
+
const cli = Cli.create('test')
|
|
2427
|
+
cli.command('ping', { run: () => ({ pong: true }) })
|
|
2428
|
+
const { output } = await serve(cli, ['mcp', 'add', '--help'])
|
|
2429
|
+
expect(output).toContain('test mcp add')
|
|
2430
|
+
expect(output).toContain('--command')
|
|
2431
|
+
expect(output).toContain('--no-global')
|
|
2432
|
+
expect(output).toContain('--agent')
|
|
2433
|
+
})
|
|
2434
|
+
|
|
2435
|
+
test('bare skills shows help with subcommands', async () => {
|
|
2436
|
+
const cli = Cli.create('test')
|
|
2437
|
+
cli.command('ping', { run: () => ({ pong: true }) })
|
|
2438
|
+
const { output } = await serve(cli, ['skills'])
|
|
2439
|
+
expect(output).toContain('test skills')
|
|
2440
|
+
expect(output).toContain('Sync skill files to agents')
|
|
2441
|
+
expect(output).toContain('add')
|
|
2442
|
+
})
|
|
2443
|
+
|
|
2444
|
+
test('skills --help shows help with subcommands', async () => {
|
|
2445
|
+
const cli = Cli.create('test')
|
|
2446
|
+
cli.command('ping', { run: () => ({ pong: true }) })
|
|
2447
|
+
const { output } = await serve(cli, ['skills', '--help'])
|
|
2448
|
+
expect(output).toContain('test skills')
|
|
2449
|
+
expect(output).toContain('add')
|
|
2450
|
+
})
|
|
2451
|
+
|
|
2452
|
+
test('skills add --help shows options', async () => {
|
|
2453
|
+
const cli = Cli.create('test')
|
|
2454
|
+
cli.command('ping', { run: () => ({ pong: true }) })
|
|
2455
|
+
const { output } = await serve(cli, ['skills', 'add', '--help'])
|
|
2456
|
+
expect(output).toContain('test skills add')
|
|
2457
|
+
expect(output).toContain('--depth')
|
|
2458
|
+
expect(output).toContain('--no-global')
|
|
2459
|
+
})
|
|
2460
|
+
})
|
|
2461
|
+
|
|
1841
2462
|
describe('skills staleness', () => {
|
|
1842
2463
|
let stderrSpy: ReturnType<typeof vi.spyOn>
|
|
1843
2464
|
|
|
@@ -3375,6 +3996,83 @@ describe('fetch', () => {
|
|
|
3375
3996
|
`)
|
|
3376
3997
|
})
|
|
3377
3998
|
|
|
3999
|
+
test('group middleware runs for nested commands', async () => {
|
|
4000
|
+
const sub = Cli.create('admin', {
|
|
4001
|
+
vars: z.object({ role: z.string().default('none') }),
|
|
4002
|
+
})
|
|
4003
|
+
sub.use(async (c, next) => {
|
|
4004
|
+
c.set('role', 'admin')
|
|
4005
|
+
await next()
|
|
4006
|
+
})
|
|
4007
|
+
sub.command('status', {
|
|
4008
|
+
run: (c) => ({ role: c.var.role }),
|
|
4009
|
+
})
|
|
4010
|
+
const cli = Cli.create('test', {
|
|
4011
|
+
vars: z.object({ role: z.string().default('none') }),
|
|
4012
|
+
})
|
|
4013
|
+
cli.command(sub)
|
|
4014
|
+
expect(await fetchJson(cli, new Request('http://localhost/admin/status')))
|
|
4015
|
+
.toMatchInlineSnapshot(`
|
|
4016
|
+
{
|
|
4017
|
+
"body": {
|
|
4018
|
+
"data": {
|
|
4019
|
+
"role": "admin",
|
|
4020
|
+
},
|
|
4021
|
+
"meta": {
|
|
4022
|
+
"command": "admin status",
|
|
4023
|
+
"duration": "<stripped>",
|
|
4024
|
+
},
|
|
4025
|
+
"ok": true,
|
|
4026
|
+
},
|
|
4027
|
+
"status": 200,
|
|
4028
|
+
}
|
|
4029
|
+
`)
|
|
4030
|
+
})
|
|
4031
|
+
|
|
4032
|
+
test('cli-level env schema is parsed', async () => {
|
|
4033
|
+
const cli = Cli.create('test', {
|
|
4034
|
+
env: z.object({ APP_TOKEN: z.string().default('fallback') }),
|
|
4035
|
+
})
|
|
4036
|
+
cli.use(async (c, next) => {
|
|
4037
|
+
// env should be parsed from envSchema
|
|
4038
|
+
;(globalThis as any).__testEnv = c.env
|
|
4039
|
+
await next()
|
|
4040
|
+
})
|
|
4041
|
+
cli.command('check', { run: () => ({ ok: true }) })
|
|
4042
|
+
await cli.fetch(new Request('http://localhost/check'))
|
|
4043
|
+
expect((globalThis as any).__testEnv).toEqual({ APP_TOKEN: 'fallback' })
|
|
4044
|
+
delete (globalThis as any).__testEnv
|
|
4045
|
+
})
|
|
4046
|
+
|
|
4047
|
+
test('retryable error is propagated', async () => {
|
|
4048
|
+
const cli = Cli.create('test')
|
|
4049
|
+
cli.command('rate-limit', {
|
|
4050
|
+
run: (c) => c.error({ code: 'RATE_LIMITED', message: 'slow down', retryable: true }),
|
|
4051
|
+
})
|
|
4052
|
+
const { body } = await fetchJson(cli, new Request('http://localhost/rate-limit'))
|
|
4053
|
+
expect(body.ok).toBe(false)
|
|
4054
|
+
expect(body.error.retryable).toBe(true)
|
|
4055
|
+
})
|
|
4056
|
+
|
|
4057
|
+
test('cta block is propagated', async () => {
|
|
4058
|
+
const cli = Cli.create('test')
|
|
4059
|
+
cli.command('done', {
|
|
4060
|
+
run: (c) => c.ok({ id: 1 }, { cta: { commands: ['list'], description: 'Next steps:' } }),
|
|
4061
|
+
})
|
|
4062
|
+
const { body } = await fetchJson(cli, new Request('http://localhost/done'))
|
|
4063
|
+
expect(body.ok).toBe(true)
|
|
4064
|
+
expect(body.meta.cta).toMatchInlineSnapshot(`
|
|
4065
|
+
{
|
|
4066
|
+
"commands": [
|
|
4067
|
+
{
|
|
4068
|
+
"command": "test list",
|
|
4069
|
+
},
|
|
4070
|
+
],
|
|
4071
|
+
"description": "Next steps:",
|
|
4072
|
+
}
|
|
4073
|
+
`)
|
|
4074
|
+
})
|
|
4075
|
+
|
|
3378
4076
|
describe('mcp over http', () => {
|
|
3379
4077
|
function mcpCli() {
|
|
3380
4078
|
const cli = Cli.create('test', { version: '1.0.0' })
|