incur 0.4.0 → 0.4.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.
Files changed (115) hide show
  1. package/README.md +83 -22
  2. package/SKILL.md +6 -6
  3. package/dist/Cli.d.ts +46 -26
  4. package/dist/Cli.d.ts.map +1 -1
  5. package/dist/Cli.js +728 -441
  6. package/dist/Cli.js.map +1 -1
  7. package/dist/Completions.d.ts +4 -3
  8. package/dist/Completions.d.ts.map +1 -1
  9. package/dist/Completions.js +17 -10
  10. package/dist/Completions.js.map +1 -1
  11. package/dist/Fetch.d.ts.map +1 -1
  12. package/dist/Fetch.js +10 -9
  13. package/dist/Fetch.js.map +1 -1
  14. package/dist/Filter.js +0 -18
  15. package/dist/Filter.js.map +1 -1
  16. package/dist/Formatter.d.ts.map +1 -1
  17. package/dist/Formatter.js +6 -1
  18. package/dist/Formatter.js.map +1 -1
  19. package/dist/Help.d.ts +7 -1
  20. package/dist/Help.d.ts.map +1 -1
  21. package/dist/Help.js +44 -27
  22. package/dist/Help.js.map +1 -1
  23. package/dist/Mcp.d.ts +37 -5
  24. package/dist/Mcp.d.ts.map +1 -1
  25. package/dist/Mcp.js +71 -72
  26. package/dist/Mcp.js.map +1 -1
  27. package/dist/Openapi.d.ts.map +1 -1
  28. package/dist/Openapi.js +22 -14
  29. package/dist/Openapi.js.map +1 -1
  30. package/dist/Parser.d.ts +4 -0
  31. package/dist/Parser.d.ts.map +1 -1
  32. package/dist/Parser.js +70 -38
  33. package/dist/Parser.js.map +1 -1
  34. package/dist/Schema.d.ts +5 -1
  35. package/dist/Schema.d.ts.map +1 -1
  36. package/dist/Schema.js +13 -2
  37. package/dist/Schema.js.map +1 -1
  38. package/dist/Skill.d.ts +2 -1
  39. package/dist/Skill.d.ts.map +1 -1
  40. package/dist/Skill.js +33 -19
  41. package/dist/Skill.js.map +1 -1
  42. package/dist/Skillgen.js +1 -1
  43. package/dist/Skillgen.js.map +1 -1
  44. package/dist/SyncSkills.d.ts +48 -0
  45. package/dist/SyncSkills.d.ts.map +1 -1
  46. package/dist/SyncSkills.js +108 -10
  47. package/dist/SyncSkills.js.map +1 -1
  48. package/dist/Typegen.js +4 -2
  49. package/dist/Typegen.js.map +1 -1
  50. package/dist/bin.d.ts +2 -1
  51. package/dist/bin.d.ts.map +1 -1
  52. package/dist/bin.js +17 -2
  53. package/dist/bin.js.map +1 -1
  54. package/dist/internal/command.d.ts +170 -0
  55. package/dist/internal/command.d.ts.map +1 -0
  56. package/dist/internal/command.js +292 -0
  57. package/dist/internal/command.js.map +1 -0
  58. package/dist/internal/configSchema.d.ts +8 -0
  59. package/dist/internal/configSchema.d.ts.map +1 -0
  60. package/dist/internal/configSchema.js +57 -0
  61. package/dist/internal/configSchema.js.map +1 -0
  62. package/dist/internal/dereference.d.ts +12 -0
  63. package/dist/internal/dereference.d.ts.map +1 -0
  64. package/dist/internal/dereference.js +71 -0
  65. package/dist/internal/dereference.js.map +1 -0
  66. package/dist/internal/helpers.d.ts +9 -0
  67. package/dist/internal/helpers.d.ts.map +1 -0
  68. package/dist/internal/helpers.js +54 -0
  69. package/dist/internal/helpers.js.map +1 -0
  70. package/dist/middleware.d.ts +6 -8
  71. package/dist/middleware.d.ts.map +1 -1
  72. package/dist/middleware.js +1 -1
  73. package/dist/middleware.js.map +1 -1
  74. package/examples/npm/.npmrc.json +21 -0
  75. package/examples/npm/config.schema.json +134 -0
  76. package/package.json +6 -29
  77. package/src/Cli.test-d.ts +44 -33
  78. package/src/Cli.test.ts +1231 -101
  79. package/src/Cli.ts +877 -569
  80. package/src/Completions.test.ts +136 -12
  81. package/src/Completions.ts +18 -13
  82. package/src/Fetch.test.ts +21 -0
  83. package/src/Fetch.ts +8 -10
  84. package/src/Filter.ts +0 -17
  85. package/src/Formatter.test.ts +15 -2
  86. package/src/Formatter.ts +5 -1
  87. package/src/Help.test.ts +184 -20
  88. package/src/Help.ts +52 -28
  89. package/src/Mcp.test.ts +159 -0
  90. package/src/Mcp.ts +108 -86
  91. package/src/Openapi.test.ts +17 -5
  92. package/src/Openapi.ts +21 -15
  93. package/src/Parser.test-d.ts +22 -0
  94. package/src/Parser.test.ts +89 -0
  95. package/src/Parser.ts +87 -36
  96. package/src/Schema.test.ts +29 -0
  97. package/src/Schema.ts +12 -2
  98. package/src/Skill.test.ts +87 -6
  99. package/src/Skill.ts +38 -21
  100. package/src/Skillgen.ts +1 -1
  101. package/src/SyncMcp.test.ts +6 -8
  102. package/src/SyncSkills.test.ts +146 -3
  103. package/src/SyncSkills.ts +191 -10
  104. package/src/Typegen.test.ts +15 -0
  105. package/src/Typegen.ts +4 -2
  106. package/src/bin.ts +21 -2
  107. package/src/e2e.test.ts +188 -98
  108. package/src/internal/command.ts +449 -0
  109. package/src/internal/configSchema.test.ts +193 -0
  110. package/src/internal/configSchema.ts +66 -0
  111. package/src/internal/dereference.test.ts +695 -0
  112. package/src/internal/dereference.ts +75 -0
  113. package/src/internal/helpers.test.ts +75 -0
  114. package/src/internal/helpers.ts +59 -0
  115. package/src/middleware.ts +5 -12
