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/Parser.ts CHANGED
@@ -2,29 +2,20 @@ import type { z } from 'zod'
2
2
 
3
3
  import type { FieldError } from './Errors.js'
4
4
  import { ParseError, ValidationError } from './Errors.js'
5
+ import { isRecord, toKebab } from './internal/helpers.js'
5
6
 
6
7
  /** Parses raw argv tokens against Zod schemas for args and options. */
7
8
  export function parse<
8
9
  const args extends z.ZodObject<any> | undefined = undefined,
9
10
  const options extends z.ZodObject<any> | undefined = undefined,
10
11
  >(argv: string[], options: parse.Options<args, options> = {}): parse.ReturnType<args, options> {
11
- const { args: argsSchema, options: optionsSchema, alias } = options
12
+ const { args: argsSchema, options: optionsSchema, alias, defaults } = options
12
13
 
13
- // Build reverse alias map: short char → long name
14
- const aliasToName = new Map<string, string>()
15
- if (alias) for (const [name, short] of Object.entries(alias)) aliasToName.set(short, name)
16
-
17
- // Known option names from schema, plus kebab-case → camelCase map
18
- const knownOptions = new Set(optionsSchema ? Object.keys(optionsSchema.shape) : [])
19
- const kebabToCamel = new Map<string, string>()
20
- for (const name of knownOptions) {
21
- const kebab = name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
22
- if (kebab !== name) kebabToCamel.set(kebab, name)
23
- }
14
+ const optionNames = createOptionNames(optionsSchema, alias)
24
15
 
25
16
  // First pass: split argv into positional tokens and raw option values
26
17
  const positionals: string[] = []
27
- const rawOptions: Record<string, unknown> = {}
18
+ const rawArgvOptions: Record<string, unknown> = {}
28
19
 
29
20
  let i = 0
30
21
  while (i < argv.length) {
@@ -32,36 +23,34 @@ export function parse<
32
23
 
33
24
  if (token.startsWith('--no-') && token.length > 5) {
34
25
  // --no-flag negation
35
- const raw = token.slice(5)
36
- const name = kebabToCamel.get(raw) ?? raw
37
- if (!knownOptions.has(name)) throw new ParseError({ message: `Unknown flag: ${token}` })
38
- rawOptions[name] = false
26
+ const name = normalizeOptionName(token.slice(5), optionNames)
27
+ if (!name) throw new ParseError({ message: `Unknown flag: ${token}` })
28
+ rawArgvOptions[name] = false
39
29
  i++
40
30
  } else if (token.startsWith('--')) {
41
31
  const eqIdx = token.indexOf('=')
42
32
  if (eqIdx !== -1) {
43
33
  // --flag=value
44
34
  const raw = token.slice(2, eqIdx)
45
- const name = kebabToCamel.get(raw) ?? raw
46
- if (!knownOptions.has(name)) throw new ParseError({ message: `Unknown flag: --${raw}` })
47
- setOption(rawOptions, name, token.slice(eqIdx + 1), optionsSchema)
35
+ const name = normalizeOptionName(raw, optionNames)
36
+ if (!name) throw new ParseError({ message: `Unknown flag: --${raw}` })
37
+ setOption(rawArgvOptions, name, token.slice(eqIdx + 1), optionsSchema)
48
38
  i++
49
39
  } else {
50
40
  // --flag [value]
51
- const raw = token.slice(2)
52
- const name = kebabToCamel.get(raw) ?? raw
53
- if (!knownOptions.has(name)) throw new ParseError({ message: `Unknown flag: ${token}` })
41
+ const name = normalizeOptionName(token.slice(2), optionNames)
42
+ if (!name) throw new ParseError({ message: `Unknown flag: ${token}` })
54
43
  if (isCountOption(name, optionsSchema)) {
55
- rawOptions[name] = ((rawOptions[name] as number) ?? 0) + 1
44
+ rawArgvOptions[name] = ((rawArgvOptions[name] as number) ?? 0) + 1
56
45
  i++
57
46
  } else if (isBooleanOption(name, optionsSchema)) {
58
- rawOptions[name] = true
47
+ rawArgvOptions[name] = true
59
48
  i++
60
49
  } else {
61
50
  const value = argv[i + 1]
62
51
  if (value === undefined)
63
52
  throw new ParseError({ message: `Missing value for flag: ${token}` })
64
- setOption(rawOptions, name, value, optionsSchema)
53
+ setOption(rawArgvOptions, name, value, optionsSchema)
65
54
  i += 2
66
55
  }
67
56
  }
@@ -70,28 +59,28 @@ export function parse<
70
59
  const chars = token.slice(1)
71
60
  for (let j = 0; j < chars.length; j++) {
72
61
  const short = chars[j]!
73
- const name = aliasToName.get(short)
62
+ const name = optionNames.aliasToName.get(short)
74
63
  if (!name) throw new ParseError({ message: `Unknown flag: -${short}` })
75
64
  const isLast = j === chars.length - 1
76
65
  if (!isLast) {
77
66
  if (isCountOption(name, optionsSchema)) {
78
- rawOptions[name] = ((rawOptions[name] as number) ?? 0) + 1
67
+ rawArgvOptions[name] = ((rawArgvOptions[name] as number) ?? 0) + 1
79
68
  } else if (isBooleanOption(name, optionsSchema)) {
80
- rawOptions[name] = true
69
+ rawArgvOptions[name] = true
81
70
  } else {
82
71
  throw new ParseError({
83
72
  message: `Non-boolean flag -${short} must be last in a stacked alias`,
84
73
  })
85
74
  }
86
75
  } else if (isCountOption(name, optionsSchema)) {
87
- rawOptions[name] = ((rawOptions[name] as number) ?? 0) + 1
76
+ rawArgvOptions[name] = ((rawArgvOptions[name] as number) ?? 0) + 1
88
77
  } else if (isBooleanOption(name, optionsSchema)) {
89
- rawOptions[name] = true
78
+ rawArgvOptions[name] = true
90
79
  } else {
91
80
  const value = argv[i + 1]
92
81
  if (value === undefined)
93
82
  throw new ParseError({ message: `Missing value for flag: -${short}` })
94
- setOption(rawOptions, name, value, optionsSchema)
83
+ setOption(rawArgvOptions, name, value, optionsSchema)
95
84
  i++
96
85
  }
97
86
  }
@@ -117,15 +106,19 @@ export function parse<
117
106
  // Validate args through zod
118
107
  const args = argsSchema ? zodParse(argsSchema, rawArgs) : {}
119
108
 
109
+ const rawDefaults = normalizeOptionDefaults(defaults, optionsSchema, optionNames)
110
+
120
111
  // Coerce raw option values before zod validation
121
112
  if (optionsSchema) {
122
- for (const [name, value] of Object.entries(rawOptions)) {
123
- rawOptions[name] = coerce(value, name, optionsSchema)
113
+ for (const [name, value] of Object.entries(rawArgvOptions)) {
114
+ rawArgvOptions[name] = coerce(value, name, optionsSchema)
124
115
  }
125
116
  }
126
117
 
118
+ const mergedOptions = { ...rawDefaults, ...rawArgvOptions }
119
+
127
120
  // Validate options through zod
128
- const parsedOptions = optionsSchema ? zodParse(optionsSchema, rawOptions) : {}
121
+ const parsedOptions = optionsSchema ? zodParse(optionsSchema, mergedOptions) : {}
129
122
 
130
123
  return { args, options: parsedOptions } as parse.ReturnType<args, options>
131
124
  }
@@ -138,6 +131,8 @@ export declare namespace parse {
138
131
  > = {
139
132
  /** Zod schema for positional arguments. Keys define order. */
140
133
  args?: args
134
+ /** Config-backed option defaults merged before argv parsing. */
135
+ defaults?: options extends z.ZodObject<any> ? Partial<z.input<options>> | undefined : undefined
141
136
  /** Zod schema for named options/flags. */
142
137
  options?: options
143
138
  /** Map of option names to single-char aliases. */
@@ -155,6 +150,62 @@ export declare namespace parse {
155
150
  }
156
151
  }
157
152
 
153
+ type OptionNames = {
154
+ aliasToName: Map<string, string>
155
+ kebabToCamel: Map<string, string>
156
+ knownOptions: Set<string>
157
+ }
158
+
159
+ /** Builds lookup tables for option names and short aliases. */
160
+ function createOptionNames(
161
+ schema: z.ZodObject<any> | undefined,
162
+ alias: Record<string, string> | undefined,
163
+ ): OptionNames {
164
+ const aliasToName = new Map<string, string>()
165
+ if (alias) for (const [name, short] of Object.entries(alias)) aliasToName.set(short, name)
166
+
167
+ const knownOptions = new Set(schema ? Object.keys(schema.shape) : [])
168
+ const kebabToCamel = new Map<string, string>()
169
+ for (const name of knownOptions) {
170
+ const kebab = toKebab(name)
171
+ if (kebab !== name) kebabToCamel.set(kebab, name)
172
+ }
173
+
174
+ return { aliasToName, kebabToCamel, knownOptions }
175
+ }
176
+
177
+ /** Normalizes a long option name, accepting kebab-case aliases for camelCase schema keys. */
178
+ function normalizeOptionName(raw: string, options: OptionNames): string | undefined {
179
+ const name = options.kebabToCamel.get(raw) ?? raw
180
+ return options.knownOptions.has(name) ? name : undefined
181
+ }
182
+
183
+ /** Normalizes config-backed defaults and validates config structure/key names. */
184
+ function normalizeOptionDefaults(
185
+ defaults: unknown,
186
+ schema: z.ZodObject<any> | undefined,
187
+ optionNames: OptionNames,
188
+ ): Record<string, unknown> {
189
+ if (defaults === undefined) return {}
190
+ if (!isRecord(defaults))
191
+ throw new ParseError({
192
+ message: 'Invalid config section: expected an object of option defaults',
193
+ })
194
+ if (!schema) {
195
+ const [first] = Object.keys(defaults)
196
+ if (first) throw new ParseError({ message: `Unknown config option: ${first}` })
197
+ return {}
198
+ }
199
+
200
+ const normalized: Record<string, unknown> = {}
201
+ for (const [rawName, value] of Object.entries(defaults)) {
202
+ const name = normalizeOptionName(rawName, optionNames)
203
+ if (!name) throw new ParseError({ message: `Unknown config option: ${rawName}` })
204
+ normalized[name] = value
205
+ }
206
+ return normalized
207
+ }
208
+
158
209
  /** Unwraps ZodDefault/ZodOptional to get the inner type. */
159
210
  function unwrap(schema: z.ZodType): z.ZodType {
160
211
  let s = schema as any
@@ -264,7 +315,7 @@ function coerce(value: unknown, name: string, schema: z.ZodObject<any>): unknown
264
315
  }
265
316
 
266
317
  /** Returns the best available env source for the current runtime. */
267
- function defaultEnvSource(): Record<string, string | undefined> {
318
+ export function defaultEnvSource(): Record<string, string | undefined> {
268
319
  if (typeof globalThis !== 'undefined') {
269
320
  const g = globalThis as any
270
321
  if (g.process?.env) return g.process.env
@@ -97,6 +97,35 @@ describe('toJsonSchema', () => {
97
97
  })
98
98
  })
99
99
 
100
+ test('converts z.bigint() as string', () => {
101
+ expect(Schema.toJsonSchema(z.bigint())).toEqual({ type: 'string' })
102
+ })
103
+
104
+ test('converts z.coerce.bigint() as string', () => {
105
+ expect(Schema.toJsonSchema(z.coerce.bigint())).toEqual({ type: 'string' })
106
+ })
107
+
108
+ test('converts z.object() with bigint field', () => {
109
+ expect(
110
+ Schema.toJsonSchema(z.object({ amount: z.coerce.bigint().describe('Token amount') })),
111
+ ).toEqual({
112
+ type: 'object',
113
+ properties: {
114
+ amount: { type: 'string', description: 'Token amount' },
115
+ },
116
+ required: ['amount'],
117
+ additionalProperties: false,
118
+ })
119
+ })
120
+
121
+ test('converts z.date() as string', () => {
122
+ expect(Schema.toJsonSchema(z.date())).toEqual({ type: 'string' })
123
+ })
124
+
125
+ test('converts z.coerce.date() as string', () => {
126
+ expect(Schema.toJsonSchema(z.coerce.date())).toEqual({ type: 'string' })
127
+ })
128
+
100
129
  test('full object with optional, default, and describe', () => {
101
130
  const result = Schema.toJsonSchema(
102
131
  z.object({
package/src/Schema.ts CHANGED
@@ -1,8 +1,18 @@
1
1
  import { z } from 'zod'
2
2
 
3
- /** Converts a Zod schema to a JSON Schema object. Strips the `$schema` meta-property. */
3
+ /**
4
+ * Converts a Zod schema to a JSON Schema object. Strips the `$schema`
5
+ * meta-property. Represents bigints and dates as `{ type: "string" }`
6
+ * since JSON lacks native types for them.
7
+ */
4
8
  export function toJsonSchema(schema: z.ZodType): Record<string, unknown> {
5
- const result = z.toJSONSchema(schema) as Record<string, unknown>
9
+ const result = z.toJSONSchema(schema, {
10
+ unrepresentable: 'any',
11
+ override: (ctx) => {
12
+ const type = ctx.zodSchema._zod?.def?.type
13
+ if (type === 'bigint' || type === 'date') ctx.jsonSchema.type = 'string'
14
+ },
15
+ }) as Record<string, unknown>
6
16
  delete result.$schema
7
17
  return result
8
18
  }
package/src/Skill.test.ts CHANGED
@@ -216,6 +216,40 @@ describe('hash', () => {
216
216
  })
217
217
  })
218
218
 
219
+ describe('root command (no name)', () => {
220
+ test('generate renders root command without trailing space', () => {
221
+ const result = Skill.generate('my-cli', [
222
+ {
223
+ description: 'Fetch a URL',
224
+ args: z.object({ url: z.string().describe('URL to fetch') }),
225
+ },
226
+ { name: 'auth', description: 'Auth commands' },
227
+ ])
228
+ expect(result).toContain('# my-cli\n\nFetch a URL')
229
+ expect(result).not.toContain('# my-cli \n')
230
+ expect(result).toContain('| `url` | `string` | yes | URL to fetch |')
231
+ expect(result).toContain('# my-cli auth')
232
+ })
233
+
234
+ test('index renders root command signature without trailing space', () => {
235
+ const result = Skill.index('my-cli', [
236
+ { description: 'Fetch a URL', args: z.object({ url: z.string() }) },
237
+ { name: 'auth', description: 'Auth commands' },
238
+ ])
239
+ expect(result).toContain('| `my-cli <url>` | Fetch a URL |')
240
+ expect(result).toContain('| `my-cli auth` | Auth commands |')
241
+ })
242
+
243
+ test('hash changes when root command is added', () => {
244
+ const a = Skill.hash([{ name: 'ping', description: 'Health check' }])
245
+ const b = Skill.hash([
246
+ { description: 'Root command' },
247
+ { name: 'ping', description: 'Health check' },
248
+ ])
249
+ expect(a).not.toBe(b)
250
+ })
251
+ })
252
+
219
253
  describe('split', () => {
220
254
  const commands: Skill.CommandInfo[] = [
221
255
  { name: 'auth login', description: 'Log in' },
@@ -252,7 +286,7 @@ describe('split', () => {
252
286
  expect(files[0]!.content).toMatchInlineSnapshot(`
253
287
  "---
254
288
  name: gh-auth
255
- description: Authenticate with GitHub. Log in, Check status. Run \`gh auth --help\` for usage details.
289
+ description: Authenticate with GitHub. Run \`gh auth --help\` for usage details.
256
290
  requires_bin: gh
257
291
  command: gh auth
258
292
  ---
@@ -270,7 +304,7 @@ describe('split', () => {
270
304
  expect(files[1]!.content).toMatchInlineSnapshot(`
271
305
  "---
272
306
  name: gh-pr
273
- description: Manage pull requests. List PRs, Create PR. Run \`gh pr --help\` for usage details.
307
+ description: Manage pull requests. Run \`gh pr --help\` for usage details.
274
308
  requires_bin: gh
275
309
  command: gh pr
276
310
  ---
@@ -287,11 +321,9 @@ describe('split', () => {
287
321
  `)
288
322
  })
289
323
 
290
- test('depth 1 without group descriptions uses child descriptions', () => {
324
+ test('depth 1 without group descriptions uses fallback hint', () => {
291
325
  const files = Skill.split('gh', commands, 1)
292
- expect(files[0]!.content).toContain(
293
- 'description: Log in, Check status. Run `gh auth --help` for usage details.',
294
- )
326
+ expect(files[0]!.content).toContain('description: Run `gh auth --help` for usage details.')
295
327
  })
296
328
 
297
329
  test('depth 2 groups by first two segments', () => {
@@ -349,4 +381,53 @@ describe('split', () => {
349
381
  expect(afterFrontmatter).not.toMatch(/^title:/m)
350
382
  expect(afterFrontmatter).not.toMatch(/^command:/m)
351
383
  })
384
+
385
+ test('root command uses command description in frontmatter', () => {
386
+ const cmds: Skill.CommandInfo[] = [
387
+ { description: 'Fetch a URL' },
388
+ { name: 'auth login', description: 'Log in' },
389
+ ]
390
+ const files = Skill.split('my-cli', cmds, 1)
391
+ const rootFile = files.find((f) => f.dir === 'my-cli')!
392
+ expect(rootFile.content).toContain(
393
+ 'description: Fetch a URL. Run `my-cli --help` for usage details.',
394
+ )
395
+ })
396
+
397
+ test('single-command group uses command description in frontmatter', () => {
398
+ const files = Skill.split('test', [{ name: 'ping', description: 'Health check' }], 1)
399
+ expect(files[0]!.content).toContain(
400
+ 'description: Health check. Run `test ping --help` for usage details.',
401
+ )
402
+ })
403
+
404
+ test('depth 1 creates separate file for root command', () => {
405
+ const cmds: Skill.CommandInfo[] = [
406
+ {
407
+ description: 'Fetch a URL',
408
+ args: z.object({ url: z.string().describe('URL to fetch') }),
409
+ },
410
+ { name: 'auth login', description: 'Log in' },
411
+ { name: 'auth status', description: 'Check status' },
412
+ ]
413
+ const files = Skill.split('my-cli', cmds, 1)
414
+ expect(files.map((f) => f.dir)).toEqual(['auth', 'my-cli'])
415
+ const rootFile = files.find((f) => f.dir === 'my-cli')!
416
+ expect(rootFile.content).toContain('name: my-cli')
417
+ expect(rootFile.content).toContain('command: my-cli')
418
+ expect(rootFile.content).toContain('# my-cli')
419
+ expect(rootFile.content).toContain('| `url` | `string` | yes | URL to fetch |')
420
+ expect(rootFile.content).not.toContain('# my-cli ')
421
+ })
422
+
423
+ test('depth 0 includes root command in single file', () => {
424
+ const cmds: Skill.CommandInfo[] = [
425
+ { description: 'Fetch a URL' },
426
+ { name: 'ping', description: 'Health check' },
427
+ ]
428
+ const files = Skill.split('test', cmds, 0)
429
+ expect(files).toHaveLength(1)
430
+ expect(files[0]!.content).toContain('# test\n\nFetch a URL')
431
+ expect(files[0]!.content).toContain('# test ping')
432
+ })
352
433
  })
package/src/Skill.ts CHANGED
@@ -5,7 +5,8 @@ import * as Schema from './Schema.js'
5
5
 
6
6
  /** Information about a single command, passed to `generate()`. */
7
7
  export type CommandInfo = {
8
- name: string
8
+ /** Command name (subcommand path). Omit for root commands. */
9
+ name?: string | undefined
9
10
  description?: string | undefined
10
11
  args?: z.ZodObject<any> | undefined
11
12
  env?: z.ZodObject<any> | undefined
@@ -48,7 +49,7 @@ export function index(
48
49
 
49
50
  /** @internal Builds a command signature with arg placeholders. */
50
51
  function buildSignature(cli: string, cmd: CommandInfo): string {
51
- const base = `${cli} ${cmd.name}`
52
+ const base = !cmd.name ? cli : `${cli} ${cmd.name}`
52
53
  if (!cmd.args) return base
53
54
  const shape = cmd.args.shape as Record<string, z.ZodType>
54
55
  const json = Schema.toJsonSchema(cmd.args)
@@ -70,14 +71,16 @@ export function generate(
70
71
  let lastGroup: string | undefined
71
72
 
72
73
  for (const cmd of commands) {
73
- const segment = cmd.name.split(' ')[0]!
74
+ const segment = !cmd.name ? '' : cmd.name.split(' ')[0]!
74
75
  if (segment !== lastGroup) {
75
76
  lastGroup = segment
76
- const desc = groups.get(segment)
77
- const heading = desc ? `## ${name} ${segment}\n\n${desc}` : `## ${name} ${segment}`
78
- sections.push(heading)
77
+ if (segment) {
78
+ const desc = groups.get(segment)
79
+ const heading = desc ? `## ${name} ${segment}\n\n${desc}` : `## ${name} ${segment}`
80
+ sections.push(heading)
81
+ }
79
82
  }
80
- sections.push(renderCommandBody(name, cmd, 3))
83
+ sections.push(renderCommandBody(name, cmd, segment ? 3 : 2))
81
84
  }
82
85
 
83
86
  return sections.join('\n\n')
@@ -94,6 +97,13 @@ export function split(
94
97
 
95
98
  const buckets = new Map<string, CommandInfo[]>()
96
99
  for (const cmd of commands) {
100
+ if (!cmd.name) {
101
+ const key = slugify(name)
102
+ const bucket = buckets.get(key) ?? []
103
+ bucket.push(cmd)
104
+ buckets.set(key, bucket)
105
+ continue
106
+ }
97
107
  const segments = cmd.name.split(' ')
98
108
  const key = segments.slice(0, depth).join('-')
99
109
  const bucket = buckets.get(key) ?? []
@@ -104,8 +114,10 @@ export function split(
104
114
  return [...buckets.entries()]
105
115
  .sort(([a], [b]) => a.localeCompare(b))
106
116
  .map(([dir, cmds]) => {
107
- const prefix = cmds[0]!.name.split(' ').slice(0, depth).join(' ')
108
- return { dir, content: renderGroup(name, `${name} ${prefix}`, cmds, groups, prefix) }
117
+ const first = cmds[0]!
118
+ const prefix = !first.name ? '' : first.name.split(' ').slice(0, depth).join(' ')
119
+ const title = prefix ? `${name} ${prefix}` : name
120
+ return { dir, content: renderGroup(name, title, cmds, groups, prefix || undefined) }
109
121
  })
110
122
  }
111
123
 
@@ -118,17 +130,13 @@ function renderGroup(
118
130
  prefix?: string | undefined,
119
131
  ): string {
120
132
  const groupDesc = prefix ? groups.get(prefix) : undefined
121
- const childDescs = cmds.map((c) => c.description).filter(Boolean) as string[]
122
- const descParts: string[] = []
123
- if (groupDesc) descParts.push(groupDesc.replace(/\.$/, ''))
124
- if (childDescs.length > 0) descParts.push(childDescs.join(', '))
125
- const description =
126
- descParts.length > 0
127
- ? `${descParts.join('. ')}. Run \`${title} --help\` for usage details.`
128
- : `Run \`${title} --help\` for usage details.`
129
-
130
- const slug = title.replace(/\s+/g, '-')
131
- const fm = ['---', `name: ${slug}`]
133
+ const fallbackDesc = cmds.length === 1 && cmds[0]!.description ? cmds[0]!.description : undefined
134
+ const desc = groupDesc ?? fallbackDesc
135
+ const description = desc
136
+ ? `${desc.replace(/\.$/, '')}. Run \`${title} --help\` for usage details.`
137
+ : `Run \`${title} --help\` for usage details.`
138
+
139
+ const fm = ['---', `name: ${slugify(title)}`]
132
140
  fm.push(`description: ${description}`)
133
141
  fm.push(`requires_bin: ${cli}`)
134
142
  fm.push(`command: ${title}`, '---')
@@ -139,7 +147,7 @@ function renderGroup(
139
147
 
140
148
  /** @internal Renders a command's heading and sections without frontmatter. */
141
149
  function renderCommandBody(cli: string, cmd: CommandInfo, level = 1): string {
142
- const fullName = `${cli} ${cmd.name}`
150
+ const fullName = !cmd.name ? cli : `${cli} ${cmd.name}`
143
151
  const sections: string[] = []
144
152
  const h = (n: number) => '#'.repeat(n)
145
153
 
@@ -283,6 +291,15 @@ function schemaToTable(schema: Record<string, unknown>, prefix = ''): string | u
283
291
  return `| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n${rows.join('\n')}`
284
292
  }
285
293
 
294
+ /** @internal Converts a string to a lowercase slug (e.g. `"my-cli"` → `"my-cli"`, `"My Tool"` → `"my-tool"`). */
295
+ function slugify(s: string): string {
296
+ return s
297
+ .toLowerCase()
298
+ .replace(/[^a-z0-9-]+/g, '-')
299
+ .replace(/-{2,}/g, '-')
300
+ .replace(/^-|-$/g, '')
301
+ }
302
+
286
303
  /** @internal Resolves a simple type name from a JSON Schema property. */
287
304
  function resolveTypeName(prop: Record<string, unknown> | undefined): string {
288
305
  if (!prop) return 'unknown'
package/src/Skillgen.ts CHANGED
@@ -62,5 +62,5 @@ function collectEntries(
62
62
  result.push(cmd)
63
63
  }
64
64
  }
65
- return result.sort((a, b) => a.name.localeCompare(b.name))
65
+ return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
66
66
  }
@@ -19,21 +19,19 @@ vi.mock('node:os', async (importOriginal) => {
19
19
  }
20
20
  })
21
21
 
22
- let savedArgv1: string | undefined
23
22
  let tmp: string
24
23
 
25
24
  beforeEach(() => {
26
- savedArgv1 = process.argv[1]
25
+ const savedArgv1 = process.argv[1]
27
26
  tmp = join(tmpdir(), `clac-test-${Date.now()}`)
28
27
  mkdirSync(join(tmp, 'node_modules', '.bin'), { recursive: true })
29
28
  fakeHome = join(tmp, 'home')
30
29
  mkdirSync(fakeHome, { recursive: true })
31
- })
32
-
33
- afterEach(() => {
34
- process.argv[1] = savedArgv1!
35
- fakeHome = undefined
36
- rmSync(tmp, { recursive: true, force: true })
30
+ return () => {
31
+ process.argv[1] = savedArgv1!
32
+ fakeHome = undefined
33
+ rmSync(tmp, { recursive: true, force: true })
34
+ }
37
35
  })
38
36
 
39
37
  function setupPkg(deps: Record<string, string>) {