incur 0.4.8 → 0.4.9

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 (88) hide show
  1. package/dist/Cli.d.ts +65 -19
  2. package/dist/Cli.d.ts.map +1 -1
  3. package/dist/Cli.js +294 -66
  4. package/dist/Cli.js.map +1 -1
  5. package/dist/Completions.d.ts +2 -1
  6. package/dist/Completions.d.ts.map +1 -1
  7. package/dist/Completions.js +52 -13
  8. package/dist/Completions.js.map +1 -1
  9. package/dist/Formatter.d.ts.map +1 -1
  10. package/dist/Formatter.js +6 -5
  11. package/dist/Formatter.js.map +1 -1
  12. package/dist/Help.d.ts +5 -0
  13. package/dist/Help.d.ts.map +1 -1
  14. package/dist/Help.js +19 -5
  15. package/dist/Help.js.map +1 -1
  16. package/dist/Mcp.d.ts +21 -0
  17. package/dist/Mcp.d.ts.map +1 -1
  18. package/dist/Mcp.js +25 -8
  19. package/dist/Mcp.js.map +1 -1
  20. package/dist/Parser.d.ts +14 -0
  21. package/dist/Parser.d.ts.map +1 -1
  22. package/dist/Parser.js +127 -1
  23. package/dist/Parser.js.map +1 -1
  24. package/dist/Skill.d.ts.map +1 -1
  25. package/dist/Skill.js +5 -2
  26. package/dist/Skill.js.map +1 -1
  27. package/dist/Skillgen.d.ts.map +1 -1
  28. package/dist/Skillgen.js +4 -38
  29. package/dist/Skillgen.js.map +1 -1
  30. package/dist/SyncMcp.d.ts.map +1 -1
  31. package/dist/SyncMcp.js +75 -8
  32. package/dist/SyncMcp.js.map +1 -1
  33. package/dist/SyncSkills.d.ts +9 -0
  34. package/dist/SyncSkills.d.ts.map +1 -1
  35. package/dist/SyncSkills.js +13 -74
  36. package/dist/SyncSkills.js.map +1 -1
  37. package/dist/bin.d.ts +1 -1
  38. package/dist/bin.d.ts.map +1 -1
  39. package/dist/internal/command.d.ts +2 -0
  40. package/dist/internal/command.d.ts.map +1 -1
  41. package/dist/internal/command.js +6 -5
  42. package/dist/internal/command.js.map +1 -1
  43. package/dist/internal/cta.d.ts +22 -0
  44. package/dist/internal/cta.d.ts.map +1 -0
  45. package/dist/internal/cta.js +25 -0
  46. package/dist/internal/cta.js.map +1 -0
  47. package/dist/internal/json.d.ts +5 -0
  48. package/dist/internal/json.d.ts.map +1 -0
  49. package/dist/internal/json.js +13 -0
  50. package/dist/internal/json.js.map +1 -0
  51. package/dist/internal/yaml.d.ts +12 -0
  52. package/dist/internal/yaml.d.ts.map +1 -0
  53. package/dist/internal/yaml.js +20 -0
  54. package/dist/internal/yaml.js.map +1 -0
  55. package/dist/middleware.d.ts +8 -4
  56. package/dist/middleware.d.ts.map +1 -1
  57. package/dist/middleware.js +1 -1
  58. package/dist/middleware.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/Cli.test-d.ts +71 -0
  61. package/src/Cli.test.ts +1164 -65
  62. package/src/Cli.ts +456 -101
  63. package/src/Completions.test.ts +84 -0
  64. package/src/Completions.ts +48 -9
  65. package/src/Formatter.test.ts +17 -0
  66. package/src/Formatter.ts +7 -5
  67. package/src/Help.test.ts +31 -0
  68. package/src/Help.ts +42 -4
  69. package/src/Mcp.test.ts +193 -0
  70. package/src/Mcp.ts +49 -8
  71. package/src/Parser.test.ts +173 -0
  72. package/src/Parser.ts +132 -1
  73. package/src/Skill.test.ts +6 -0
  74. package/src/Skill.ts +8 -5
  75. package/src/Skillgen.test.ts +55 -6
  76. package/src/Skillgen.ts +9 -35
  77. package/src/SyncMcp.test.ts +89 -0
  78. package/src/SyncMcp.ts +72 -8
  79. package/src/SyncSkills.test.ts +78 -1
  80. package/src/SyncSkills.ts +20 -78
  81. package/src/bun-compile.test.ts +5 -0
  82. package/src/e2e.test.ts +162 -8
  83. package/src/internal/command.ts +8 -4
  84. package/src/internal/cta.ts +58 -0
  85. package/src/internal/json.ts +16 -0
  86. package/src/internal/yaml.ts +23 -0
  87. package/src/lazy-imports.test.ts +94 -0
  88. package/src/middleware.ts +12 -3
