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
package/src/Help.test.ts CHANGED
@@ -1,5 +1,43 @@
1
1
  import { Help, z } from 'incur'
2
2
 
3
+ describe('redact: short secrets should not leak characters', () => {
4
+ /**
5
+ * The internal `redact()` function is exercised through `formatCommand`
6
+ * by passing an env schema + envSource with a set value.
7
+ */
8
+ function getRedactedValue(secret: string): string {
9
+ const env = z.object({ SECRET: z.string().describe('a secret') })
10
+ const output = Help.formatCommand('test', {
11
+ env,
12
+ envSource: { SECRET: secret },
13
+ hideGlobalOptions: true,
14
+ })
15
+ const match = output.match(/set:\s*(\S+)/)
16
+ if (!match) throw new Error(`Could not find "set:" in output:\n${output}`)
17
+ return match[1]!
18
+ }
19
+
20
+ test('1-char secret is fully masked', () => {
21
+ const redacted = getRedactedValue('x')
22
+ expect(redacted).not.toContain('x')
23
+ })
24
+
25
+ test('2-char secret does not leak any character', () => {
26
+ const redacted = getRedactedValue('ab')
27
+ expect(redacted).not.toContain('b')
28
+ })
29
+
30
+ test('3-char secret does not leak any character', () => {
31
+ const redacted = getRedactedValue('abc')
32
+ expect(redacted).not.toContain('c')
33
+ })
34
+
35
+ test('4-char secret does not leak any character', () => {
36
+ const redacted = getRedactedValue('wxyz')
37
+ expect(redacted).not.toContain('z')
38
+ })
39
+ })
40
+
3
41
  describe('formatCommand', () => {
4
42
  test('formats leaf command with args and options', () => {
5
43
  const result = Help.formatCommand('gh pr list', {
@@ -27,13 +65,13 @@ describe('formatCommand', () => {
27
65
  Global Options:
28
66
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
29
67
  --format <toon|json|yaml|md|jsonl> Output format
68
+ --full-output Show full output envelope
30
69
  --help Show help
31
70
  --llms, --llms-full Print LLM-readable manifest
32
- --schema Show JSON Schema for a command
71
+ --schema Show JSON Schema for command
33
72
  --token-count Print token count of output (instead of output)
34
73
  --token-limit <n> Limit output to n tokens
35
- --token-offset <n> Skip first n tokens of output
36
- --verbose Show full output envelope"
74
+ --token-offset <n> Skip first n tokens of output"
37
75
  `)
38
76
  })
39
77
 
@@ -49,13 +87,13 @@ describe('formatCommand', () => {
49
87
  Global Options:
50
88
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
51
89
  --format <toon|json|yaml|md|jsonl> Output format
90
+ --full-output Show full output envelope
52
91
  --help Show help
53
92
  --llms, --llms-full Print LLM-readable manifest
54
- --schema Show JSON Schema for a command
93
+ --schema Show JSON Schema for command
55
94
  --token-count Print token count of output (instead of output)
56
95
  --token-limit <n> Limit output to n tokens
57
- --token-offset <n> Skip first n tokens of output
58
- --verbose Show full output envelope"
96
+ --token-offset <n> Skip first n tokens of output"
59
97
  `)
60
98
  })
61
99
 
@@ -78,16 +116,27 @@ describe('formatCommand', () => {
78
116
  Global Options:
79
117
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
80
118
  --format <toon|json|yaml|md|jsonl> Output format
119
+ --full-output Show full output envelope
81
120
  --help Show help
82
121
  --llms, --llms-full Print LLM-readable manifest
83
- --schema Show JSON Schema for a command
122
+ --schema Show JSON Schema for command
84
123
  --token-count Print token count of output (instead of output)
85
124
  --token-limit <n> Limit output to n tokens
86
- --token-offset <n> Skip first n tokens of output
87
- --verbose Show full output envelope"
125
+ --token-offset <n> Skip first n tokens of output"
88
126
  `)
89
127
  })
90
128
 
129
+ test('synopsis uses key name for non-union args and expanded values for enums', () => {
130
+ const result = Help.formatCommand('tool run', {
131
+ args: z.object({
132
+ port: z.number().describe('Port number'),
133
+ verbose: z.boolean().optional().describe('Verbose'),
134
+ mode: z.enum(['fast', 'slow']).describe('Mode'),
135
+ }),
136
+ })
137
+ expect(result).toContain('Usage: tool run <port> [verbose] <fast|slow>')
138
+ })
139
+
91
140
  test('shows count type in help for meta count', () => {
92
141
  const result = Help.formatCommand('tool run', {
93
142
  options: z.object({
@@ -99,6 +148,55 @@ describe('formatCommand', () => {
99
148
  expect(result).toContain('Verbosity level')
100
149
  })
101
150
 
151
+ test('omits value placeholders for boolean flag options', () => {
152
+ const result = Help.formatCommand('tool deploy', {
153
+ options: z.object({
154
+ dryRun: z.boolean().optional().describe('Preview without submitting.'),
155
+ }),
156
+ })
157
+
158
+ const line = result.split('\n').find((line) => line.includes('--dry-run'))
159
+
160
+ expect(line).toBe(' --dry-run Preview without submitting.')
161
+ })
162
+
163
+ test('omits value placeholders for aliased boolean flag options', () => {
164
+ const result = Help.formatCommand('tool deploy', {
165
+ options: z.object({
166
+ dryRun: z.boolean().optional().describe('Preview without submitting.'),
167
+ }),
168
+ alias: { dryRun: 'd' },
169
+ })
170
+
171
+ const line = result.split('\n').find((line) => line.includes('--dry-run'))
172
+
173
+ expect(line).toBe(' --dry-run, -d Preview without submitting.')
174
+ })
175
+
176
+ test('omits default false for boolean flag options', () => {
177
+ const result = Help.formatCommand('tool deploy', {
178
+ options: z.object({
179
+ dryRun: z.boolean().default(false).describe('Preview without submitting.'),
180
+ }),
181
+ })
182
+
183
+ const line = result.split('\n').find((line) => line.includes('--dry-run'))
184
+
185
+ expect(line).toBe(' --dry-run Preview without submitting.')
186
+ })
187
+
188
+ test('shows default true for boolean flag options', () => {
189
+ const result = Help.formatCommand('tool deploy', {
190
+ options: z.object({
191
+ watch: z.boolean().default(true).describe('Watch for changes.'),
192
+ }),
193
+ })
194
+
195
+ const line = result.split('\n').find((line) => line.includes('--watch'))
196
+
197
+ expect(line).toBe(' --watch Watch for changes. (default: true)')
198
+ })
199
+
102
200
  test('shows enum values for z.enum options', () => {
103
201
  const result = Help.formatCommand('tool deploy', {
104
202
  options: z.object({
@@ -130,6 +228,36 @@ describe('formatCommand', () => {
130
228
  expect(result).toContain('[deprecated] Availability zone')
131
229
  expect(result).not.toContain('[deprecated] Target region')
132
230
  })
231
+
232
+ test('shows config global options when flag name is set', () => {
233
+ const result = Help.formatCommand('tool deploy', {
234
+ configFlag: 'config',
235
+ options: z.object({
236
+ env: z.enum(['staging', 'production']).describe('Target environment'),
237
+ }),
238
+ })
239
+ expect(result).toMatchInlineSnapshot(`
240
+ "tool deploy
241
+
242
+ Usage: tool deploy [options]
243
+
244
+ Options:
245
+ --env <staging|production> Target environment
246
+
247
+ Global Options:
248
+ --config <path> Load JSON option defaults from a file
249
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
250
+ --format <toon|json|yaml|md|jsonl> Output format
251
+ --full-output Show full output envelope
252
+ --help Show help
253
+ --llms, --llms-full Print LLM-readable manifest
254
+ --no-config Disable JSON option defaults for this run
255
+ --schema Show JSON Schema for command
256
+ --token-count Print token count of output (instead of output)
257
+ --token-limit <n> Limit output to n tokens
258
+ --token-offset <n> Skip first n tokens of output"
259
+ `)
260
+ })
133
261
  })
134
262
 
135
263
  describe('formatRoot', () => {
@@ -155,13 +283,13 @@ describe('formatRoot', () => {
155
283
  Global Options:
156
284
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
157
285
  --format <toon|json|yaml|md|jsonl> Output format
286
+ --full-output Show full output envelope
158
287
  --help Show help
159
288
  --llms, --llms-full Print LLM-readable manifest
160
- --schema Show JSON Schema for a command
289
+ --schema Show JSON Schema for command
161
290
  --token-count Print token count of output (instead of output)
162
291
  --token-limit <n> Limit output to n tokens
163
- --token-offset <n> Skip first n tokens of output
164
- --verbose Show full output envelope"
292
+ --token-offset <n> Skip first n tokens of output"
165
293
  `)
166
294
  })
167
295
 
@@ -180,13 +308,13 @@ describe('formatRoot', () => {
180
308
  Global Options:
181
309
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
182
310
  --format <toon|json|yaml|md|jsonl> Output format
311
+ --full-output Show full output envelope
183
312
  --help Show help
184
313
  --llms, --llms-full Print LLM-readable manifest
185
- --schema Show JSON Schema for a command
314
+ --schema Show JSON Schema for command
186
315
  --token-count Print token count of output (instead of output)
187
316
  --token-limit <n> Limit output to n tokens
188
- --token-offset <n> Skip first n tokens of output
189
- --verbose Show full output envelope"
317
+ --token-offset <n> Skip first n tokens of output"
190
318
  `)
191
319
  })
192
320
 
@@ -209,13 +337,13 @@ describe('formatRoot', () => {
209
337
  Global Options:
210
338
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
211
339
  --format <toon|json|yaml|md|jsonl> Output format
340
+ --full-output Show full output envelope
212
341
  --help Show help
213
342
  --llms, --llms-full Print LLM-readable manifest
214
- --schema Show JSON Schema for a command
343
+ --schema Show JSON Schema for command
215
344
  --token-count Print token count of output (instead of output)
216
345
  --token-limit <n> Limit output to n tokens
217
- --token-offset <n> Skip first n tokens of output
218
- --verbose Show full output envelope"
346
+ --token-offset <n> Skip first n tokens of output"
219
347
  `)
220
348
  })
221
349
 
@@ -238,13 +366,49 @@ describe('formatRoot', () => {
238
366
  Global Options:
239
367
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
240
368
  --format <toon|json|yaml|md|jsonl> Output format
369
+ --full-output Show full output envelope
370
+ --help Show help
371
+ --llms, --llms-full Print LLM-readable manifest
372
+ --schema Show JSON Schema for command
373
+ --token-count Print token count of output (instead of output)
374
+ --token-limit <n> Limit output to n tokens
375
+ --token-offset <n> Skip first n tokens of output"
376
+ `)
377
+ })
378
+
379
+ test('formatRoot shows config global options when flag name is set', () => {
380
+ const result = Help.formatRoot('tool', {
381
+ configFlag: 'config',
382
+ root: true,
383
+ commands: [{ name: 'ping', description: 'Health check' }],
384
+ })
385
+ expect(result).toMatchInlineSnapshot(`
386
+ "tool
387
+
388
+ Usage: tool <command>
389
+
390
+ Commands:
391
+ ping Health check
392
+
393
+ Integrations:
394
+ completions Generate shell completion script
395
+ mcp add Register as MCP server
396
+ skills Sync skill files to agents (add, list)
397
+
398
+ Global Options:
399
+ --config <path> Load JSON option defaults from a file
400
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
401
+ --format <toon|json|yaml|md|jsonl> Output format
402
+ --full-output Show full output envelope
241
403
  --help Show help
242
404
  --llms, --llms-full Print LLM-readable manifest
243
- --schema Show JSON Schema for a command
405
+ --mcp Start as MCP stdio server
406
+ --no-config Disable JSON option defaults for this run
407
+ --schema Show JSON Schema for command
244
408
  --token-count Print token count of output (instead of output)
245
409
  --token-limit <n> Limit output to n tokens
246
410
  --token-offset <n> Skip first n tokens of output
247
- --verbose Show full output envelope"
411
+ --version Show version"
248
412
  `)
249
413
  })
250
414
  })
package/src/Help.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  import { z } from 'zod'
2
2
 
3
+ import { builtinCommands } from './internal/command.js'
4
+ import { toKebab } from './internal/helpers.js'
5
+ import { defaultEnvSource } from './Parser.js'
6
+
3
7
  /** Formats help text for a router CLI or command group. */
4
8
  export function formatRoot(name: string, options: formatRoot.Options = {}): string {
5
- const { aliases, description, version, commands = [], root = false } = options
9
+ const { aliases, configFlag, description, version, commands = [], root = false } = options
6
10
  const lines: string[] = []
7
11
 
8
12
  // Header
@@ -27,7 +31,7 @@ export function formatRoot(name: string, options: formatRoot.Options = {}): stri
27
31
  }
28
32
  }
29
33
 
30
- lines.push(...globalOptionsLines(root))
34
+ lines.push(...globalOptionsLines(root, configFlag))
31
35
 
32
36
  return lines.join('\n')
33
37
  }
@@ -36,6 +40,8 @@ export declare namespace formatRoot {
36
40
  type Options = {
37
41
  /** Alternative binary names for this CLI. */
38
42
  aliases?: string[] | undefined
43
+ /** Flag name for config file path (e.g. `'config'` renders `--config <path>`). */
44
+ configFlag?: string | undefined
39
45
  /** Commands to list. */
40
46
  commands?: { name: string; description?: string | undefined }[] | undefined
41
47
  /** A short description of the CLI or group. */
@@ -50,11 +56,13 @@ export declare namespace formatRoot {
50
56
  export declare namespace formatCommand {
51
57
  type Options = {
52
58
  /** Map of option names to single-char aliases. */
53
- alias?: Record<string, string> | undefined
59
+ alias?: Partial<Record<string, string>> | undefined
54
60
  /** Alternative binary names for this CLI. */
55
61
  aliases?: string[] | undefined
56
62
  /** Zod schema for positional arguments. */
57
63
  args?: z.ZodObject<any> | undefined
64
+ /** Flag name for config file path (e.g. `'config'` renders `--config <path>`). */
65
+ configFlag?: string | undefined
58
66
  /** Subcommands to list (for CLIs with both a root handler and subcommands). */
59
67
  commands?: { name: string; description?: string | undefined }[] | undefined
60
68
  /** A short description of what the command does. */
@@ -67,6 +75,8 @@ export declare namespace formatCommand {
67
75
  examples?: { command: string; description?: string }[] | undefined
68
76
  /** Plain text hint displayed after examples and before global options. */
69
77
  hint?: string | undefined
78
+ /** Hide global options section. */
79
+ hideGlobalOptions?: boolean | undefined
70
80
  /** Zod schema for named options/flags. */
71
81
  options?: z.ZodObject<any> | undefined
72
82
  /** Show root-level built-in commands and flags. */
@@ -90,6 +100,7 @@ export function formatCommand(name: string, options: formatCommand.Options = {})
90
100
  const {
91
101
  alias,
92
102
  aliases,
103
+ configFlag,
93
104
  description,
94
105
  version,
95
106
  args,
@@ -195,7 +206,7 @@ export function formatCommand(name: string, options: formatCommand.Options = {})
195
206
  }
196
207
  }
197
208
 
198
- lines.push(...globalOptionsLines(root))
209
+ if (!options.hideGlobalOptions) lines.push(...globalOptionsLines(root, configFlag))
199
210
 
200
211
  // Environment Variables
201
212
  if (env) {
@@ -207,7 +218,7 @@ export function formatCommand(name: string, options: formatCommand.Options = {})
207
218
  for (const entry of entries) {
208
219
  const padding = ' '.repeat(maxLen - entry.name.length)
209
220
  const parts: string[] = [entry.description]
210
- const source = envSource ?? process.env
221
+ const source = envSource ?? defaultEnvSource()
211
222
  if (entry.name in source) parts.push(`set: ${redact(source[entry.name]!)}`)
212
223
  if (entry.defaultValue !== undefined) parts.push(`default: ${entry.defaultValue}`)
213
224
  const desc = parts.length > 1 ? `${parts[0]} (${parts.slice(1).join(', ')})` : parts[0]
@@ -223,8 +234,11 @@ export function formatCommand(name: string, options: formatCommand.Options = {})
223
234
  function buildSynopsis(name: string, args?: z.ZodObject<any>): string {
224
235
  if (!args) return name
225
236
  const parts = [name]
226
- for (const [key, schema] of Object.entries(args.shape))
227
- parts.push((schema as z.ZodType)._zod.optout === 'optional' ? `[${key}]` : `<${key}>`)
237
+ for (const [key, schema] of Object.entries(args.shape)) {
238
+ const type = resolveTypeName(schema)
239
+ const label = type.includes('|') ? type : key
240
+ parts.push((schema as z.ZodType)._zod.optout === 'optional' ? `[${label}]` : `<${label}>`)
241
+ }
228
242
  return parts.join(' ')
229
243
  }
230
244
 
@@ -247,7 +261,10 @@ function envEntries(schema: z.ZodObject<any>) {
247
261
  }
248
262
 
249
263
  /** Extracts option entries from a Zod object schema. */
250
- function optionEntries(schema: z.ZodObject<any>, alias?: Record<string, string> | undefined) {
264
+ function optionEntries(
265
+ schema: z.ZodObject<any>,
266
+ alias?: Partial<Record<string, string>> | undefined,
267
+ ) {
251
268
  const entries: {
252
269
  flag: string
253
270
  description: string
@@ -258,8 +275,10 @@ function optionEntries(schema: z.ZodObject<any>, alias?: Record<string, string>
258
275
  const type = resolveTypeName(field)
259
276
  const short = alias?.[key]
260
277
  const kebab = toKebab(key)
261
- const flag = short ? `--${kebab}, -${short} <${type}>` : `--${kebab} <${type}>`
262
- const defaultValue = extractDefault(field)
278
+ const valueHint = type === 'boolean' ? '' : ` <${type}>`
279
+ const flag = short ? `--${kebab}, -${short}${valueHint}` : `--${kebab}${valueHint}`
280
+ let defaultValue = extractDefault(field)
281
+ if (type === 'boolean' && defaultValue === false) defaultValue = undefined
263
282
  const deprecated = extractDeprecated(field)
264
283
  entries.push({ flag, description: (field as any).description ?? '', defaultValue, deprecated })
265
284
  }
@@ -320,30 +339,32 @@ function extractDeprecated(schema: unknown): boolean | undefined {
320
339
  return meta?.deprecated === true ? true : undefined
321
340
  }
322
341
 
323
- /** Converts a camelCase string to kebab-case. */
324
- function toKebab(str: string): string {
325
- return str.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
326
- }
327
-
328
342
  /** Renders the built-in commands and global options block. Root-only items are hidden for subcommands. */
329
- function globalOptionsLines(root = false): string[] {
343
+ function globalOptionsLines(root = false, configFlag?: string): string[] {
330
344
  const lines: string[] = []
331
345
 
332
346
  if (root) {
333
- const builtins = [
334
- { name: 'completions', desc: 'Generate shell completion script' },
335
- { name: 'mcp add', desc: 'Register as an MCP server' },
336
- { name: 'skills add', desc: 'Sync skill files to your agent' },
337
- ]
347
+ const builtins = builtinCommands.flatMap((b) => {
348
+ if (!b.subcommands) return [{ name: b.name, desc: b.description }]
349
+ if (b.subcommands.length === 1)
350
+ return [
351
+ { name: `${b.name} ${b.subcommands[0]!.name}`, desc: b.subcommands[0]!.description },
352
+ ]
353
+ const names = b.subcommands.map((s) => s.name).join(', ')
354
+ return [{ name: b.name, desc: `${b.description} (${names})` }]
355
+ })
338
356
  const maxCmd = Math.max(...builtins.map((b) => b.name.length))
339
357
  lines.push(
340
358
  '',
341
- 'Built-in Commands:',
359
+ 'Integrations:',
342
360
  ...builtins.map((b) => ` ${b.name}${' '.repeat(maxCmd - b.name.length)} ${b.desc}`),
343
361
  )
344
362
  }
345
363
 
346
364
  const flags = [
365
+ ...(configFlag
366
+ ? [{ flag: `--${configFlag} <path>`, desc: 'Load JSON option defaults from a file' }]
367
+ : []),
347
368
  {
348
369
  flag: '--filter-output <keys>',
349
370
  desc: 'Filter output by key paths (e.g. foo,bar.baz,a[0,3])',
@@ -352,13 +373,16 @@ function globalOptionsLines(root = false): string[] {
352
373
  { flag: '--help', desc: 'Show help' },
353
374
  { flag: '--llms, --llms-full', desc: 'Print LLM-readable manifest' },
354
375
  ...(root ? [{ flag: '--mcp', desc: 'Start as MCP stdio server' }] : []),
355
- { flag: '--schema', desc: 'Show JSON Schema for a command' },
376
+ ...(configFlag
377
+ ? [{ flag: `--no-${configFlag}`, desc: 'Disable JSON option defaults for this run' }]
378
+ : []),
379
+ { flag: '--schema', desc: 'Show JSON Schema for command' },
356
380
  { flag: '--token-count', desc: 'Print token count of output (instead of output)' },
357
381
  { flag: '--token-limit <n>', desc: 'Limit output to n tokens' },
358
382
  { flag: '--token-offset <n>', desc: 'Skip first n tokens of output' },
359
- { flag: '--verbose', desc: 'Show full output envelope' },
383
+ { flag: '--full-output', desc: 'Show full output envelope' },
360
384
  ...(root ? [{ flag: '--version', desc: 'Show version' }] : []),
361
- ]
385
+ ].sort((a, b) => a.flag.localeCompare(b.flag))
362
386
  const maxLen = Math.max(...flags.map((f) => f.flag.length))
363
387
  lines.push(
364
388
  '',
@@ -369,8 +393,8 @@ function globalOptionsLines(root = false): string[] {
369
393
  return lines
370
394
  }
371
395
 
372
- /** Redacts a value, showing only the last 3 characters. */
396
+ /** Redacts a value, showing only the last 4 characters for long values. */
373
397
  function redact(value: string): string {
374
- if (value.length <= 3) return '••••'
375
- return `••••${value.slice(-3)}`
398
+ if (value.length <= 4) return '****'
399
+ return `****${value.slice(-4)}`
376
400
  }
package/src/Mcp.test.ts CHANGED
@@ -103,6 +103,22 @@ describe('Mcp', () => {
103
103
  expect(res.result.capabilities.tools).toBeDefined()
104
104
  })
105
105
 
106
+ test('initialize with 2025-03-26 protocol version', async () => {
107
+ const [res] = await mcpSession(createTestCommands(), [
108
+ {
109
+ id: 1,
110
+ method: 'initialize',
111
+ params: {
112
+ protocolVersion: '2025-03-26',
113
+ capabilities: {},
114
+ clientInfo: { name: 'test-client', version: '1.0.0' },
115
+ },
116
+ },
117
+ ])
118
+ expect(res.result.serverInfo).toEqual({ name: 'test-cli', version: '1.0.0' })
119
+ expect(res.result.capabilities.tools).toBeDefined()
120
+ })
121
+
106
122
  test('tools/list returns all leaf commands as tools', async () => {
107
123
  const [, res] = await mcpSession(createTestCommands(), [
108
124
  { id: 1, method: 'initialize', params: initParams },
@@ -216,6 +232,149 @@ describe('Mcp', () => {
216
232
  ])
217
233
  })
218
234
 
235
+ test('middleware runs for tool calls', async () => {
236
+ const commands = new Map<string, any>()
237
+ commands.set('secret', {
238
+ description: 'Protected command',
239
+ run: () => ({ secret: 'data' }),
240
+ })
241
+ const middlewares = [
242
+ async (_c: any, next: () => Promise<void>) => {
243
+ _c.set('ran', true)
244
+ await next()
245
+ },
246
+ ]
247
+ const input = new PassThrough()
248
+ const output = new PassThrough()
249
+ const chunks: string[] = []
250
+ output.on('data', (chunk: Buffer) => chunks.push(chunk.toString()))
251
+
252
+ const done = Mcp.serve('test-cli', '1.0.0', commands, {
253
+ input,
254
+ output,
255
+ middlewares,
256
+ vars: z.object({ ran: z.boolean().default(false) }),
257
+ })
258
+
259
+ input.write(
260
+ `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: initParams })}\n`,
261
+ )
262
+ await new Promise((r) => setTimeout(r, 10))
263
+ input.write(
264
+ `${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'secret', arguments: {} } })}\n`,
265
+ )
266
+ await new Promise((r) => setTimeout(r, 20))
267
+ input.end()
268
+ await done
269
+
270
+ const responses = chunks.map((c) => JSON.parse(c.trim()))
271
+ const callRes = responses.find((r: any) => r.id === 2)
272
+ expect(callRes.result.content).toEqual([{ type: 'text', text: '{"secret":"data"}' }])
273
+ })
274
+
275
+ test('middleware error blocks tool call', async () => {
276
+ const commands = new Map<string, any>()
277
+ commands.set('secret', {
278
+ description: 'Protected',
279
+ run: () => ({ secret: true }),
280
+ })
281
+ const middlewares = [
282
+ (c: any) => {
283
+ c.error({ code: 'FORBIDDEN', message: 'not allowed' })
284
+ },
285
+ ]
286
+ const input = new PassThrough()
287
+ const output = new PassThrough()
288
+ const chunks: string[] = []
289
+ output.on('data', (chunk: Buffer) => chunks.push(chunk.toString()))
290
+
291
+ const done = Mcp.serve('test-cli', '1.0.0', commands, {
292
+ input,
293
+ output,
294
+ middlewares,
295
+ })
296
+
297
+ input.write(
298
+ `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: initParams })}\n`,
299
+ )
300
+ await new Promise((r) => setTimeout(r, 10))
301
+ input.write(
302
+ `${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'secret', arguments: {} } })}\n`,
303
+ )
304
+ await new Promise((r) => setTimeout(r, 20))
305
+ input.end()
306
+ await done
307
+
308
+ const responses = chunks.map((c) => JSON.parse(c.trim()))
309
+ const callRes = responses.find((r: any) => r.id === 2)
310
+ expect(callRes.result.isError).toBe(true)
311
+ expect(callRes.result.content[0].text).toBe('not allowed')
312
+ })
313
+
314
+ test('group middleware runs for nested tool calls', async () => {
315
+ const commands = new Map<string, any>()
316
+ const groupMiddleware = async (c: any, next: () => Promise<void>) => {
317
+ c.set('group', 'admin')
318
+ await next()
319
+ }
320
+ commands.set('admin', {
321
+ _group: true,
322
+ description: 'Admin commands',
323
+ middlewares: [groupMiddleware],
324
+ commands: new Map([
325
+ [
326
+ 'status',
327
+ {
328
+ description: 'Admin status',
329
+ run: (c: any) => ({ group: c.var.group }),
330
+ },
331
+ ],
332
+ ]),
333
+ })
334
+
335
+ const input = new PassThrough()
336
+ const output = new PassThrough()
337
+ const chunks: string[] = []
338
+ output.on('data', (chunk: Buffer) => chunks.push(chunk.toString()))
339
+
340
+ const done = Mcp.serve('test-cli', '1.0.0', commands, {
341
+ input,
342
+ output,
343
+ vars: z.object({ group: z.string().default('none') }),
344
+ })
345
+
346
+ input.write(
347
+ `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: initParams })}\n`,
348
+ )
349
+ await new Promise((r) => setTimeout(r, 10))
350
+ input.write(
351
+ `${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'admin_status', arguments: {} } })}\n`,
352
+ )
353
+ await new Promise((r) => setTimeout(r, 20))
354
+ input.end()
355
+ await done
356
+
357
+ const responses = chunks.map((c) => JSON.parse(c.trim()))
358
+ const callRes = responses.find((r: any) => r.id === 2)
359
+ expect(callRes.result.content).toEqual([{ type: 'text', text: '{"group":"admin"}' }])
360
+ })
361
+
362
+ test('env schema is parsed for tool calls', async () => {
363
+ const commands = new Map<string, any>()
364
+ commands.set('check-env', {
365
+ description: 'Check env',
366
+ env: z.object({ MY_VAR: z.string().default('default-val') }),
367
+ run: (c: any) => ({ val: c.env.MY_VAR }),
368
+ })
369
+
370
+ const [, res] = await mcpSession(commands, [
371
+ { id: 1, method: 'initialize', params: initParams },
372
+ { id: 2, method: 'tools/call', params: { name: 'check-env', arguments: {} } },
373
+ ])
374
+ const data = JSON.parse(res.result.content[0].text)
375
+ expect(data.val).toBe('default-val')
376
+ })
377
+
219
378
  test('streaming command sends progress notifications', async () => {
220
379
  const input = new PassThrough()
221
380
  const output = new PassThrough()