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
@@ -123,6 +123,95 @@ test('register with agents: ["amp"] skips add-mcp', async () => {
123
123
  expect(result.agents).toEqual(['Amp'])
124
124
  })
125
125
 
126
+ test('register handles quoted command paths with spaces', async () => {
127
+ const { execFile } = await import('node:child_process')
128
+ vi.mocked(execFile).mockClear()
129
+
130
+ const result = await register('my-cli', {
131
+ command: '"/path/to my/cli" --mcp',
132
+ agents: ['amp'],
133
+ })
134
+
135
+ expect(result.agents).toEqual(['Amp'])
136
+
137
+ const configPath = join(fakeHome!, '.config', 'amp', 'settings.json')
138
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'))
139
+ expect(config['amp.mcpServers']['my-cli']).toEqual({
140
+ command: '/path/to my/cli',
141
+ args: ['--mcp'],
142
+ })
143
+ })
144
+
145
+ test('register uses bare name for global binary installs', async () => {
146
+ process.argv[1] = '/usr/local/bin/my-cli'
147
+
148
+ const { execFile } = await import('node:child_process')
149
+ vi.mocked(execFile).mockClear()
150
+
151
+ const result = await register('my-cli', { agents: ['amp'] })
152
+
153
+ expect(result.command).toBe('my-cli --mcp')
154
+
155
+ const configPath = join(fakeHome!, '.config', 'amp', 'settings.json')
156
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'))
157
+ expect(config['amp.mcpServers']['my-cli']).toEqual({
158
+ command: 'my-cli',
159
+ args: ['--mcp'],
160
+ })
161
+ })
162
+
163
+ test('register uses runner for source entrypoints outside node_modules', async () => {
164
+ process.argv[1] = join(tmp, 'dist', 'bin.js')
165
+
166
+ const { execFile } = await import('node:child_process')
167
+ vi.mocked(execFile).mockClear()
168
+
169
+ const result = await register('my-cli', { agents: ['amp'] })
170
+
171
+ expect(result.command).toMatch(/^(npx|pnpx|bunx)\s/)
172
+ expect(result.command).toContain('my-cli --mcp')
173
+ })
174
+
175
+ test('register uses bare name for global package entrypoints under node_modules', async () => {
176
+ process.argv[1] = join(tmp, 'global', 'node_modules', 'my-cli', 'dist', 'bin.js')
177
+
178
+ const { execFile } = await import('node:child_process')
179
+ vi.mocked(execFile).mockClear()
180
+
181
+ const result = await register('my-cli', { agents: ['amp'] })
182
+
183
+ expect(result.command).toBe('my-cli --mcp')
184
+ })
185
+
186
+ test('register uses runner for project dev dependency entrypoints under node_modules', async () => {
187
+ writeFileSync(
188
+ join(tmp, 'package.json'),
189
+ JSON.stringify({ devDependencies: { 'my-cli': '1.0.0' } }),
190
+ )
191
+ process.argv[1] = join(tmp, 'node_modules', 'my-cli', 'dist', 'bin.js')
192
+
193
+ const { execFile } = await import('node:child_process')
194
+ vi.mocked(execFile).mockClear()
195
+
196
+ const result = await register('my-cli', { agents: ['amp'] })
197
+
198
+ expect(result.command).toMatch(/^(npx|pnpx|bunx)\s/)
199
+ expect(result.command).toContain('my-cli --mcp')
200
+ })
201
+
202
+ test('register uses runner for node_modules installs', async () => {
203
+ process.argv[1] = join(tmp, 'node_modules', '.bin', 'my-cli')
204
+
205
+ const { execFile } = await import('node:child_process')
206
+ vi.mocked(execFile).mockClear()
207
+
208
+ const result = await register('my-cli', { agents: ['amp'] })
209
+
210
+ // Should include a runner prefix (npx, pnpx, or bunx)
211
+ expect(result.command).toMatch(/^(npx|pnpx|bunx)\s/)
212
+ expect(result.command).toContain('--mcp')
213
+ })
214
+
126
215
  test('register writes amp config to existing settings', async () => {
127
216
  const configDir = join(fakeHome!, '.config', 'amp')
128
217
  mkdirSync(configDir, { recursive: true })
package/src/SyncMcp.ts CHANGED
@@ -11,7 +11,7 @@ export async function register(
11
11
  options: register.Options = {},
12
12
  ): Promise<register.Result> {
13
13
  const runner = detectRunner()
14
- const command = options.command ?? `${runner} ${detectPackageSpecifier(name)} --mcp`
14
+ const command = options.command ?? defaultCommand(name, runner)
15
15
  const targetAgents = options.agents ?? []
16
16
  const ampOnly = targetAgents.length === 1 && targetAgents[0] === 'amp'
17
17
 
@@ -64,7 +64,7 @@ function registerAmp(name: string, command: string): boolean {
64
64
  }
65
65
  }
66
66
 
67
- const [cmd, ...args] = command.split(' ')
67
+ const [cmd, ...args] = splitCommand(command)
68
68
  if (!cmd) return false
69
69
 
70
70
  const servers: Record<string, any> = config['amp.mcpServers'] ?? {}
@@ -98,16 +98,56 @@ export declare namespace register {
98
98
  }
99
99
  }
100
100
 
101
- /** @internal Detects the package specifier used to run this CLI (handles dlx/npx URL and version installs). */
102
- export function detectPackageSpecifier(name: string): string {
101
+ /** @internal Builds the default MCP command for the current launch mode. */
102
+ function defaultCommand(name: string, runner: string): string {
103
+ return shouldUseBareCommand(name)
104
+ ? `${name} --mcp`
105
+ : `${runner} ${detectPackageSpecifier(name)} --mcp`
106
+ }
107
+
108
+ /** @internal Returns node_modules path details for the current entrypoint. */
109
+ function nodeModulesInfo(): { entry: string; root: string } | null {
110
+ const normalized = process.argv[1]?.replace(/\\/g, '/')
111
+ const match = normalized?.match(/^(.+)\/node_modules\/(.+)$/)
112
+ if (!match) return null
113
+ return { root: match[1]!, entry: match[2]! }
114
+ }
115
+
116
+ /** @internal Uses the bare command only when the binary is expected on PATH. */
117
+ function shouldUseBareCommand(name: string): boolean {
103
118
  const bin = process.argv[1]
104
- if (!bin) return name
119
+ if (!bin) return false
120
+
121
+ const info = nodeModulesInfo()
122
+ if (info) return !info.entry.startsWith('.bin/') && !packageDependsOn(info.root, name)
105
123
 
106
- const match = bin.match(/^(.+)[/\\]node_modules[/\\]/)
107
- if (!match) return name
124
+ const file = bin.replace(/\\/g, '/').split('/').pop()
125
+ return file === name || file === `${name}.cmd` || file === `${name}.ps1`
126
+ }
108
127
 
128
+ /** @internal Checks whether the entrypoint came from a project dependency install. */
129
+ function packageDependsOn(root: string, name: string): boolean {
109
130
  try {
110
- const pkg = JSON.parse(readFileSync(join(match[1]!, 'package.json'), 'utf-8'))
131
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'))
132
+ const dependencyFields = [
133
+ pkg.dependencies,
134
+ pkg.devDependencies,
135
+ pkg.optionalDependencies,
136
+ pkg.peerDependencies,
137
+ ]
138
+ return dependencyFields.some((dependencies) => name in (dependencies ?? {}))
139
+ } catch {
140
+ return false
141
+ }
142
+ }
143
+
144
+ /** @internal Detects the package specifier used to run this CLI (handles dlx/npx URL and version installs). */
145
+ export function detectPackageSpecifier(name: string): string {
146
+ const info = nodeModulesInfo()
147
+ if (!info) return name
148
+
149
+ try {
150
+ const pkg = JSON.parse(readFileSync(join(info.root, 'package.json'), 'utf-8'))
111
151
  const deps = pkg.dependencies ?? {}
112
152
  const spec = deps[name]
113
153
  if (!spec || Object.keys(deps).length !== 1) return name
@@ -119,6 +159,30 @@ export function detectPackageSpecifier(name: string): string {
119
159
  return name
120
160
  }
121
161
 
162
+ /** Splits a command string into tokens, respecting single and double quotes. */
163
+ function splitCommand(input: string): string[] {
164
+ const tokens: string[] = []
165
+ let current = ''
166
+ let quote: string | null = null
167
+
168
+ for (let i = 0; i < input.length; i++) {
169
+ const ch = input[i]!
170
+ if (quote) {
171
+ if (ch === quote) quote = null
172
+ else current += ch
173
+ } else if (ch === '"' || ch === "'") {
174
+ quote = ch
175
+ } else if (ch === ' ') {
176
+ if (current) tokens.push(current)
177
+ current = ''
178
+ } else {
179
+ current += ch
180
+ }
181
+ }
182
+ if (current) tokens.push(current)
183
+ return tokens
184
+ }
185
+
122
186
  /** Promisified execFile with stderr in error message. */
123
187
  function exec(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
124
188
  return new Promise((resolve, reject) => {
@@ -1,4 +1,4 @@
1
- import { Cli, SyncSkills } from 'incur'
1
+ import { Cli, SyncSkills, z } from 'incur'
2
2
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
@@ -161,6 +161,45 @@ test('installed SKILL.md contains frontmatter', async () => {
161
161
  rmSync(tmp, { recursive: true, force: true })
162
162
  })
163
163
 
164
+ test('installed SKILL.md marks destructive commands', async () => {
165
+ const tmp = join(tmpdir(), `clac-destructive-test-${Date.now()}`)
166
+ mkdirSync(tmp, { recursive: true })
167
+
168
+ const cli = Cli.create('my-tool')
169
+ cli.command('destroy', {
170
+ description: 'Destroy data',
171
+ destructive: true,
172
+ hint: 'Deletes all data.',
173
+ run: () => ({}),
174
+ })
175
+ cli.command('status', {
176
+ description: 'Check status',
177
+ hint: 'Shows current status.',
178
+ run: () => ({}),
179
+ })
180
+
181
+ const commands = Cli.toCommands.get(cli)!
182
+ const installDir = join(tmp, 'install')
183
+ mkdirSync(join(installDir, '.agents', 'skills'), { recursive: true })
184
+
185
+ const result = await SyncSkills.sync('my-tool', commands, {
186
+ depth: 0,
187
+ global: false,
188
+ cwd: installDir,
189
+ })
190
+
191
+ const content = readFileSync(join(result.paths[0]!, 'SKILL.md'), 'utf8')
192
+ expect(content).toContain(
193
+ 'Deletes all data. Confirm with the user before executing this destructive command.',
194
+ )
195
+ expect(content).toContain('Shows current status.')
196
+ expect(content).not.toContain(
197
+ 'Shows current status. Confirm with the user before executing this destructive command.',
198
+ )
199
+
200
+ rmSync(tmp, { recursive: true, force: true })
201
+ })
202
+
164
203
  test('sync returns unquoted descriptions from YAML frontmatter', async () => {
165
204
  const tmp = join(tmpdir(), `clac-quoted-description-test-${Date.now()}`)
166
205
  mkdirSync(tmp, { recursive: true })
@@ -288,6 +327,44 @@ test('list includes root command skill', async () => {
288
327
  expect(names).toContain('test-ping')
289
328
  })
290
329
 
330
+ test('sync uses CLI skill projection for aliases, fetch gateways, examples, and output', async () => {
331
+ const tmp = join(tmpdir(), `clac-sync-drift-test-${Date.now()}`)
332
+ mkdirSync(tmp, { recursive: true })
333
+
334
+ const cli = Cli.create('tool')
335
+ .command('real', {
336
+ description: 'Real command',
337
+ aliases: ['r'],
338
+ options: z.object({ dryRun: z.boolean().default(false) }),
339
+ output: z.object({ value: z.string() }),
340
+ examples: [{ options: { dryRun: true }, description: 'Preview' }],
341
+ run: () => ({ value: 'ok' }),
342
+ })
343
+ .command('api', { description: 'Raw API', fetch: () => new Response('{}') })
344
+
345
+ const commands = Cli.toCommands.get(cli)!
346
+ const listed = await SyncSkills.list('tool', commands)
347
+ const names = listed.map((skill) => skill.name)
348
+ expect(names).toContain('tool-api')
349
+ expect(names).toContain('tool-real')
350
+ expect(names).not.toContain('tool-r')
351
+
352
+ const installDir = join(tmp, 'install')
353
+ mkdirSync(join(installDir, '.agents', 'skills'), { recursive: true })
354
+ const synced = await SyncSkills.sync('tool', commands, {
355
+ depth: 0,
356
+ global: false,
357
+ cwd: installDir,
358
+ })
359
+ const content = readFileSync(join(synced.paths[0]!, 'SKILL.md'), 'utf8')
360
+ expect(content).toContain('Preview')
361
+ expect(content).toContain('## Output')
362
+ expect(content).toContain('Fetch gateway. Pass path segments')
363
+ expect(content).not.toMatch(/^# tool r$/m)
364
+
365
+ rmSync(tmp, { recursive: true, force: true })
366
+ })
367
+
291
368
  test('list results are sorted alphabetically', async () => {
292
369
  const cli = Cli.create('test')
293
370
  cli.command('zebra', { description: 'Z command', run: () => ({}) })
package/src/SyncSkills.ts CHANGED
@@ -2,10 +2,11 @@ import fsSync from 'node:fs'
2
2
  import fs from 'node:fs/promises'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
- import { parse as yamlParse } from 'yaml'
6
5
 
7
- import { formatExamples } from './Cli.js'
6
+ import { collectSkillCommands, parseSkillFrontmatter } from './Cli.js'
8
7
  import * as Agents from './internal/agents.js'
8
+ import * as Yaml from './internal/yaml.js'
9
+ import type * as Mcp from './Mcp.js'
9
10
  import * as Skill from './Skill.js'
10
11
 
11
12
  /** Generates skill files from a command map and installs them natively. */
@@ -17,9 +18,12 @@ export async function sync(
17
18
  const { depth = 1, description, global = true } = options
18
19
  const cwd = options.cwd ?? (global ? resolvePackageRoot() : process.cwd())
19
20
 
21
+ // Pre-load yaml for the sync call paths below (`Skill.split`, `parseFrontmatter`).
22
+ await Yaml.load()
23
+
20
24
  const groups = new Map<string, string>()
21
25
  if (description) groups.set(name, description)
22
- const entries = collectEntries(commands, [], groups, options.rootCommand)
26
+ const entries = collectSkillCommands(commands, [], groups, options.rootCommand)
23
27
  const files = Skill.split(name, entries, depth, groups)
24
28
 
25
29
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), `incur-skills-${name}-`))
@@ -31,7 +35,7 @@ export async function sync(
31
35
  : path.join(tmpDir, 'SKILL.md')
32
36
  await fs.mkdir(path.dirname(filePath), { recursive: true })
33
37
  await fs.writeFile(filePath, `${file.content}\n`)
34
- const meta = parseFrontmatter(file.content)
38
+ const meta = parseSkillFrontmatter(file.content)
35
39
  skills.push({ name: meta.name ?? (file.dir || name), description: meta.description })
36
40
  }
37
41
 
@@ -42,7 +46,7 @@ export async function sync(
42
46
  for await (const match of fs.glob(globPattern, { cwd })) {
43
47
  try {
44
48
  const content = await fs.readFile(path.resolve(cwd, match), 'utf8')
45
- const meta = parseFrontmatter(content)
49
+ const meta = parseSkillFrontmatter(content)
46
50
  const skillName =
47
51
  pattern === '_root' ? (meta.name ?? name) : path.basename(path.dirname(match))
48
52
  const dest = path.join(tmpDir, skillName, 'SKILL.md')
@@ -68,7 +72,7 @@ export async function sync(
68
72
  }
69
73
 
70
74
  // Write skills hash + names for staleness detection
71
- const hashEntries = collectEntries(commands, [], undefined, options.rootCommand)
75
+ const hashEntries = collectSkillCommands(commands, [], new Map(), options.rootCommand)
72
76
  writeMeta(
73
77
  name,
74
78
  Skill.hash(hashEntries),
@@ -100,8 +104,10 @@ export declare namespace sync {
100
104
  | {
101
105
  description?: string | undefined
102
106
  args?: any
107
+ destructive?: boolean | undefined
103
108
  env?: any
104
109
  hint?: string | undefined
110
+ mcp?: { annotations?: Mcp.ToolAnnotations | undefined } | undefined
105
111
  options?: any
106
112
  output?: any
107
113
  examples?: any[] | undefined
@@ -137,16 +143,19 @@ export async function list(
137
143
  const { depth = 1, description } = options
138
144
  const cwd = options.cwd ?? process.cwd()
139
145
 
146
+ // Pre-load yaml for the sync call paths below (`Skill.split`, `parseFrontmatter`).
147
+ await Yaml.load()
148
+
140
149
  const groups = new Map<string, string>()
141
150
  if (description) groups.set(name, description)
142
- const entries = collectEntries(commands, [], groups, options.rootCommand)
151
+ const entries = collectSkillCommands(commands, [], groups, options.rootCommand)
143
152
  const files = Skill.split(name, entries, depth, groups)
144
153
 
145
154
  const skills: list.Skill[] = []
146
155
  const installed = readInstalledSkills(name, { cwd })
147
156
 
148
157
  for (const file of files) {
149
- const meta = parseFrontmatter(file.content)
158
+ const meta = parseSkillFrontmatter(file.content)
150
159
  const skillName = meta.name ?? (file.dir || name)
151
160
  skills.push({
152
161
  name: skillName,
@@ -162,7 +171,7 @@ export async function list(
162
171
  for await (const match of fs.glob(globPattern, { cwd })) {
163
172
  try {
164
173
  const content = await fs.readFile(path.resolve(cwd, match), 'utf8')
165
- const meta = parseFrontmatter(content)
174
+ const meta = parseSkillFrontmatter(content)
166
175
  const skillName =
167
176
  pattern === '_root' ? (meta.name ?? name) : path.basename(path.dirname(match))
168
177
  if (!skills.some((s) => s.name === skillName)) {
@@ -204,8 +213,10 @@ export declare namespace list {
204
213
  | {
205
214
  description?: string | undefined
206
215
  args?: any
216
+ destructive?: boolean | undefined
207
217
  env?: any
208
218
  hint?: string | undefined
219
+ mcp?: { annotations?: Mcp.ToolAnnotations | undefined } | undefined
209
220
  options?: any
210
221
  output?: any
211
222
  examples?: any[] | undefined
@@ -223,75 +234,6 @@ export declare namespace list {
223
234
  }
224
235
  }
225
236
 
226
- /** Recursively collects leaf commands as `Skill.CommandInfo`. */
227
- function collectEntries(
228
- commands: Map<string, any>,
229
- prefix: string[],
230
- groups: Map<string, string> = new Map(),
231
- rootCommand?:
232
- | {
233
- description?: string | undefined
234
- args?: any
235
- env?: any
236
- hint?: string | undefined
237
- options?: any
238
- output?: any
239
- examples?: any[] | undefined
240
- }
241
- | undefined,
242
- ): Skill.CommandInfo[] {
243
- const result: Skill.CommandInfo[] = []
244
- if (rootCommand) {
245
- const cmd: Skill.CommandInfo = {}
246
- if (rootCommand.description) cmd.description = rootCommand.description
247
- if (rootCommand.args) cmd.args = rootCommand.args
248
- if (rootCommand.env) cmd.env = rootCommand.env
249
- if (rootCommand.hint) cmd.hint = rootCommand.hint
250
- if (rootCommand.options) cmd.options = rootCommand.options
251
- if (rootCommand.output) cmd.output = rootCommand.output
252
- const examples = formatExamples(rootCommand.examples)
253
- if (examples) cmd.examples = examples
254
- result.push(cmd)
255
- }
256
- for (const [name, entry] of commands) {
257
- const entryPath = [...prefix, name]
258
- if ('_group' in entry && entry._group) {
259
- if (entry.description) groups.set(entryPath.join(' '), entry.description)
260
- result.push(...collectEntries(entry.commands, entryPath, groups))
261
- } else {
262
- const cmd: Skill.CommandInfo = { name: entryPath.join(' ') }
263
- if (entry.description) cmd.description = entry.description
264
- if (entry.args) cmd.args = entry.args
265
- if (entry.env) cmd.env = entry.env
266
- if (entry.hint) cmd.hint = entry.hint
267
- if (entry.options) cmd.options = entry.options
268
- if (entry.output) cmd.output = entry.output
269
- const examples = formatExamples(entry.examples)
270
- if (examples) {
271
- const cmdName = entryPath.join(' ')
272
- cmd.examples = examples.map((e) => ({
273
- ...e,
274
- command: e.command ? `${cmdName} ${e.command}` : cmdName,
275
- }))
276
- }
277
- result.push(cmd)
278
- }
279
- }
280
- return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
281
- }
282
-
283
- function parseFrontmatter(content: string): {
284
- description?: string | undefined
285
- name?: string | undefined
286
- } {
287
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
288
- if (!match) return {}
289
-
290
- const meta = yamlParse(match[1]!)
291
- if (!meta || typeof meta !== 'object') return {}
292
- return meta as { description?: string | undefined; name?: string | undefined }
293
- }
294
-
295
237
  /** Resolves the package root from the executing bin script (`process.argv[1]`). Walks up from the bin's directory looking for `package.json`. Falls back to `process.cwd()`. */
296
238
  function resolvePackageRoot(): string {
297
239
  const bin = process.argv[1]
@@ -70,6 +70,11 @@ cli.serve()
70
70
  expect(stdout).toContain('result: HELLO')
71
71
  })
72
72
 
73
+ test('runs command with --format yaml (lazy yaml import)', async () => {
74
+ const { stdout } = await exec(bin, ['ping', '--format', 'yaml'])
75
+ expect(stdout).toContain('pong: true')
76
+ })
77
+
73
78
  test('shows help', async () => {
74
79
  const { stdout } = await exec(bin, ['--help'])
75
80
  expect(stdout).toContain('test-cli')