package/src/Mcp.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'
2
1
  import type { Readable, Writable } from 'node:stream'
3
2
  import { z } from 'zod'
4
3
 
5
4
  import * as Command from './internal/command.js'
5
+ import { formatCtaBlock, type FormattedCtaBlock } from './internal/cta.js'
6
+ import * as Json from './internal/json.js'
6
7
  import type { Handler as MiddlewareHandler } from './middleware.js'
7
8
  import * as Schema from './Schema.js'
8
9
 
@@ -13,7 +14,14 @@ export async function serve(
13
14
  commands: Map<string, any>,
14
15
  options: serve.Options = {},
15
16
  ): Promise<void> {
16
- const server = new McpServer({ name, version })
17
+ // Lazy: only runs when actually serving MCP, so plain command runs don't pay for the SDK import.
18
+ const { fromJsonSchema, McpServer, StdioServerTransport } =
19
+ await import('@modelcontextprotocol/server')
20
+
21
+ const server = new McpServer(
22
+ { name, version },
23
+ options.instructions ? { instructions: options.instructions } : undefined,
24
+ )
17
25
 
18
26
  for (const tool of collectTools(commands, [])) {
19
27
  const mergedShape: Record<string, any> = {
@@ -27,7 +35,9 @@ export async function serve(
27
35
  {
28
36
  ...(tool.description ? { description: tool.description } : undefined),
29
37
  ...(hasInput ? { inputSchema: z.object(mergedShape) } : undefined),
30
- ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : undefined),
38
+ ...(tool.outputSchema ? { outputSchema: fromJsonSchema(tool.outputSchema) } : undefined),
39
+ ...(tool.annotations ? { annotations: tool.annotations } : undefined),
40
+ ...(tool.instructions ? { _meta: { instructions: tool.instructions } } : undefined),
31
41
  } as never,
32
42
  async (...callArgs: any[]) => {
33
43
  // registerTool passes (args, extra) when inputSchema is set, (extra) when not
@@ -67,6 +77,8 @@ export declare namespace serve {
67
77
  vars?: z.ZodObject<any> | undefined
68
78
  /** CLI version string. */
69
79
  version?: string | undefined
80
+ /** Instructions describing how to use the server and its features. */
81
+ instructions?: string | undefined
70
82
  }
71
83
  }
72
84
 
@@ -88,6 +100,7 @@ export async function callTool(
88
100
  ): Promise<{
89
101
  content: { type: 'text'; text: string }[]
90
102
  structuredContent?: Record<string, unknown>
103
+ _meta?: { cta: FormattedCtaBlock } | undefined
91
104
  isError?: boolean
92
105
  }> {
93
106
  const allMiddleware = [
@@ -122,7 +135,7 @@ export async function callTool(
122
135
  if (progressToken !== undefined && options.sendNotification)
123
136
  await options.sendNotification({
124
137
  method: 'notifications/progress' as const,
125
- params: { progressToken, progress: ++i, message: JSON.stringify(chunk) },
138
+ params: { progressToken, progress: ++i, message: Json.stringify(chunk) },
126
139
  })
127
140
  }
128
141
  } catch (err) {
@@ -131,21 +144,31 @@ export async function callTool(
131
144
  isError: true,
132
145
  }
133
146
  }
134
- return { content: [{ type: 'text', text: JSON.stringify(chunks) }] }
147
+ return { content: [{ type: 'text', text: Json.stringify(chunks) }] }
135
148
  }
136
149
 
137
150
  if (!result.ok)
138
151
  return {
139
- content: [{ type: 'text', text: result.error.message ?? 'Command failed' }],
152
+ content: [
153
+ {
154
+ type: 'text',
155
+ text: result.error.fieldErrors
156
+ ? JSON.stringify(result.error)
157
+ : (result.error.message ?? 'Command failed'),
158
+ },
159
+ ],
140
160
  isError: true,
141
161
  }
142
162
 
143
163
  const data = result.data ?? null
164
+ const jsonData = Json.normalize(data)
165
+ const cta = formatCtaBlock(options.name ?? tool.name, result.cta as Command.CtaBlock | undefined)
144
166
  return {
145
- content: [{ type: 'text', text: JSON.stringify(data) }],
167
+ content: [{ type: 'text', text: Json.stringify(jsonData) }],
146
168
  ...(data !== null && tool.outputSchema
147
- ? { structuredContent: data as Record<string, unknown> }
169
+ ? { structuredContent: jsonData as Record<string, unknown> }
148
170
  : undefined),
171
+ ...(cta ? { _meta: { cta } } : undefined),
149
172
  }
150
173
  }
151
174
 
@@ -161,10 +184,26 @@ export type ToolEntry = {
161
184
  description?: string | undefined
162
185
  inputSchema: { type: 'object'; properties: Record<string, unknown>; required?: string[] }
163
186
  outputSchema?: Record<string, unknown> | undefined
187
+ annotations?: ToolAnnotations | undefined
188
+ instructions?: string | undefined
164
189
  command: any
165
190
  middlewares?: MiddlewareHandler[] | undefined
166
191
  }
167
192
 
193
+ /** MCP tool annotations that describe tool behavior to clients. */
194
+ export type ToolAnnotations = {
195
+ /** A human-readable title for the tool. */
196
+ title?: string | undefined
197
+ /** If true, the tool does not modify its environment. Default: false. */
198
+ readOnlyHint?: boolean | undefined
199
+ /** If true, the tool may perform destructive updates to its environment. Meaningful only when readOnlyHint is false. Default: true. */
200
+ destructiveHint?: boolean | undefined
201
+ /** If true, calling the tool repeatedly with the same arguments has no additional effect. Meaningful only when readOnlyHint is false. Default: false. */
202
+ idempotentHint?: boolean | undefined
203
+ /** If true, the tool may interact with an open world of external entities. Default: true. */
204
+ openWorldHint?: boolean | undefined
205
+ }
206
+
168
207
  /** @internal Recursively collects leaf commands as tool entries. */
169
208
  export function collectTools(
170
209
  commands: Map<string, any>,
@@ -189,6 +228,8 @@ export function collectTools(
189
228
  ...(entry.output
190
229
  ? { outputSchema: Schema.toJsonSchema(entry.output) as Record<string, unknown> }
191
230
  : undefined),
231
+ ...(entry.mcp?.annotations ? { annotations: entry.mcp.annotations } : undefined),
232
+ ...(entry.mcp?.instructions ? { instructions: entry.mcp.instructions } : undefined),
192
233
  command: entry,
193
234
  ...(parentMiddlewares.length > 0 ? { middlewares: parentMiddlewares } : undefined),
194
235
  })
@@ -376,3 +376,176 @@ describe('parse', () => {
376
376
  expect(result.options).toEqual({ min: 1, max: 3 })
377
377
  })
378
378
  })
