incur 0.0.0 → 0.0.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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -0
  3. package/SKILL.md +664 -0
  4. package/dist/Cli.d.ts +255 -0
  5. package/dist/Cli.d.ts.map +1 -0
  6. package/dist/Cli.js +900 -0
  7. package/dist/Cli.js.map +1 -0
  8. package/dist/Errors.d.ts +92 -0
  9. package/dist/Errors.d.ts.map +1 -0
  10. package/dist/Errors.js +75 -0
  11. package/dist/Errors.js.map +1 -0
  12. package/dist/Formatter.d.ts +5 -0
  13. package/dist/Formatter.d.ts.map +1 -0
  14. package/dist/Formatter.js +91 -0
  15. package/dist/Formatter.js.map +1 -0
  16. package/dist/Help.d.ts +53 -0
  17. package/dist/Help.d.ts.map +1 -0
  18. package/dist/Help.js +231 -0
  19. package/dist/Help.js.map +1 -0
  20. package/dist/Mcp.d.ts +13 -0
  21. package/dist/Mcp.d.ts.map +1 -0
  22. package/dist/Mcp.js +140 -0
  23. package/dist/Mcp.js.map +1 -0
  24. package/dist/Parser.d.ts +24 -0
  25. package/dist/Parser.d.ts.map +1 -0
  26. package/dist/Parser.js +215 -0
  27. package/dist/Parser.js.map +1 -0
  28. package/dist/Register.d.ts +19 -0
  29. package/dist/Register.d.ts.map +1 -0
  30. package/dist/Register.js +2 -0
  31. package/dist/Register.js.map +1 -0
  32. package/dist/Schema.d.ts +4 -0
  33. package/dist/Schema.d.ts.map +1 -0
  34. package/dist/Schema.js +8 -0
  35. package/dist/Schema.js.map +1 -0
  36. package/dist/Skill.d.ts +29 -0
  37. package/dist/Skill.d.ts.map +1 -0
  38. package/dist/Skill.js +196 -0
  39. package/dist/Skill.js.map +1 -0
  40. package/dist/Skillgen.d.ts +3 -0
  41. package/dist/Skillgen.d.ts.map +1 -0
  42. package/dist/Skillgen.js +67 -0
  43. package/dist/Skillgen.js.map +1 -0
  44. package/dist/SyncMcp.d.ts +23 -0
  45. package/dist/SyncMcp.d.ts.map +1 -0
  46. package/dist/SyncMcp.js +100 -0
  47. package/dist/SyncMcp.js.map +1 -0
  48. package/dist/SyncSkills.d.ts +38 -0
  49. package/dist/SyncSkills.d.ts.map +1 -0
  50. package/dist/SyncSkills.js +163 -0
  51. package/dist/SyncSkills.js.map +1 -0
  52. package/dist/Typegen.d.ts +6 -0
  53. package/dist/Typegen.d.ts.map +1 -0
  54. package/dist/Typegen.js +92 -0
  55. package/dist/Typegen.js.map +1 -0
  56. package/dist/bin.d.ts +14 -0
  57. package/dist/bin.d.ts.map +1 -0
  58. package/dist/bin.js +30 -0
  59. package/dist/bin.js.map +1 -0
  60. package/dist/index.d.ts +15 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +14 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/internal/pm.d.ts +3 -0
  65. package/dist/internal/pm.d.ts.map +1 -0
  66. package/dist/internal/pm.js +11 -0
  67. package/dist/internal/pm.js.map +1 -0
  68. package/dist/internal/types.d.ts +11 -0
  69. package/dist/internal/types.d.ts.map +1 -0
  70. package/dist/internal/types.js +2 -0
  71. package/dist/internal/types.js.map +1 -0
  72. package/dist/internal/utils.d.ts +8 -0
  73. package/dist/internal/utils.d.ts.map +1 -0
  74. package/dist/internal/utils.js +51 -0
  75. package/dist/internal/utils.js.map +1 -0
  76. package/examples/npm/cli.ts +180 -0
  77. package/examples/npm/node_modules/.bin/incur.src +21 -0
  78. package/examples/npm/node_modules/.bin/tsx +21 -0
  79. package/examples/npm/package.json +14 -0
  80. package/examples/npm/tsconfig.json +9 -0
  81. package/examples/presto/cli.ts +246 -0
  82. package/examples/presto/node_modules/.bin/incur.src +21 -0
  83. package/examples/presto/node_modules/.bin/tsx +21 -0
  84. package/examples/presto/package.json +14 -0
  85. package/examples/presto/tsconfig.json +9 -0
  86. package/package.json +53 -2
  87. package/src/Cli.test-d.ts +135 -0
  88. package/src/Cli.test.ts +1373 -0
  89. package/src/Cli.ts +1470 -0
  90. package/src/Errors.test.ts +96 -0
  91. package/src/Errors.ts +139 -0
  92. package/src/Formatter.test.ts +245 -0
  93. package/src/Formatter.ts +106 -0
  94. package/src/Help.test.ts +124 -0
  95. package/src/Help.ts +302 -0
  96. package/src/Mcp.test.ts +254 -0
  97. package/src/Mcp.ts +195 -0
  98. package/src/Parser.test-d.ts +45 -0
  99. package/src/Parser.test.ts +118 -0
  100. package/src/Parser.ts +247 -0
  101. package/src/Register.ts +18 -0
  102. package/src/Schema.test.ts +125 -0
  103. package/src/Schema.ts +8 -0
  104. package/src/Skill.test.ts +293 -0
  105. package/src/Skill.ts +253 -0
  106. package/src/Skillgen.ts +66 -0
  107. package/src/SyncMcp.test.ts +75 -0
  108. package/src/SyncMcp.ts +132 -0
  109. package/src/SyncSkills.test.ts +92 -0
  110. package/src/SyncSkills.ts +205 -0
  111. package/src/Typegen.test.ts +150 -0
  112. package/src/Typegen.ts +107 -0
  113. package/src/bin.ts +33 -0
  114. package/src/e2e.test.ts +1710 -0
  115. package/src/index.ts +14 -0
  116. package/src/internal/pm.test.ts +38 -0
  117. package/src/internal/pm.ts +8 -0
  118. package/src/internal/types.ts +22 -0
  119. package/src/internal/utils.ts +50 -0
  120. package/src/tsconfig.json +8 -0