@@ -1,4 +1,8 @@
1
1
  import { Cli, Completions, z } from 'incur'
2
+ import { execFile, spawnSync } from 'node:child_process'
3
+ import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
2
6
 
3
7
  const originalIsTTY = process.stdout.isTTY
4
8
  const originalEnv = { ...process.env }
@@ -15,6 +19,45 @@ vi.mock('./SyncSkills.js', async (importOriginal) => {
15
19
  return { ...actual, readHash: () => undefined }
16
20
  })
17
21
 
22
+ function hasShell(shell: string): boolean {
23
+ return spawnSync(shell, ['-c', ':'], { stdio: 'ignore' }).status === 0
24
+ }
25
+
26
+ const bash = hasShell('bash')
27
+ const zsh = hasShell('zsh')
28
+ const fish = hasShell('fish')
29
+
30
+ function exec(cmd: string, args: string[], env: NodeJS.ProcessEnv): Promise<string> {
31
+ return new Promise((resolve, reject) => {
32
+ execFile(cmd, args, { env, timeout: 30_000 }, (error, stdout, stderr) => {
33
+ if (error) reject(new Error(stderr?.trim() || stdout?.trim() || error.message))
34
+ else resolve(stdout)
35
+ })
36
+ })
37
+ }
38
+
39
+ async function withFakeCli(run: (dir: string) => Promise<void>) {
40
+ const dir = await mkdtemp(join(tmpdir(), 'incur-completions-'))
41
+ const bin = join(dir, 'fake-cli')
42
+
43
+ try {
44
+ await writeFile(
45
+ bin,
46
+ `#!/bin/sh
47
+ if [ -n "$COMPLETE" ]; then
48
+ printf '%s' "$COMPLETE:\${_COMPLETE_INDEX:-missing}"
49
+ else
50
+ printf 'missing'
51
+ fi
52
+ `,
53
+ )
54
+ await chmod(bin, 0o755)
55
+ await run(dir)
56
+ } finally {
57
+ await rm(dir, { recursive: true, force: true })
58
+ }
59
+ }
60
+
18
61
  async function serve(
19
62
  cli: { serve: Cli.Cli['serve'] },
20
63
  argv: string[],
@@ -231,20 +274,59 @@ describe('register', () => {
231
274
  test('bash: generates complete -F script with nospace support', () => {
232
275
  const script = Completions.register('bash', 'mycli')
233
276
  expect(script).toContain('_incur_complete_mycli()')
234
- expect(script).toContain('COMPLETE="bash"')
277
+ expect(script).toContain('export COMPLETE="bash"')
235
278
  expect(script).toContain('complete -o default -o bashdefault -o nosort -F')
236
279
  expect(script).toContain('"mycli" -- "${COMP_WORDS[@]}"')
237
280
  expect(script).toContain('compopt -o nospace')
238
281
  })
239
282
 
283
+ test.skipIf(!bash)('bash: exports completion env vars to the CLI subprocess', async () => {
284
+ await withFakeCli(async (dir) => {
285
+ const output = await exec(
286
+ 'bash',
287
+ [
288
+ '-lc',
289
+ `${Completions.register('bash', 'fake-cli')}
290
+ COMP_WORDS=('fake-cli' 'build' '')
291
+ COMP_CWORD=2
292
+ _incur_complete_fake_cli
293
+ printf '%s' "\${COMPREPLY[*]}"`,
294
+ ],
295
+ { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` },
296
+ )
297
+
298
+ expect(output).toBe('bash:2')
299
+ })
300
+ })
301
+
240
302
  test('zsh: generates compdef script', () => {
241
303
  const script = Completions.register('zsh', 'mycli')
242
304
  expect(script).toContain('#compdef mycli')
243
- expect(script).toContain('COMPLETE="zsh"')
305
+ expect(script).toContain('export COMPLETE="zsh"')
244
306
  expect(script).toContain('compdef _incur_complete_mycli mycli')
245
307
  expect(script).toContain('_describe')
246
308
  })
247
309
 
310
+ test.skipIf(!zsh)('zsh: exports completion env vars to the CLI subprocess', async () => {
311
+ await withFakeCli(async (dir) => {
312
+ const output = await exec(
313
+ 'zsh',
314
+ [
315
+ '-lc',
316
+ `compdef() { : }
317
+ _describe() { print -r -- "\${(j:|:)\${(@P)2}}" }
318
+ ${Completions.register('zsh', 'fake-cli')}
319
+ words=('fake-cli' 'build' '')
320
+ CURRENT=3
321
+ _incur_complete_fake_cli`,
322
+ ],
323
+ { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` },
324
+ )
325
+
326
+ expect(output.trim()).toBe('zsh:2')
327
+ })
328
+ })
329
+
248
330
  test('fish: generates complete command', () => {
249
331
  const script = Completions.register('fish', 'mycli')
250
332
  expect(script).toContain('complete --keep-order --exclusive --command mycli')
@@ -252,6 +334,22 @@ describe('register', () => {
252
334
  expect(script).toContain('commandline --current-token')
253
335
  })
254
336
 
337
+ test.skipIf(!fish)('fish: passes completion env vars to the CLI subprocess', async () => {
338
+ await withFakeCli(async (dir) => {
339
+ const output = await exec(
340
+ 'fish',
341
+ [
342
+ '-c',
343
+ `${Completions.register('fish', 'fake-cli')}
344
+ complete --do-complete 'fake-cli '`,
345
+ ],
346
+ { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` },
347
+ )
348
+
349
+ expect(output.trim()).toBe('fish:missing')
350
+ })
351
+ })
352
+
255
353
  test('nushell: generates external completer closure', () => {
256
354
  const script = Completions.register('nushell', 'mycli')
257
355
  expect(script).toContain('COMPLETE=nushell')
@@ -294,27 +392,24 @@ describe('completions built-in command', () => {
294
392
  expect(output).toMatchInlineSnapshot(`
295
393
  "mycli completions — Generate shell completion script
296
394
 
297
- Usage: mycli completions <shell>
395
+ Usage: mycli completions <bash|fish|nushell|zsh>
298
396
 
299
- Shells:
300
- bash
301
- fish
302
- nushell
303
- zsh
397
+ Arguments:
398
+ shell Shell to generate completions for
304
399
 
305
400
  Setup:
306
401
  bash eval "$(mycli completions bash)" # add to ~/.bashrc
307
- zsh eval "$(mycli completions zsh)" # add to ~/.zshrc
308
402
  fish mycli completions fish | source # add to ~/.config/fish/config.fish
309
403
  nushell see \`mycli completions nushell\` # add to config.nu
404
+ zsh eval "$(mycli completions zsh)" # add to ~/.zshrc
310
405
  "
311
406
  `)
312
407
  })
313
408
 
314
- test('errors on missing shell argument', async () => {
409
+ test('shows help on missing shell argument', async () => {
315
410
  const cli = makeCli()
316
411
  const output = await serve(cli, ['completions'])
317
- expect(output).toContain('Missing shell argument')
412
+ expect(output).toContain('Generate shell completion script')
318
413
  })
319
414
 
320
415
  test('errors on unknown shell', async () => {
@@ -391,9 +486,38 @@ describe('serve integration', () => {
391
486
  expect(output).toContain('db')
392
487
  })
393
488
 
489
+ test('COMPLETE=bash includes built-in commands at root', async () => {
490
+ const cli = makeCli()
491
+ const output = await serve(cli, ['--', 'mycli', ''], {
492
+ COMPLETE: 'bash',
493
+ _COMPLETE_INDEX: '1',
494
+ })
495
+ expect(output).toContain('completions')
496
+ expect(output).toContain('mcp')
497
+ expect(output).toContain('skills')
498
+ })
499
+
500
+ test('COMPLETE=bash suggests add for skills subcommand', async () => {
501
+ const cli = makeCli()
502
+ const output = await serve(cli, ['--', 'mycli', 'skills', ''], {
503
+ COMPLETE: 'bash',
504
+ _COMPLETE_INDEX: '2',
505
+ })
506
+ expect(output).toContain('add')
507
+ })
508
+
509
+ test('COMPLETE=bash suggests add for mcp subcommand', async () => {
510
+ const cli = makeCli()
511
+ const output = await serve(cli, ['--', 'mycli', 'mcp', ''], {
512
+ COMPLETE: 'bash',
513
+ _COMPLETE_INDEX: '2',
514
+ })
515
+ expect(output).toContain('add')
516
+ })
517
+
394
518
  test('COMPLETE=zsh with words outputs candidates in zsh format', async () => {
395
519
  const cli = makeCli()
396
- const output = await serve(cli, ['--', 'mycli', '--'], {
520
+ await serve(cli, ['--', 'mycli', '--'], {
397
521
  COMPLETE: 'zsh',
398
522
  _COMPLETE_INDEX: '1',
399
523
  })
@@ -1,7 +1,6 @@
1
1
  import type { z } from 'zod'
2
2
 
3
- /** Supported shells for completion generation. */
4
- export type Shell = 'bash' | 'fish' | 'nushell' | 'zsh'
3
+ import type { Shell } from './internal/command.js'
5
4
 
6
5
  /** A completion candidate with an optional description. */
7
6
  export type Candidate = {
@@ -13,14 +12,16 @@ export type Candidate = {
13
12
  value: string
14
13
  }
15
14
 
16
- /** @internal Entry stored in a command map — either a leaf definition or a group. */
15
+ /** @internal Entry stored in a command map — either a leaf definition, a group, or an alias. */
17
16
  type CommandEntry = {
17
+ _alias?: true | undefined
18
18
  _group?: true | undefined
19
19
  alias?: Record<string, string | undefined> | undefined
20
20
  args?: z.ZodObject<any> | undefined
21
21
  commands?: Map<string, CommandEntry> | undefined
22
22
  description?: string | undefined
23
23
  options?: z.ZodObject<any> | undefined
24
+ target?: string | undefined
24
25
  }
25
26
 
26
27
  /**
@@ -62,7 +63,10 @@ export function complete(
62
63
  for (let i = 0; i < index; i++) {
63
64
  const token = argv[i]!
64
65
  if (token.startsWith('-')) continue
65
- const entry = scope.commands.get(token)
66
+ let entry = scope.commands.get(token)
67
+ if (!entry) continue
68
+ // Follow alias to canonical entry
69
+ if (entry._alias && entry.target) entry = scope.commands.get(entry.target)
66
70
  if (!entry) continue
67
71
  if (entry._group && entry.commands) {
68
72
  scope = { commands: entry.commands }
@@ -117,6 +121,7 @@ export function complete(
117
121
 
118
122
  // Suggest subcommands (groups get noSpace so user can keep typing subcommand)
119
123
  for (const [name, entry] of scope.commands) {
124
+ if (entry._alias) continue
120
125
  if (name.startsWith(current))
121
126
  candidates.push({
122
127
  value: name,
@@ -233,25 +238,25 @@ function bashRegister(name: string): string {
233
238
  local _COMPLETE_INDEX=\${COMP_CWORD}
234
239
  local _completions
235
240
  _completions=( $(
236
- COMPLETE="bash"
237
- _COMPLETE_INDEX="\$_COMPLETE_INDEX"
241
+ export COMPLETE="bash"
242
+ export _COMPLETE_INDEX="$_COMPLETE_INDEX"
238
243
  "${name}" -- "\${COMP_WORDS[@]}"
239
244
  ) )
240
- if [[ \$? != 0 ]]; then
245
+ if [[ $? != 0 ]]; then
241
246
  unset COMPREPLY
242
247
  return
243
248
  fi
244
249
  local _nospace=false
245
250
  COMPREPLY=()
246
251
  for _c in "\${_completions[@]}"; do
247
- if [[ "\$_c" == *$'\\001' ]]; then
252
+ if [[ "$_c" == *$'\\001' ]]; then
248
253
  _nospace=true
249
254
  COMPREPLY+=("\${_c%$'\\001'}")
250
255
  else
251
- COMPREPLY+=("\$_c")
256
+ COMPREPLY+=("$_c")
252
257
  fi
253
258
  done
254
- if [[ \$_nospace == true ]]; then
259
+ if [[ $_nospace == true ]]; then
255
260
  compopt -o nospace
256
261
  fi
257
262
  }
@@ -263,11 +268,11 @@ function zshRegister(name: string): string {
263
268
  return `#compdef ${name}
264
269
  _incur_complete_${id}() {
265
270
  local completions=("\${(@f)$(
266
- _COMPLETE_INDEX=$(( CURRENT - 1 ))
267
- COMPLETE="zsh"
271
+ export _COMPLETE_INDEX=$(( CURRENT - 1 ))
272
+ export COMPLETE="zsh"
268
273
  "${name}" -- "\${words[@]}" 2>/dev/null
269
274
  )}")
270
- if [[ -n \$completions ]]; then
275
+ if [[ -n $completions ]]; then
271
276
  _describe 'values' completions -S ''
272
277
  fi
273
278
  }
package/src/Fetch.test.ts CHANGED
@@ -82,6 +82,27 @@ describe('parseArgv', () => {
82
82
  expect(input.query.get('limit')).toBe('5')
83
83
  })
84
84
 
85
+ test('--method as last token throws missing value error', () => {
86
+ expect(() => Fetch.parseArgv(['users', '--method'])).toThrow('Missing value for --method')
87
+ })
88
+
89
+ test('-X as last token throws missing value error', () => {
90
+ expect(() => Fetch.parseArgv(['users', '-X'])).toThrow('Missing value for -X')
91
+ })
92
+
93
+ test('--header as last token throws missing value error', () => {
94
+ expect(() => Fetch.parseArgv(['users', '--header'])).toThrow('Missing value for --header')
95
+ })
96
+
97
+ test('--limit as last token throws missing value error', () => {
98
+ expect(() => Fetch.parseArgv(['users', '--limit'])).toThrow('Missing value for --limit')
99
+ })
100
+
101
+ test('unknown short flag does not swallow the next token', () => {
102
+ const input = Fetch.parseArgv(['users', '-z', 'create'])
103
+ expect(input.path).toBe('/users/create')
104
+ })
105
+
85
106
  test('mixed tokens, flags, and query params', () => {
86
107
  const input = Fetch.parseArgv([
87
108
  'users',
package/src/Fetch.ts CHANGED
@@ -43,24 +43,22 @@ export function parseArgv(argv: string[]): FetchInput {
43
43
  } else {
44
44
  const key = token.slice(2)
45
45
  const value = argv[i + 1]
46
- if (reservedFlags.has(key)) {
47
- handleReserved(key, value!)
48
- i += 2
49
- } else {
50
- query.set(key, value!)
51
- i += 2
52
- }
46
+ if (value === undefined) throw new Error(`Missing value for --${key}`)
47
+ if (reservedFlags.has(key)) handleReserved(key, value)
48
+ else query.set(key, value)
49
+ i += 2
53
50
  }
54
51
  } else if (token.startsWith('-') && token.length === 2) {
55
52
  const short = token[1]!
56
53
  const mapped = reservedShort[short]
57
- const value = argv[i + 1]!
58
54
  if (mapped) {
55
+ const value = argv[i + 1]
56
+ if (value === undefined) throw new Error(`Missing value for -${short}`)
59
57
  handleReserved(mapped, value)
60
58
  i += 2
61
59
  } else {
62
- // Unknown short flag — skip (shouldn't happen in fetch context)
63
- i += 2
60
+ // Unknown short flag — treat as single token, don't consume next
61
+ i++
64
62
  }
65
63
  } else {
66
64
  segments.push(token)
package/src/Filter.ts CHANGED
@@ -80,23 +80,6 @@ export function apply(data: unknown, paths: FilterPath[]): unknown {
80
80
  return result
81
81
  }
82
82
 
83
- function resolve(data: unknown, segments: Segment[], index: number): unknown {
84
- if (index >= segments.length) return data
85
- const segment = segments[index]!
86
-
87
- if ('key' in segment) {
88
- if (typeof data !== 'object' || data === null) return undefined
89
- const val = (data as Record<string, unknown>)[segment.key]
90
- return resolve(val, segments, index + 1)
91
- }
92
-
93
- // slice segment
94
- if (!Array.isArray(data)) return undefined
95
- const sliced = data.slice(segment.start, segment.end)
96
- if (index + 1 >= segments.length) return sliced
97
- return sliced.map((item) => resolve(item, segments, index + 1))
98
- }
99
-
100
83
  function merge(
101
84
  target: Record<string, unknown>,
102
85
  data: unknown,
@@ -296,8 +296,21 @@ describe('format md', () => {
296
296
  })
297
297
 
298
298
  describe('format jsonl', () => {
299
- test('falls through to toon', () => {
299
+ test('single object outputs one JSON line', () => {
300
300
  const result = Formatter.format({ message: 'hello' }, 'jsonl')
301
- expect(result).toMatchInlineSnapshot(`"message: hello"`)
301
+ expect(result).toMatchInlineSnapshot(`"{"message":"hello"}"`)
302
+ })
303
+
304
+ test('array outputs one JSON line per element', () => {
305
+ const result = Formatter.format([{ id: 1 }, { id: 2 }], 'jsonl')
306
+ expect(result).toMatchInlineSnapshot(`
307
+ "{"id":1}
308
+ {"id":2}"
309
+ `)
310
+ })
311
+
312
+ test('scalar value outputs JSON', () => {
313
+ expect(Formatter.format(42, 'jsonl')).toMatchInlineSnapshot(`"42"`)
314
+ expect(Formatter.format('hi', 'jsonl')).toMatchInlineSnapshot(`""hi""`)
302
315
  })
303
316
  })
package/src/Formatter.ts CHANGED
@@ -10,7 +10,11 @@ export function format(value: unknown, fmt: Format = 'toon'): string {
10
10
  if (fmt === 'json') return JSON.stringify(value, null, 2)
11
11
  if (fmt === 'yaml') return yamlStringify(value)
12
12
  if (fmt === 'md') return formatMarkdown(value)
13
- // toon
13
+ if (fmt === 'jsonl') {
14
+ if (Array.isArray(value)) return value.map((v) => JSON.stringify(v)).join('\n')
15
+ return JSON.stringify(value)
16
+ }
17
+ // toon (default)
14
18
  if (isScalar(value)) return String(value)
15
19
  return encode(value as Record<string, unknown>)
16
20
  }