379
+
380
+ describe('parseGlobals', () => {
381
+ test('extracts known globals and returns rest', () => {
382
+ const schema = z.object({ rpcUrl: z.string() })
383
+ const result = Parser.parseGlobals(['--rpc-url', 'http://example.com', 'deploy'], schema)
384
+ expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' })
385
+ expect(result.rest).toEqual(['deploy'])
386
+ })
387
+
388
+ test('unknown flags pass through to rest', () => {
389
+ const schema = z.object({ rpcUrl: z.string() })
390
+ const result = Parser.parseGlobals(
391
+ ['--rpc-url', 'http://example.com', '--unknown', 'val', 'deploy'],
392
+ schema,
393
+ )
394
+ expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' })
395
+ expect(result.rest).toEqual(['--unknown', 'val', 'deploy'])
396
+ })
397
+
398
+ test('handles --flag=value syntax', () => {
399
+ const schema = z.object({ rpcUrl: z.string() })
400
+ const result = Parser.parseGlobals(['--rpc-url=http://example.com', 'deploy'], schema)
401
+ expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' })
402
+ expect(result.rest).toEqual(['deploy'])
403
+ })
404
+
405
+ test('handles short aliases', () => {
406
+ const schema = z.object({ rpcUrl: z.string() })
407
+ const result = Parser.parseGlobals(
408
+ ['-r', 'http://example.com', 'deploy'],
409
+ schema,
410
+ { rpcUrl: 'r' },
411
+ )
412
+ expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' })
413
+ expect(result.rest).toEqual(['deploy'])
414
+ })
415
+
416
+ test('handles boolean globals', () => {
417
+ const schema = z.object({ verbose: z.boolean().default(false) })
418
+ const result = Parser.parseGlobals(['--verbose', 'deploy'], schema)
419
+ expect(result.parsed).toEqual({ verbose: true })
420
+ expect(result.rest).toEqual(['deploy'])
421
+ })
422
+
423
+ test('validates against schema', () => {
424
+ const schema = z.object({ count: z.number() })
425
+ expect(() => Parser.parseGlobals(['--count', 'not-a-number'], schema)).toThrow()
426
+ })
427
+
428
+ test('coerces string to number', () => {
429
+ const schema = z.object({ limit: z.number() })
430
+ const result = Parser.parseGlobals(['--limit', '42', 'deploy'], schema)
431
+ expect(result.parsed).toEqual({ limit: 42 })
432
+ expect(result.rest).toEqual(['deploy'])
433
+ })
434
+
435
+ test('positionals pass through to rest', () => {
436
+ const schema = z.object({ verbose: z.boolean().default(false) })
437
+ const result = Parser.parseGlobals(['deploy', 'contract', '--verbose'], schema)
438
+ expect(result.parsed).toEqual({ verbose: true })
439
+ expect(result.rest).toEqual(['deploy', 'contract'])
440
+ })
441
+
442
+ test('-- separator: everything after -- passes through to rest including the --', () => {
443
+ const schema = z.object({ verbose: z.boolean().default(false) })
444
+ const result = Parser.parseGlobals(
445
+ ['--verbose', '--', '--unknown', 'positional', '--also-unknown'],
446
+ schema,
447
+ )
448
+ expect(result.parsed).toEqual({ verbose: true })
449
+ expect(result.rest).toEqual(['--', '--unknown', 'positional', '--also-unknown'])
450
+ })
451
+
452
+ test('stacked short aliases: -rv where both are known boolean globals', () => {
453
+ const schema = z.object({
454
+ recursive: z.boolean().default(false),
455
+ verbose: z.boolean().default(false),
456
+ })
457
+ const result = Parser.parseGlobals(['-rv', 'deploy'], schema, {
458
+ recursive: 'r',
459
+ verbose: 'v',
460
+ })
461
+ expect(result.parsed).toEqual({ recursive: true, verbose: true })
462
+ expect(result.rest).toEqual(['deploy'])
463
+ })
464
+
465
+ test('count options: --verbose --verbose accumulates', () => {
466
+ const schema = z.object({ verbose: z.number().default(0).meta({ count: true }) })
467
+ const result = Parser.parseGlobals(['--verbose', '--verbose', 'deploy'], schema)
468
+ expect(result.parsed).toEqual({ verbose: 2 })
469
+ expect(result.rest).toEqual(['deploy'])
470
+ })
471
+
472
+ test('array options: --tag foo --tag bar collects into array', () => {
473
+ const schema = z.object({ tag: z.array(z.string()).default([]) })
474
+ const result = Parser.parseGlobals(['--tag', 'foo', '--tag', 'bar', 'deploy'], schema)
475
+ expect(result.parsed).toEqual({ tag: ['foo', 'bar'] })
476
+ expect(result.rest).toEqual(['deploy'])
477
+ })
478
+
479
+ test('unknown --no-* flags pass through to rest', () => {
480
+ const schema = z.object({ verbose: z.boolean().default(false) })
481
+ const result = Parser.parseGlobals(['--no-color', '--verbose'], schema)
482
+ expect(result.parsed).toEqual({ verbose: true })
483
+ expect(result.rest).toEqual(['--no-color'])
484
+ })
485
+
486
+ test('unknown --flag=value passes through as single token', () => {
487
+ const schema = z.object({ verbose: z.boolean().default(false) })
488
+ const result = Parser.parseGlobals(['--output=json', '--verbose'], schema)
489
+ expect(result.parsed).toEqual({ verbose: true })
490
+ expect(result.rest).toEqual(['--output=json'])
491
+ })
492
+
493
+ test('missing value for known flag throws ParseError', () => {
494
+ const schema = z.object({ rpcUrl: z.string() })
495
+ expect(() => Parser.parseGlobals(['--rpc-url'], schema)).toThrow(
496
+ expect.objectContaining({ name: 'Incur.ParseError' }),
497
+ )
498
+ })
499
+
500
+ test('stacked short: count in non-last position', () => {
501
+ const schema = z.object({
502
+ verbose: z.number().default(0).meta({ count: true }),
503
+ recursive: z.boolean().default(false),
504
+ })
505
+ const result = Parser.parseGlobals(['-vr'], schema, { verbose: 'v', recursive: 'r' })
506
+ expect(result.parsed).toEqual({ verbose: 1, recursive: true })
507
+ })
508
+
509
+ test('stacked short: non-boolean in non-last position throws', () => {
510
+ const schema = z.object({
511
+ output: z.string(),
512
+ verbose: z.boolean().default(false),
513
+ })
514
+ expect(() => Parser.parseGlobals(['-ov', 'file'], schema, { output: 'o', verbose: 'v' })).toThrow(
515
+ /must be last/,
516
+ )
517
+ })
518
+
519
+ test('short flag value-taking as last in stacked alias', () => {
520
+ const schema = z.object({
521
+ verbose: z.boolean().default(false),
522
+ output: z.string(),
523
+ })
524
+ const result = Parser.parseGlobals(['-vo', 'file', 'deploy'], schema, {
525
+ verbose: 'v',
526
+ output: 'o',
527
+ })
528
+ expect(result.parsed).toEqual({ verbose: true, output: 'file' })
529
+ expect(result.rest).toEqual(['deploy'])
530
+ })
531
+
532
+ test('short flag missing value throws ParseError', () => {
533
+ const schema = z.object({ output: z.string() })
534
+ expect(() => Parser.parseGlobals(['-o'], schema, { output: 'o' })).toThrow(
535
+ expect.objectContaining({ name: 'Incur.ParseError' }),
536
+ )
537
+ })
538
+
539
+ test('known --no- negation for boolean global', () => {
540
+ const schema = z.object({ verbose: z.boolean().default(true) })
541
+ const result = Parser.parseGlobals(['--no-verbose', 'deploy'], schema)
542
+ expect(result.parsed).toEqual({ verbose: false })
543
+ expect(result.rest).toEqual(['deploy'])
544
+ })
545
+
546
+ test('known --flag=value with setOption', () => {
547
+ const schema = z.object({ tag: z.array(z.string()).default([]) })
548
+ const result = Parser.parseGlobals(['--tag=foo', '--tag=bar'], schema)
549
+ expect(result.parsed).toEqual({ tag: ['foo', 'bar'] })
550
+ })
551
+ })
package/src/Parser.ts CHANGED
@@ -257,7 +257,7 @@ function setOption(
257
257
  }