@@ -0,0 +1,125 @@
1
+ import { Schema, z } from 'incur'
2
+
3
+ describe('toJsonSchema', () => {
4
+ test('converts z.string()', () => {
5
+ expect(Schema.toJsonSchema(z.string())).toEqual({ type: 'string' })
6
+ })
7
+
8
+ test('converts z.number()', () => {
9
+ expect(Schema.toJsonSchema(z.number())).toEqual({ type: 'number' })
10
+ })
11
+
12
+ test('converts z.boolean()', () => {
13
+ expect(Schema.toJsonSchema(z.boolean())).toEqual({ type: 'boolean' })
14
+ })
15
+
16
+ test('converts z.enum()', () => {
17
+ expect(Schema.toJsonSchema(z.enum(['open', 'closed']))).toEqual({
18
+ type: 'string',
19
+ enum: ['open', 'closed'],
20
+ })
21
+ })
22
+
23
+ test('converts z.array()', () => {
24
+ expect(Schema.toJsonSchema(z.array(z.string()))).toEqual({
25
+ type: 'array',
26
+ items: { type: 'string' },
27
+ })
28
+ })
29
+
30
+ test('converts z.object() with required fields', () => {
31
+ expect(Schema.toJsonSchema(z.object({ name: z.string(), count: z.number() }))).toEqual({
32
+ type: 'object',
33
+ properties: {
34
+ name: { type: 'string' },
35
+ count: { type: 'number' },
36
+ },
37
+ required: ['name', 'count'],
38
+ additionalProperties: false,
39
+ })
40
+ })
41
+
42
+ test('.optional() removes from required', () => {
43
+ expect(
44
+ Schema.toJsonSchema(
45
+ z.object({
46
+ name: z.string(),
47
+ age: z.number().optional(),
48
+ }),
49
+ ),
50
+ ).toEqual({
51
+ type: 'object',
52
+ properties: {
53
+ name: { type: 'string' },
54
+ age: { type: 'number' },
55
+ },
56
+ required: ['name'],
57
+ additionalProperties: false,
58
+ })
59
+ })
60
+
61
+ test('.default() adds default to schema', () => {
62
+ const result = Schema.toJsonSchema(
63
+ z.object({
64
+ state: z.enum(['open', 'closed']).default('open'),
65
+ }),
66
+ )
67
+ expect(result).toMatchObject({
68
+ properties: {
69
+ state: { type: 'string', enum: ['open', 'closed'], default: 'open' },
70
+ },
71
+ })
72
+ })
73
+
74
+ test('.describe() adds description', () => {
75
+ const result = Schema.toJsonSchema(
76
+ z.object({
77
+ name: z.string().describe('The user name'),
78
+ }),
79
+ )
80
+ expect(result).toMatchObject({
81
+ properties: {
82
+ name: { type: 'string', description: 'The user name' },
83
+ },
84
+ })
85
+ })
86
+
87
+ test('full object with optional, default, and describe', () => {
88
+ const result = Schema.toJsonSchema(
89
+ z.object({
90
+ name: z.string().describe('User name'),
91
+ state: z.enum(['open', 'closed']).default('open').describe('Filter state'),
92
+ limit: z.number().optional().describe('Max items'),
93
+ }),
94
+ )
95
+ expect(result).toMatchInlineSnapshot(`
96
+ {
97
+ "additionalProperties": false,
98
+ "properties": {
99
+ "limit": {
100
+ "description": "Max items",
101
+ "type": "number",
102
+ },
103
+ "name": {
104
+ "description": "User name",
105
+ "type": "string",
106
+ },
107
+ "state": {
108
+ "default": "open",
109
+ "description": "Filter state",
110
+ "enum": [
111
+ "open",
112
+ "closed",
113
+ ],
114
+ "type": "string",
115
+ },
116
+ },
117
+ "required": [
118
+ "name",
119
+ "state",
120
+ ],
121
+ "type": "object",
122
+ }
123
+ `)
124
+ })
125
+ })
package/src/Schema.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod'
2
+
3
+ /** Converts a Zod schema to a JSON Schema object. Strips the `$schema` meta-property. */
4
+ export function toJsonSchema(schema: z.ZodType): Record<string, unknown> {
5
+ const result = z.toJSONSchema(schema) as Record<string, unknown>
6
+ delete result.$schema
7
+ return result
8
+ }
@@ -0,0 +1,293 @@
1
+ import { Skill, z } from 'incur'
2
+
3
+ test('generates skill file with frontmatter and heading', () => {
4
+ const result = Skill.generate('test', [{ name: 'ping', description: 'Health check' }])
5
+ expect(result).toMatchInlineSnapshot(`
6
+ "# test ping
7
+
8
+ Health check"
9
+ `)
10
+ })
11
+
12
+ test('includes arguments table', () => {
13
+ const result = Skill.generate('test', [
14
+ {
15
+ name: 'greet',
16
+ description: 'Greet someone',
17
+ args: z.object({ name: z.string().describe('Name to greet') }),
18
+ },
19
+ ])
20
+ expect(result).toMatchInlineSnapshot(`
21
+ "# test greet
22
+
23
+ Greet someone
24
+
25
+ ## Arguments
26
+
27
+ | Name | Type | Required | Description |
28
+ |------|------|----------|-------------|
29
+ | \`name\` | \`string\` | yes | Name to greet |"
30
+ `)
31
+ })
32
+
33
+ test('includes options table', () => {
34
+ const result = Skill.generate('test', [
35
+ {
36
+ name: 'list',
37
+ description: 'List items',
38
+ options: z.object({
39
+ limit: z.number().default(30).describe('Max items'),
40
+ verbose: z.boolean().default(false).describe('Show details'),
41
+ }),
42
+ },
43
+ ])
44
+ expect(result).toMatchInlineSnapshot(`
45
+ "# test list
46
+
47
+ List items
48
+
49
+ ## Options
50
+
51
+ | Flag | Type | Default | Description |
52
+ |------|------|---------|-------------|
53
+ | \`--limit\` | \`number\` | \`30\` | Max items |
54
+ | \`--verbose\` | \`boolean\` | \`false\` | Show details |"
55
+ `)
56
+ })
57
+
58
+ test('includes output schema', () => {
59
+ const result = Skill.generate('test', [
60
+ {
61
+ name: 'greet',
62
+ description: 'Greet someone',
63
+ output: z.object({ message: z.string().describe('Greeting message') }),
64
+ },
65
+ ])
66
+ expect(result).toMatchInlineSnapshot(`
67
+ "# test greet
68
+
69
+ Greet someone
70
+
71
+ ## Output
72
+
73
+ | Field | Type | Required | Description |
74
+ |-------|------|----------|-------------|
75
+ | \`message\` | \`string\` | yes | Greeting message |"
76
+ `)
77
+ })
78
+
79
+ test('expands nested output schema', () => {
80
+ const result = Skill.generate('test', [
81
+ {
82
+ name: 'list',
83
+ description: 'List items',
84
+ output: z.object({
85
+ items: z.array(
86
+ z.object({
87
+ id: z.number(),
88
+ meta: z.object({ tag: z.string() }),
89
+ }),
90
+ ),
91
+ }),
92
+ },
93
+ ])
94
+ expect(result).toMatchInlineSnapshot(`
95
+ "# test list
96
+
97
+ List items
98
+
99
+ ## Output
100
+
101
+ | Field | Type | Required | Description |
102
+ |-------|------|----------|-------------|
103
+ | \`items\` | \`array\` | yes | |
104
+ | \`items[].id\` | \`number\` | yes | |
105
+ | \`items[].meta\` | \`object\` | yes | |
106
+ | \`items[].meta.tag\` | \`string\` | yes | |"
107
+ `)
108
+ })
109
+
110
+ test('omits sections when not applicable', () => {
111
+ const result = Skill.generate('test', [{ name: 'ping', description: 'Health check' }])
112
+ expect(result).not.toContain('## Arguments')
113
+ expect(result).not.toContain('## Options')
114
+ expect(result).not.toContain('## Output')
115
+ })
116
+
117
+ test('concatenates multiple commands', () => {
118
+ const result = Skill.generate('test', [
119
+ { name: 'ping', description: 'Health check' },
120
+ { name: 'pong', description: 'Pong back' },
121
+ ])
122
+ expect(result).toMatchInlineSnapshot(`
123
+ "# test ping
124
+
125
+ Health check
126
+
127
+ # test pong
128
+
129
+ Pong back"
130
+ `)
131
+ })
132
+
133
+ describe('hash', () => {
134
+ test('returns consistent hash for same commands', () => {
135
+ const commands: Skill.CommandInfo[] = [
136
+ { name: 'ping', description: 'Health check' },
137
+ { name: 'greet', description: 'Say hello' },
138
+ ]
139
+ expect(Skill.hash(commands)).toBe(Skill.hash(commands))
140
+ })
141
+
142
+ test('changes when command is added', () => {
143
+ const a = Skill.hash([{ name: 'ping', description: 'Health check' }])
144
+ const b = Skill.hash([
145
+ { name: 'ping', description: 'Health check' },
146
+ { name: 'greet', description: 'Say hello' },
147
+ ])
148
+ expect(a).not.toBe(b)
149
+ })
150
+
151
+ test('changes when description changes', () => {
152
+ const a = Skill.hash([{ name: 'ping', description: 'Health check' }])
153
+ const b = Skill.hash([{ name: 'ping', description: 'Check health' }])
154
+ expect(a).not.toBe(b)
155
+ })
156
+
157
+ test('changes when schema changes', () => {
158
+ const a = Skill.hash([{ name: 'greet', args: z.object({ name: z.string() }) }])
159
+ const b = Skill.hash([{ name: 'greet', args: z.object({ name: z.string(), age: z.number() }) }])
160
+ expect(a).not.toBe(b)
161
+ })
162
+
163
+ test('returns 16-char hex string', () => {
164
+ const h = Skill.hash([{ name: 'ping' }])
165
+ expect(h).toMatch(/^[0-9a-f]{16}$/)
166
+ })
167
+ })
168
+
169
+ describe('split', () => {
170
+ const commands: Skill.CommandInfo[] = [
171
+ { name: 'auth login', description: 'Log in' },
172
+ { name: 'auth status', description: 'Check status' },
173
+ { name: 'pr list', description: 'List PRs' },
174
+ { name: 'pr create', description: 'Create PR' },
175
+ ]
176
+
177
+ const groups = new Map([
178
+ ['auth', 'Authenticate with GitHub'],
179
+ ['pr', 'Manage pull requests'],
180
+ ])
181
+
182
+ test('depth 0 returns single file', () => {
183
+ const files = Skill.split('gh', commands, 0)
184
+ expect(files.map((f) => f.dir)).toMatchInlineSnapshot(`
185
+ [
186
+ "",
187
+ ]
188
+ `)
189
+ expect(files[0]!.content).toContain('name: gh')
190
+ expect(files[0]!.content).toContain('# gh auth login')
191
+ expect(files[0]!.content).toContain('# gh pr list')
192
+ })
193
+
194
+ test('depth 1 groups by first segment with group frontmatter', () => {
195
+ const files = Skill.split('gh', commands, 1, groups)
196
+ expect(files.map((f) => f.dir)).toMatchInlineSnapshot(`
197
+ [
198
+ "auth",
199
+ "pr",
200
+ ]
201
+ `)
202
+ expect(files[0]!.content).toMatchInlineSnapshot(`
203
+ "---
204
+ name: gh-auth
205
+ description: Authenticate with GitHub. Log in, Check status. Run \`gh auth --help\` for usage details.
206
+ command: gh auth
207
+ ---
208
+
209
+ # gh auth login
210
+
211
+ Log in
212
+
213
+ ---
214
+
215
+ # gh auth status
216
+
217
+ Check status"
218
+ `)
219
+ expect(files[1]!.content).toMatchInlineSnapshot(`
220
+ "---
221
+ name: gh-pr
222
+ description: Manage pull requests. List PRs, Create PR. Run \`gh pr --help\` for usage details.
223
+ command: gh pr
224
+ ---
225
+
226
+ # gh pr list
227
+
228
+ List PRs
229
+
230
+ ---
231
+
232
+ # gh pr create
233
+
234
+ Create PR"
235
+ `)
236
+ })
237
+
238
+ test('depth 1 without group descriptions uses child descriptions', () => {
239
+ const files = Skill.split('gh', commands, 1)
240
+ expect(files[0]!.content).toContain('description: Log in, Check status. Run `gh auth --help` for usage details.')
241
+ })
242
+
243
+ test('depth 2 groups by first two segments', () => {
244
+ const files = Skill.split('gh', commands, 2)
245
+ expect(files.map((f) => f.dir)).toMatchInlineSnapshot(`
246
+ [
247
+ "auth-login",
248
+ "auth-status",
249
+ "pr-create",
250
+ "pr-list",
251
+ ]
252
+ `)
253
+ })
254
+
255
+ test('shallow commands use available segments', () => {
256
+ const files = Skill.split('test', [{ name: 'ping', description: 'Ping' }], 2)
257
+ expect(files.map((f) => f.dir)).toMatchInlineSnapshot(`
258
+ [
259
+ "ping",
260
+ ]
261
+ `)
262
+ })
263
+
264
+ test('description includes --help hint for depth 0', () => {
265
+ const files = Skill.split('gh', commands, 0, groups)
266
+ expect(files[0]!.content).toContain('Run `gh --help` for usage details.')
267
+ })
268
+
269
+ test('description includes --help hint for depth 1 with groups', () => {
270
+ const files = Skill.split('gh', commands, 1, groups)
271
+ expect(files[0]!.content).toContain('Run `gh auth --help` for usage details.')
272
+ expect(files[1]!.content).toContain('Run `gh pr --help` for usage details.')
273
+ })
274
+
275
+ test('description includes --help hint for depth 2', () => {
276
+ const files = Skill.split('gh', commands, 2)
277
+ expect(files[0]!.content).toContain('Run `gh auth login --help` for usage details.')
278
+ })
279
+
280
+ test('omits --help hint when no descriptions exist', () => {
281
+ const files = Skill.split('test', [{ name: 'ping' }], 1)
282
+ expect(files[0]!.content).not.toContain('--help')
283
+ })
284
+
285
+ test('no per-command frontmatter in split files', () => {
286
+ const files = Skill.split('gh', commands, 1, groups)
287
+ const afterFrontmatter = files[0]!.content.slice(
288
+ files[0]!.content.indexOf('---', files[0]!.content.indexOf('---') + 3) + 3,
289
+ )
290
+ expect(afterFrontmatter).not.toMatch(/^title:/m)
291
+ expect(afterFrontmatter).not.toMatch(/^command:/m)
292
+ })
293
+ })
package/src/Skill.ts ADDED
@@ -0,0 +1,253 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { z } from 'zod'
3
+
4
+ import * as Schema from './Schema.js'
5
+
6
+ /** Information about a single command, passed to `generate()`. */
7
+ export type CommandInfo = {
8
+ name: string
9
+ description?: string | undefined
10
+ args?: z.ZodObject<any> | undefined
11
+ env?: z.ZodObject<any> | undefined
12
+ hint?: string | undefined
13
+ options?: z.ZodObject<any> | undefined
14
+ output?: z.ZodType | undefined
15
+ examples?: { command: string; description?: string }[] | undefined
16
+ }
17
+
18
+ /** A skill file entry with its directory name and content. */
19
+ export type File = {
20
+ /** Directory name relative to output root (empty string for depth 0). */
21
+ dir: string
22
+ /** Markdown content. */
23
+ content: string
24
+ }
25
+
26
+ /** Generates a Markdown skill file from a CLI name and collected command data. */
27
+ export function generate(
28
+ name: string,
29
+ commands: CommandInfo[],
30
+ groups: Map<string, string> = new Map(),
31
+ ): string {
32
+ const hasGroups = groups.size > 0
33
+ if (!hasGroups) return commands.map((cmd) => renderCommandBody(name, cmd)).join('\n\n')
34
+
35
+ const sections: string[] = [`# ${name}`]
36
+ let lastGroup: string | undefined
37
+
38
+ for (const cmd of commands) {
39
+ const segment = cmd.name.split(' ')[0]!
40
+ if (segment !== lastGroup) {
41
+ lastGroup = segment
42
+ const desc = groups.get(segment)
43
+ const heading = desc ? `## ${name} ${segment}\n\n${desc}` : `## ${name} ${segment}`
44
+ sections.push(heading)
45
+ }
46
+ sections.push(renderCommandBody(name, cmd, 3))
47
+ }
48
+
49
+ return sections.join('\n\n')
50
+ }
51
+
52
+ /** Splits commands into skill files grouped by depth. */
53
+ export function split(
54
+ name: string,
55
+ commands: CommandInfo[],
56
+ depth: number,
57
+ groups: Map<string, string> = new Map(),
58
+ ): File[] {
59
+ if (depth === 0) return [{ dir: '', content: renderGroup(name, name, commands, groups, name) }]
60
+
61
+ const buckets = new Map<string, CommandInfo[]>()
62
+ for (const cmd of commands) {
63
+ const segments = cmd.name.split(' ')
64
+ const key = segments.slice(0, depth).join('-')
65
+ const bucket = buckets.get(key) ?? []
66
+ bucket.push(cmd)
67
+ buckets.set(key, bucket)
68
+ }
69
+
70
+ return [...buckets.entries()]
71
+ .sort(([a], [b]) => a.localeCompare(b))
72
+ .map(([dir, cmds]) => {
73
+ const prefix = cmds[0]!.name.split(' ').slice(0, depth).join(' ')
74
+ return { dir, content: renderGroup(name, `${name} ${prefix}`, cmds, groups, prefix) }
75
+ })
76
+ }
77
+
78
+ /** @internal Renders a group-level frontmatter + command bodies. */
79
+ function renderGroup(
80
+ cli: string,
81
+ title: string,
82
+ cmds: CommandInfo[],
83
+ groups: Map<string, string>,
84
+ prefix?: string | undefined,
85
+ ): string {
86
+ const groupDesc = prefix ? groups.get(prefix) : undefined
87
+ const childDescs = cmds.map((c) => c.description).filter(Boolean) as string[]
88
+ const descParts: string[] = []
89
+ if (groupDesc) descParts.push(groupDesc.replace(/\.$/, ''))
90
+ if (childDescs.length > 0) descParts.push(childDescs.join(', '))
91
+ const description = descParts.join('. ') || undefined
92
+
93
+ const slug = title.replace(/\s+/g, '-')
94
+ const fm = ['---', `name: ${slug}`]
95
+ if (description) fm.push(`description: ${description}. Run \`${title} --help\` for usage details.`)
96
+ fm.push(`command: ${title}`, '---')
97
+
98
+ const body = cmds.map((cmd) => renderCommandBody(cli, cmd)).join('\n\n---\n\n')
99
+ return `${fm.join('\n')}\n\n${body}`
100
+ }
101
+
102
+ /** @internal Renders a command's heading and sections without frontmatter. */
103
+ function renderCommandBody(cli: string, cmd: CommandInfo, level = 1): string {
104
+ const fullName = `${cli} ${cmd.name}`
105
+ const sections: string[] = []
106
+ const h = (n: number) => '#'.repeat(n)
107
+
108
+ let heading = `${h(level)} ${fullName}`
109
+ if (cmd.description) heading += `\n\n${cmd.description}`
110
+ sections.push(heading)
111
+
112
+ const sub = h(level + 1)
113
+
114
+ // Arguments table
115
+ if (cmd.args) {
116
+ const shape = cmd.args.shape as Record<string, z.ZodType>
117
+ const json = Schema.toJsonSchema(cmd.args)
118
+ const required = new Set((json.required as string[] | undefined) ?? [])
119
+ const properties = json.properties as Record<string, Record<string, unknown>> | undefined
120
+ const rows = Object.entries(shape).map(([key, field]) => {
121
+ const prop = properties?.[key]
122
+ const type = resolveTypeName(prop)
123
+ const req = required.has(key) ? 'yes' : 'no'
124
+ const desc = field.description ?? ''
125
+ return `| \`${key}\` | \`${type}\` | ${req} | ${desc} |`
126
+ })
127
+ sections.push(
128
+ `${sub} Arguments\n\n| Name | Type | Required | Description |\n|------|------|----------|-------------|\n${rows.join('\n')}`,
129
+ )
130
+ }
131
+
132
+ // Environment Variables table
133
+ if (cmd.env) {
134
+ const shape = cmd.env.shape as Record<string, z.ZodType>
135
+ const json = Schema.toJsonSchema(cmd.env)
136
+ const required = new Set((json.required as string[] | undefined) ?? [])
137
+ const properties = json.properties as Record<string, Record<string, unknown>> | undefined
138
+ const rows = Object.entries(shape).map(([key, field]) => {
139
+ const prop = properties?.[key]
140
+ const type = resolveTypeName(prop)
141
+ const def = prop?.default !== undefined ? String(prop.default) : ''
142
+ const req = required.has(key) ? 'yes' : 'no'
143
+ const desc = field.description ?? ''
144
+ return `| \`${key}\` | \`${type}\` | ${req} | ${def ? `\`${def}\`` : ''} | ${desc} |`
145
+ })
146
+ sections.push(
147
+ `${sub} Environment Variables\n\n| Name | Type | Required | Default | Description |\n|------|------|----------|---------|-------------|\n${rows.join('\n')}`,
148
+ )
149
+ }
150
+
151
+ // Options table
152
+ if (cmd.options) {
153
+ const shape = cmd.options.shape as Record<string, z.ZodType>
154
+ const json = Schema.toJsonSchema(cmd.options)
155
+ const properties = json.properties as Record<string, Record<string, unknown>> | undefined
156
+ const rows = Object.entries(shape).map(([key, field]) => {
157
+ const prop = properties?.[key]
158
+ const type = resolveTypeName(prop)
159
+ const def = prop?.default !== undefined ? String(prop.default) : ''
160
+ const desc = field.description ?? ''
161
+ return `| \`--${key}\` | \`${type}\` | ${def ? `\`${def}\`` : ''} | ${desc} |`
162
+ })
163
+ sections.push(
164
+ `${sub} Options\n\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n${rows.join('\n')}`,
165
+ )
166
+ }
167
+
168
+ // Output table
169
+ if (cmd.output) {
170
+ const outputSchema = Schema.toJsonSchema(cmd.output)
171
+ const table = schemaToTable(outputSchema)
172
+ if (table) sections.push(`${sub} Output\n\n${table}`)
173
+ else {
174
+ const type = resolveTypeName(outputSchema)
175
+ sections.push(`${sub} Output\n\nType: \`${type}\``)
176
+ }
177
+ }
178
+
179
+ // Examples
180
+ if (cmd.examples && cmd.examples.length > 0) {
181
+ const lines = cmd.examples.map((ex) => {
182
+ const comment = ex.description ? `# ${ex.description}\n` : ''
183
+ return `${comment}${cli} ${ex.command}`
184
+ })
185
+ sections.push(`${sub} Examples\n\n\`\`\`sh\n${lines.join('\n\n')}\n\`\`\``)
186
+ }
187
+
188
+ // Hint
189
+ if (cmd.hint) sections.push(`> ${cmd.hint}`)
190
+
191
+ return sections.join('\n\n')
192
+ }
193
+
194
+ /** Computes a deterministic hash of command structure for staleness detection. */
195
+ export function hash(commands: CommandInfo[]): string {
196
+ const data = commands.map((cmd) => ({
197
+ name: cmd.name,
198
+ description: cmd.description,
199
+ args: cmd.args ? Schema.toJsonSchema(cmd.args) : undefined,
200
+ env: cmd.env ? Schema.toJsonSchema(cmd.env) : undefined,
201
+ options: cmd.options ? Schema.toJsonSchema(cmd.options) : undefined,
202
+ output: cmd.output ? Schema.toJsonSchema(cmd.output) : undefined,
203
+ }))
204
+ return createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 16)
205
+ }
206
+
207
+ /** @internal Renders a JSON Schema object as a Markdown table. Returns `undefined` for non-object schemas. */
208
+ function schemaToTable(schema: Record<string, unknown>, prefix = ''): string | undefined {
209
+ if (schema.type !== 'object') return undefined
210
+ const properties = schema.properties as Record<string, Record<string, unknown>> | undefined
211
+ if (!properties || Object.keys(properties).length === 0) return undefined
212
+ const required = new Set((schema.required as string[] | undefined) ?? [])
213
+
214
+ const rows: string[] = []
215
+ for (const [key, prop] of Object.entries(properties)) {
216
+ const name = prefix ? `${prefix}.${key}` : key
217
+ const type = resolveTypeName(prop)
218
+ const req = required.has(key) ? 'yes' : 'no'
219
+ const desc = (prop.description as string) ?? ''
220
+ rows.push(`| \`${name}\` | \`${type}\` | ${req} | ${desc} |`)
221
+
222
+ // Expand nested objects inline
223
+ if (prop.type === 'object' && prop.properties) {
224
+ const nested = schemaToTable(prop, name)
225
+ if (nested) {
226
+ const lines = nested.split('\n')
227
+ rows.push(...lines.slice(2)) // skip header + separator
228
+ }
229
+ }
230
+
231
+ // Expand array item objects inline
232
+ if (prop.type === 'array' && prop.items) {
233
+ const items = prop.items as Record<string, unknown>
234
+ if (items.type === 'object' && items.properties) {
235
+ const nested = schemaToTable(items, `${name}[]`)
236
+ if (nested) {
237
+ const lines = nested.split('\n')
238
+ rows.push(...lines.slice(2))
239
+ }
240
+ }
241
+ }
242
+ }
243
+
244
+ return `| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n${rows.join('\n')}`
245
+ }
246
+
247
+ /** @internal Resolves a simple type name from a JSON Schema property. */
248
+ function resolveTypeName(prop: Record<string, unknown> | undefined): string {
249
+ if (!prop) return 'unknown'
250
+ const type = prop.type as string | undefined
251
+ if (type) return type === 'integer' ? 'number' : type
252
+ return 'unknown'
253
+ }