258
258
 
259
259
  /** Wraps zod schema.parse(), converting ZodError to ValidationError. */
260
- function zodParse(schema: z.ZodObject<any>, data: Record<string, unknown>) {
260
+ export function zodParse(schema: z.ZodObject<any>, data: Record<string, unknown>) {
261
261
  try {
262
262
  return schema.parse(data)
263
263
  } catch (err: any) {
@@ -330,6 +330,137 @@ function coerce(value: unknown, name: string, schema: z.ZodObject<any>): unknown
330
330
  return value
331
331
  }
332
332
 
333
+ /** Parses known global options from argv, passing unknown flags and positionals through to `rest`. */
334
+ export function parseGlobals<const globals extends z.ZodObject<any>>(
335
+ argv: string[],
336
+ schema: globals,
337
+ alias?: Record<string, string>,
338
+ options: parseGlobals.Options = {},
339
+ ): { parsed: z.output<globals>; rest: string[] } {
340
+ const optionNames = createOptionNames(schema, alias)
341
+
342
+ const rest: string[] = []
343
+ const rawOptions: Record<string, unknown> = {}
344
+
345
+ let i = 0
346
+ while (i < argv.length) {
347
+ const token = argv[i]!
348
+
349
+ if (token === '--') {
350
+ for (let j = i; j < argv.length; j++) rest.push(argv[j]!)
351
+ break
352
+ }
353
+
354
+ if (token.startsWith('--no-') && token.length > 5) {
355
+ const name = normalizeOptionName(token.slice(5), optionNames)
356
+ if (!name) {
357
+ rest.push(token)
358
+ } else {
359
+ rawOptions[name] = false
360
+ }
361
+ i++
362
+ } else if (token.startsWith('--')) {
363
+ const eqIdx = token.indexOf('=')
364
+ if (eqIdx !== -1) {
365
+ // --flag=value
366
+ const raw = token.slice(2, eqIdx)
367
+ const name = normalizeOptionName(raw, optionNames)
368
+ if (!name) {
369
+ rest.push(token)
370
+ } else {
371
+ setOption(rawOptions, name, token.slice(eqIdx + 1), schema)
372
+ }
373
+ i++
374
+ } else {
375
+ // --flag [value]
376
+ const name = normalizeOptionName(token.slice(2), optionNames)
377
+ if (!name) {
378
+ // Unknown flag — pass through as-is
379
+ rest.push(token)
380
+ i++
381
+ } else if (isCountOption(name, schema)) {
382
+ rawOptions[name] = ((rawOptions[name] as number) ?? 0) + 1
383
+ i++
384
+ } else if (isBooleanOption(name, schema)) {
385
+ rawOptions[name] = true
386
+ i++
387
+ } else {
388
+ const value = argv[i + 1]
389
+ if (value === undefined)
390
+ throw new ParseError({ message: `Missing value for flag: ${token}` })
391
+ setOption(rawOptions, name, value, schema)
392
+ i += 2
393
+ }
394
+ }
395
+ } else if (token.startsWith('-') && !token.startsWith('--') && token.length >= 2) {
396
+ // Short flag(s)
397
+ const chars = token.slice(1)
398
+ let allKnown = true
399
+ for (let j = 0; j < chars.length; j++) {
400
+ if (!optionNames.aliasToName.has(chars[j]!)) {
401
+ allKnown = false
402
+ break
403
+ }
404
+ }
405
+
406
+ if (!allKnown) {
407
+ // Unknown short flag — pass through as-is
408
+ rest.push(token)
409
+ i++
410
+ } else {
411
+ for (let j = 0; j < chars.length; j++) {
412
+ const short = chars[j]!
413
+ const name = optionNames.aliasToName.get(short)!
414
+ const isLast = j === chars.length - 1
415
+ if (!isLast) {
416
+ if (isCountOption(name, schema)) {
417
+ rawOptions[name] = ((rawOptions[name] as number) ?? 0) + 1
418
+ } else if (isBooleanOption(name, schema)) {
419
+ rawOptions[name] = true
420
+ } else {
421
+ throw new ParseError({
422
+ message: `Non-boolean flag -${short} must be last in a stacked alias`,
423
+ })
424
+ }
425
+ } else if (isCountOption(name, schema)) {
426
+ rawOptions[name] = ((rawOptions[name] as number) ?? 0) + 1
427
+ } else if (isBooleanOption(name, schema)) {
428
+ rawOptions[name] = true
429
+ } else {
430
+ const value = argv[i + 1]
431
+ if (value === undefined)
432
+ throw new ParseError({ message: `Missing value for flag: -${short}` })
433
+ setOption(rawOptions, name, value, schema)
434
+ i++
435
+ }
436
+ }
437
+ i++
438
+ }
439
+ } else {
440
+ // Positional — pass through
441
+ rest.push(token)
442
+ i++
443
+ }
444
+ }
445
+
446
+ if (options.validate === false) return { parsed: rawOptions as z.output<globals>, rest }
447
+
448
+ // Coerce raw option values before zod validation
449
+ for (const [name, value] of Object.entries(rawOptions))
450
+ rawOptions[name] = coerce(value, name, schema)
451
+
452
+ const parsed = zodParse(schema, rawOptions) as z.output<globals>
453
+ return { parsed, rest }
454
+ }
455
+
456
+ export declare namespace parseGlobals {
457
+ /** Options for parsing global flags. */
458
+ type Options = {
459
+ /** Whether to validate parsed globals against the schema. */
460
+ validate?: boolean | undefined
461
+ }
462
+ }
463
+
333
464
  /** Returns the best available env source for the current runtime. */
334
465
  export function defaultEnvSource(): Record<string, string | undefined> {
335
466
  if (typeof globalThis !== 'undefined') {
package/src/Skill.test.ts CHANGED
@@ -204,6 +204,12 @@ describe('hash', () => {
204
204
  expect(a).not.toBe(b)
205
205
  })
206
206
 
207
+ test('changes when hint changes', () => {
208
+ const a = Skill.hash([{ name: 'ping', hint: 'Read status.' }])
209
+ const b = Skill.hash([{ name: 'ping', hint: 'Delete status.' }])
210
+ expect(a).not.toBe(b)
211
+ })
212
+
207
213
  test('changes when schema changes', () => {
208
214
  const a = Skill.hash([{ name: 'greet', args: z.object({ name: z.string() }) }])
209
215
  const b = Skill.hash([{ name: 'greet', args: z.object({ name: z.string(), age: z.number() }) }])
package/src/Skill.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { stringify as yamlStringify } from 'yaml'
3
2
  import type { z } from 'zod'
4
3
 
4
+ import * as Yaml from './internal/yaml.js'
5
5
  import * as Schema from './Schema.js'
6
6
 
7
7
  /** Information about a single command, passed to `generate()`. */
@@ -137,10 +137,12 @@ function renderGroup(
137
137
  ? `${desc.replace(/\.$/, '')}. Run \`${title} --help\` for usage details.`
138
138
  : `Run \`${title} --help\` for usage details.`
139
139
 
140
- const fm = yamlStringify(
141
- { name: slugify(title), description, requires_bin: cli, command: title },
142
- { lineWidth: 0 },
143
- ).trimEnd()
140
+ const fm = Yaml.loadSync()
141
+ .stringify(
142
+ { name: slugify(title), description, requires_bin: cli, command: title },
143
+ { lineWidth: 0 },
144
+ )
145
+ .trimEnd()
144
146
  const fmBlock = `---\n${fm}\n---`
145
147
 
146
148
  const body = cmds.map((cmd) => renderCommandBody(cli, cmd)).join('\n\n---\n\n')
@@ -245,6 +247,7 @@ export function hash(commands: CommandInfo[]): string {
245
247
  const data = commands.map((cmd) => ({
246
248
  name: cmd.name,
247
249
  description: cmd.description,
250
+ hint: cmd.hint,
248
251
  args: cmd.args ? Schema.toJsonSchema(cmd.args) : undefined,
249
252
  env: cmd.env ? Schema.toJsonSchema(cmd.env) : undefined,
250
253
  options: cmd.options ? Schema.toJsonSchema(cmd.options) : undefined,
@@ -60,13 +60,20 @@ test('collects group descriptions', async () => {
60
60
  test('includes args, options, and examples in output', async () => {
61
61
  const cli = Cli.create('tool', {
62
62
  description: 'A tool',
63
- }).command('greet', {
64
- description: 'Greet someone',
65
- args: z.object({ name: z.string().describe('Name to greet') }),
66
- options: z.object({ loud: z.boolean().default(false).describe('Shout') }),
67
- examples: [{ args: { name: 'world' }, description: 'Greet the world' }],
68
- run: () => ({}),
69
63
  })
64
+ .command('greet', {
65
+ description: 'Greet someone',
66
+ aliases: ['hi'],
67
+ args: z.object({ name: z.string().describe('Name to greet') }),
68
+ options: z.object({ loud: z.boolean().default(false).describe('Shout') }),
69
+ output: z.object({ message: z.string() }),
70
+ examples: [{ args: { name: 'world' }, description: 'Greet the world' }],
71
+ run: () => ({ message: 'hi' }),
72
+ })
73
+ .command('api', {
74
+ description: 'Proxy API',
75
+ fetch: () => new Response('{}'),
76
+ })
70
77
  vi.mocked(importCli).mockResolvedValue(cli)
71
78
 
72
79
  const files = await generate('fake-input', tmp, 0)
@@ -74,4 +81,46 @@ test('includes args, options, and examples in output', async () => {
74
81
  expect(content).toContain('Name to greet')
75
82
  expect(content).toContain('Shout')
76
83
  expect(content).toContain('Greet the world')
84
+ expect(content).toContain('## Output')
85
+ expect(content).toContain('Fetch gateway. Pass path segments')
86
+ expect(content).not.toContain('# tool hi')
87
+ })
88
+
89
+ test('appends confirmation hint for destructive commands', async () => {
90
+ const cli = Cli.create('tool')
91
+ .command('destroy', {
92
+ description: 'Destroy data',
93
+ destructive: true,
94
+ hint: 'Deletes all data.',
95
+ run: () => ({}),
96
+ })
97
+ .command('status', {
98
+ description: 'Check status',
99
+ hint: 'Shows current status.',
100
+ run: () => ({}),
101
+ })
102
+ vi.mocked(importCli).mockResolvedValue(cli)
103
+
104
+ const files = await generate('fake-input', tmp, 0)
105
+ const content = readFileSync(files[0]!, 'utf-8')
106
+ expect(content).toContain(
107
+ 'Deletes all data. Confirm with the user before executing this destructive command.',
108
+ )
109
+ expect(content).toContain('Shows current status.')
110
+ expect(content).not.toContain(
111
+ 'Shows current status. Confirm with the user before executing this destructive command.',
112
+ )
113
+ })
114
+
115
+ test('uses MCP destructiveHint when generating skill files', async () => {
116
+ const cli = Cli.create('tool').command('deploy', {
117
+ description: 'Deploy',
118
+ mcp: { annotations: { destructiveHint: true } },
119
+ run: () => ({}),
120
+ })
121
+ vi.mocked(importCli).mockResolvedValue(cli)
122
+
123
+ const files = await generate('fake-input', tmp, 0)
124
+ const content = readFileSync(files[0]!, 'utf-8')
125
+ expect(content).toContain('Confirm with the user before executing this destructive command.')
77
126
  })
package/src/Skillgen.ts CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path'
3
3
 
4
4
  import * as Cli from './Cli.js'
5
5
  import { importCli } from './internal/utils.js'
6
+ import * as Yaml from './internal/yaml.js'
6
7
  import * as Skill from './Skill.js'
7
8
 
8
9
  /** Imports a CLI from `input`, generates Markdown skill files, and writes them to `output`. */
@@ -13,7 +14,14 @@ export async function generate(input: string, output: string, depth = 1): Promis
13
14
 
14
15
  const groups = new Map<string, string>()
15
16
  if (cli.description) groups.set(cli.name, cli.description)
16
- const entries = collectEntries(commands, [], groups)
17
+ const entries = Cli.collectSkillCommands(
18
+ commands,
19
+ [],
20
+ groups,
21
+ Cli.toRootDefinition.get(cli as unknown as Cli.Root),
22
+ )
23
+ // Pre-load yaml for `Skill.split`'s sync call path.
24
+ await Yaml.load()
17
25
  const files = Skill.split(cli.name, entries, depth, groups)
18
26
 
19
27
  if (depth > 0) await fs.rm(output, { recursive: true, force: true })
@@ -30,37 +38,3 @@ export async function generate(input: string, output: string, depth = 1): Promis
30
38
 
31
39
  return written
32
40
  }
33
-
34
- /** Recursively collects leaf commands as `Skill.CommandInfo` and group descriptions. */
35
- function collectEntries(
36
- commands: Map<string, any>,
37
- prefix: string[],
38
- groups: Map<string, string> = new Map(),
39
- ): Skill.CommandInfo[] {
40
- const result: Skill.CommandInfo[] = []
41
- for (const [name, entry] of commands) {
42
- const path = [...prefix, name]
43
- if ('_group' in entry && entry._group) {
44
- if (entry.description) groups.set(path.join(' '), entry.description)
45
- result.push(...collectEntries(entry.commands, path, groups))
46
- } else {
47
- const cmd: Skill.CommandInfo = { name: path.join(' ') }
48
- if (entry.description) cmd.description = entry.description
49
- if (entry.args) cmd.args = entry.args
50
- if (entry.env) cmd.env = entry.env
51
- if (entry.hint) cmd.hint = entry.hint
52
- if (entry.options) cmd.options = entry.options
53
- if (entry.output) cmd.output = entry.output
54
- const examples = Cli.formatExamples(entry.examples)
55
- if (examples) {
56
- const cmdName = path.join(' ')
57
- cmd.examples = examples.map((e) => ({
58
- ...e,
59
- command: e.command ? `${cmdName} ${e.command}` : cmdName,
60
- }))
61
- }
62
- result.push(cmd)
63
- }
64
- }
65
- return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
66
- }