incur 0.3.13 → 0.3.15

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 (52) hide show
  1. package/dist/Cli.d.ts.map +1 -1
  2. package/dist/Cli.js +82 -10
  3. package/dist/Cli.js.map +1 -1
  4. package/dist/Fetch.d.ts.map +1 -1
  5. package/dist/Fetch.js +10 -9
  6. package/dist/Fetch.js.map +1 -1
  7. package/dist/Formatter.d.ts.map +1 -1
  8. package/dist/Formatter.js +6 -1
  9. package/dist/Formatter.js.map +1 -1
  10. package/dist/Help.d.ts +1 -1
  11. package/dist/Help.d.ts.map +1 -1
  12. package/dist/Help.js +4 -3
  13. package/dist/Help.js.map +1 -1
  14. package/dist/Openapi.js +20 -12
  15. package/dist/Openapi.js.map +1 -1
  16. package/dist/Parser.d.ts +2 -0
  17. package/dist/Parser.d.ts.map +1 -1
  18. package/dist/Parser.js +1 -1
  19. package/dist/Parser.js.map +1 -1
  20. package/dist/Skill.d.ts +2 -1
  21. package/dist/Skill.d.ts.map +1 -1
  22. package/dist/Skill.js +30 -16
  23. package/dist/Skill.js.map +1 -1
  24. package/dist/Skillgen.js +1 -1
  25. package/dist/Skillgen.js.map +1 -1
  26. package/dist/SyncSkills.d.ts +34 -0
  27. package/dist/SyncSkills.d.ts.map +1 -1
  28. package/dist/SyncSkills.js +69 -4
  29. package/dist/SyncSkills.js.map +1 -1
  30. package/dist/internal/command.d.ts +4 -2
  31. package/dist/internal/command.d.ts.map +1 -1
  32. package/dist/internal/command.js +4 -0
  33. package/dist/internal/command.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/Cli.test.ts +84 -3
  36. package/src/Cli.ts +83 -9
  37. package/src/Fetch.test.ts +21 -0
  38. package/src/Fetch.ts +8 -10
  39. package/src/Formatter.test.ts +15 -2
  40. package/src/Formatter.ts +5 -1
  41. package/src/Help.test.ts +39 -1
  42. package/src/Help.ts +6 -5
  43. package/src/Openapi.test.ts +7 -0
  44. package/src/Openapi.ts +19 -13
  45. package/src/Parser.ts +1 -1
  46. package/src/Skill.test.ts +64 -0
  47. package/src/Skill.ts +34 -17
  48. package/src/Skillgen.ts +1 -1
  49. package/src/SyncSkills.test.ts +63 -0
  50. package/src/SyncSkills.ts +116 -3
  51. package/src/e2e.test.ts +2 -2
  52. package/src/internal/command.ts +4 -0
@@ -47,6 +47,13 @@ describe('generateCommands', () => {
47
47
  const cmd = commands.get('listUsers')!
48
48
  expect(cmd.description).toBe('List users')
49
49
  })
50
+
51
+ test('coerced number params preserve description', async () => {
52
+ const commands = await Openapi.generateCommands(spec, app.fetch)
53
+ const cmd = commands.get('listUsers')!
54
+ const limitSchema = cmd.options!.shape.limit
55
+ expect(limitSchema.description).toBe('Max results')
56
+ })
50
57
  })
51
58
 
52
59
  describe('cli integration', () => {
package/src/Openapi.ts CHANGED
@@ -186,20 +186,26 @@ function coerceIfNeeded(schema: z.ZodType): z.ZodType {
186
186
  const isOptional = schema instanceof z.ZodOptional
187
187
  const inner = isOptional ? schema.unwrap() : schema
188
188
 
189
- // Direct number/boolean
190
- if (inner instanceof z.ZodNumber)
191
- return isOptional ? z.coerce.number().optional() : z.coerce.number()
192
- if (inner instanceof z.ZodBoolean)
193
- return isOptional ? z.coerce.boolean().optional() : z.coerce.boolean()
194
-
195
- // Union containing number (e.g. type: ["number", "null"] from OpenAPI 3.1)
196
- if (inner instanceof z.ZodUnion) {
197
- const options = (inner as any)._zod?.def?.options as z.ZodType[] | undefined
198
- if (options?.some((o: z.ZodType) => o instanceof z.ZodNumber))
189
+ const coerced = (() => {
190
+ // Direct number
191
+ if (inner instanceof z.ZodNumber)
199
192
  return isOptional ? z.coerce.number().optional() : z.coerce.number()
200
- if (options?.some((o: z.ZodType) => o instanceof z.ZodBoolean))
193
+ // Direct boolean
194
+ if (inner instanceof z.ZodBoolean)
201
195
  return isOptional ? z.coerce.boolean().optional() : z.coerce.boolean()
202
- }
196
+ // Union containing number or boolean (e.g. type: ["number", "null"] from OpenAPI 3.1)
197
+ if (inner instanceof z.ZodUnion) {
198
+ const options = (inner as any)._zod?.def?.options as z.ZodType[] | undefined
199
+ if (options?.some((o: z.ZodType) => o instanceof z.ZodNumber))
200
+ return isOptional ? z.coerce.number().optional() : z.coerce.number()
201
+ if (options?.some((o: z.ZodType) => o instanceof z.ZodBoolean))
202
+ return isOptional ? z.coerce.boolean().optional() : z.coerce.boolean()
203
+ }
204
+ // No coercion needed
205
+ return undefined
206
+ })()
203
207
 
204
- return schema
208
+ if (!coerced) return schema
209
+ const desc = (schema as any).description ?? (inner as any).description
210
+ return desc ? coerced.describe(desc) : coerced
205
211
  }
package/src/Parser.ts CHANGED
@@ -315,7 +315,7 @@ function coerce(value: unknown, name: string, schema: z.ZodObject<any>): unknown
315
315
  }
316
316
 
317
317
  /** Returns the best available env source for the current runtime. */
318
- function defaultEnvSource(): Record<string, string | undefined> {
318
+ export function defaultEnvSource(): Record<string, string | undefined> {
319
319
  if (typeof globalThis !== 'undefined') {
320
320
  const g = globalThis as any
321
321
  if (g.process?.env) return g.process.env
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' },
@@ -349,4 +383,34 @@ describe('split', () => {
349
383
  expect(afterFrontmatter).not.toMatch(/^title:/m)
350
384
  expect(afterFrontmatter).not.toMatch(/^command:/m)
351
385
  })
386
+
387
+ test('depth 1 creates separate file for root command', () => {
388
+ const cmds: Skill.CommandInfo[] = [
389
+ {
390
+ description: 'Fetch a URL',
391
+ args: z.object({ url: z.string().describe('URL to fetch') }),
392
+ },
393
+ { name: 'auth login', description: 'Log in' },
394
+ { name: 'auth status', description: 'Check status' },
395
+ ]
396
+ const files = Skill.split('my-cli', cmds, 1)
397
+ expect(files.map((f) => f.dir)).toEqual(['auth', 'my-cli'])
398
+ const rootFile = files.find((f) => f.dir === 'my-cli')!
399
+ expect(rootFile.content).toContain('name: my-cli')
400
+ expect(rootFile.content).toContain('command: my-cli')
401
+ expect(rootFile.content).toContain('# my-cli')
402
+ expect(rootFile.content).toContain('| `url` | `string` | yes | URL to fetch |')
403
+ expect(rootFile.content).not.toContain('# my-cli ')
404
+ })
405
+
406
+ test('depth 0 includes root command in single file', () => {
407
+ const cmds: Skill.CommandInfo[] = [
408
+ { description: 'Fetch a URL' },
409
+ { name: 'ping', description: 'Health check' },
410
+ ]
411
+ const files = Skill.split('test', cmds, 0)
412
+ expect(files).toHaveLength(1)
413
+ expect(files[0]!.content).toContain('# test\n\nFetch a URL')
414
+ expect(files[0]!.content).toContain('# test ping')
415
+ })
352
416
  })
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
 
@@ -121,18 +133,14 @@ function renderGroup(
121
133
  const childDescs = cmds.map((c) => c.description).filter(Boolean) as string[]
122
134
  const descParts: string[] = []
123
135
  if (groupDesc) descParts.push(groupDesc.replace(/\.$/, ''))
124
- if (childDescs.length > 0) descParts.push(childDescs.join(', '))
136
+ if (childDescs.length > 0)
137
+ descParts.push(childDescs.map((d) => d.replace(/\.$/, '')).join(', '))
125
138
  const description =
126
139
  descParts.length > 0
127
140
  ? `${descParts.join('. ')}. Run \`${title} --help\` for usage details.`
128
141
  : `Run \`${title} --help\` for usage details.`
129
142
 
130
- const slug = title
131
- .toLowerCase()
132
- .replace(/[^a-z0-9-]+/g, '-')
133
- .replace(/-{2,}/g, '-')
134
- .replace(/^-|-$/g, '')
135
- const fm = ['---', `name: ${slug}`]
143
+ const fm = ['---', `name: ${slugify(title)}`]
136
144
  fm.push(`description: ${description}`)
137
145
  fm.push(`requires_bin: ${cli}`)
138
146
  fm.push(`command: ${title}`, '---')
@@ -143,7 +151,7 @@ function renderGroup(
143
151
 
144
152
  /** @internal Renders a command's heading and sections without frontmatter. */
145
153
  function renderCommandBody(cli: string, cmd: CommandInfo, level = 1): string {
146
- const fullName = `${cli} ${cmd.name}`
154
+ const fullName = !cmd.name ? cli : `${cli} ${cmd.name}`
147
155
  const sections: string[] = []
148
156
  const h = (n: number) => '#'.repeat(n)
149
157
 
@@ -287,6 +295,15 @@ function schemaToTable(schema: Record<string, unknown>, prefix = ''): string | u
287
295
  return `| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n${rows.join('\n')}`
288
296
  }
289
297
 
298
+ /** @internal Converts a string to a lowercase slug (e.g. `"my-cli"` → `"my-cli"`, `"My Tool"` → `"my-tool"`). */
299
+ function slugify(s: string): string {
300
+ return s
301
+ .toLowerCase()
302
+ .replace(/[^a-z0-9-]+/g, '-')
303
+ .replace(/-{2,}/g, '-')
304
+ .replace(/^-|-$/g, '')
305
+ }
306
+
290
307
  /** @internal Resolves a simple type name from a JSON Schema property. */
291
308
  function resolveTypeName(prop: Record<string, unknown> | undefined): string {
292
309
  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
  }
@@ -125,3 +125,66 @@ test('installed SKILL.md contains frontmatter', async () => {
125
125
 
126
126
  rmSync(tmp, { recursive: true, force: true })
127
127
  })
128
+
129
+ test('list returns skills from command map', async () => {
130
+ const cli = Cli.create('test', { description: 'A test CLI' })
131
+ cli.command('ping', { description: 'Health check', run: () => ({}) })
132
+ cli.command('greet', { description: 'Say hello', run: () => ({}) })
133
+
134
+ const commands = Cli.toCommands.get(cli)!
135
+ const result = await SyncSkills.list('test', commands)
136
+
137
+ expect(result.length).toBeGreaterThan(0)
138
+ const names = result.map((s) => s.name)
139
+ expect(names).toContain('test-ping')
140
+ expect(names).toContain('test-greet')
141
+ for (const s of result) {
142
+ expect(s.installed).toBe(false)
143
+ expect(s.description).toBeDefined()
144
+ }
145
+ })
146
+
147
+ test('list shows installed status after sync', async () => {
148
+ const tmp = join(tmpdir(), `clac-list-test-${Date.now()}`)
149
+ mkdirSync(tmp, { recursive: true })
150
+ process.env.XDG_DATA_HOME = tmp
151
+
152
+ const cli = Cli.create('test')
153
+ cli.command('ping', { description: 'Ping', run: () => ({}) })
154
+
155
+ const commands = Cli.toCommands.get(cli)!
156
+ const installDir = join(tmp, 'install')
157
+ mkdirSync(join(installDir, '.agents', 'skills'), { recursive: true })
158
+
159
+ // Sync first to install
160
+ await SyncSkills.sync('test', commands, {
161
+ global: false,
162
+ cwd: installDir,
163
+ })
164
+
165
+ // Now list should show installed
166
+ const result = await SyncSkills.list('test', commands)
167
+ expect(result.length).toBeGreaterThan(0)
168
+ for (const s of result) expect(s.installed).toBe(true)
169
+
170
+ rmSync(tmp, { recursive: true, force: true })
171
+ })
172
+
173
+ test('list returns empty for CLI with no commands', async () => {
174
+ const cli = Cli.create('empty')
175
+ const commands = Cli.toCommands.get(cli)!
176
+ const result = await SyncSkills.list('empty', commands)
177
+ expect(result).toHaveLength(0)
178
+ })
179
+
180
+ test('list results are sorted alphabetically', async () => {
181
+ const cli = Cli.create('test')
182
+ cli.command('zebra', { description: 'Z command', run: () => ({}) })
183
+ cli.command('alpha', { description: 'A command', run: () => ({}) })
184
+ cli.command('middle', { description: 'M command', run: () => ({}) })
185
+
186
+ const commands = Cli.toCommands.get(cli)!
187
+ const result = await SyncSkills.list('test', commands)
188
+ const names = result.map((s) => s.name)
189
+ expect(names).toEqual([...names].sort())
190
+ })
package/src/SyncSkills.ts CHANGED
@@ -18,7 +18,7 @@ export async function sync(
18
18
 
19
19
  const groups = new Map<string, string>()
20
20
  if (description) groups.set(name, description)
21
- const entries = collectEntries(commands, [], groups)
21
+ const entries = collectEntries(commands, [], groups, options.rootCommand)
22
22
  const files = Skill.split(name, entries, depth, groups)
23
23
 
24
24
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), `incur-skills-${name}-`))
@@ -70,7 +70,7 @@ export async function sync(
70
70
  }
71
71
 
72
72
  // Write skills hash + names for staleness detection
73
- const hashEntries = collectEntries(commands, [])
73
+ const hashEntries = collectEntries(commands, [], undefined, options.rootCommand)
74
74
  writeMeta(name, Skill.hash(hashEntries), [...currentNames])
75
75
 
76
76
  return { skills, paths, agents }
@@ -92,6 +92,18 @@ export declare namespace sync {
92
92
  global?: boolean | undefined
93
93
  /** Glob patterns for directories containing SKILL.md files to include (e.g. `"skills/*"`, `"my-skill"`). Skill name is the parent directory name. */
94
94
  include?: string[] | undefined
95
+ /** Root command definition (when the CLI itself has a `run` handler). */
96
+ rootCommand?:
97
+ | {
98
+ description?: string | undefined
99
+ args?: any
100
+ env?: any
101
+ hint?: string | undefined
102
+ options?: any
103
+ output?: any
104
+ examples?: any[] | undefined
105
+ }
106
+ | undefined
95
107
  }
96
108
  /** Result of a sync operation. */
97
109
  type Result = {
@@ -113,13 +125,114 @@ export declare namespace sync {
113
125
  }
114
126
  }
115
127
 
128
+ /** Lists skills derived from a CLI's command map with install status. */
129
+ export async function list(
130
+ name: string,
131
+ commands: Map<string, any>,
132
+ options: list.Options = {},
133
+ ): Promise<list.Skill[]> {
134
+ const { depth = 1, description } = options
135
+ const cwd = options.cwd ?? process.cwd()
136
+
137
+ const groups = new Map<string, string>()
138
+ if (description) groups.set(name, description)
139
+ const entries = collectEntries(commands, [], groups)
140
+ const files = Skill.split(name, entries, depth, groups)
141
+
142
+ const skills: list.Skill[] = []
143
+ const meta = readMeta(name)
144
+ const installed = new Set(meta?.skills)
145
+
146
+ for (const file of files) {
147
+ const nameMatch = file.content.match(/^name:\s*(.+)$/m)
148
+ const descMatch = file.content.match(/^description:\s*(.+)$/m)
149
+ const skillName = nameMatch?.[1] ?? (file.dir || name)
150
+ skills.push({
151
+ name: skillName,
152
+ description: descMatch?.[1],
153
+ installed: installed.has(skillName),
154
+ })
155
+ }
156
+
157
+ // Include additional SKILL.md files matched by glob patterns
158
+ if (options.include) {
159
+ for (const pattern of options.include) {
160
+ const globPattern = pattern === '_root' ? 'SKILL.md' : path.join(pattern, 'SKILL.md')
161
+ for await (const match of fs.glob(globPattern, { cwd })) {
162
+ try {
163
+ const content = await fs.readFile(path.resolve(cwd, match), 'utf8')
164
+ const nameMatch = content.match(/^name:\s*(.+)$/m)
165
+ const skillName =
166
+ pattern === '_root' ? (nameMatch?.[1] ?? name) : path.basename(path.dirname(match))
167
+ if (!skills.some((s) => s.name === skillName)) {
168
+ const descMatch = content.match(/^description:\s*(.+)$/m)
169
+ skills.push({
170
+ name: skillName,
171
+ description: descMatch?.[1],
172
+ installed: installed.has(skillName),
173
+ })
174
+ }
175
+ } catch {}
176
+ }
177
+ }
178
+ }
179
+
180
+ return skills.sort((a, b) => a.name.localeCompare(b.name))
181
+ }
182
+
183
+ export declare namespace list {
184
+ /** Options for listing skills. */
185
+ type Options = {
186
+ /** Working directory for resolving `include` globs. Defaults to `process.cwd()`. */
187
+ cwd?: string | undefined
188
+ /** Grouping depth for skill files. Defaults to `1`. */
189
+ depth?: number | undefined
190
+ /** CLI description, used as the top-level group description. */
191
+ description?: string | undefined
192
+ /** Glob patterns for directories containing SKILL.md files to include. */
193
+ include?: string[] | undefined
194
+ }
195
+ /** A skill entry with install status. */
196
+ type Skill = {
197
+ /** Description extracted from the skill frontmatter. */
198
+ description?: string | undefined
199
+ /** Whether this skill is currently installed. */
200
+ installed: boolean
201
+ /** Skill name. */
202
+ name: string
203
+ }
204
+ }
205
+
116
206
  /** Recursively collects leaf commands as `Skill.CommandInfo`. */
117
207
  function collectEntries(
118
208
  commands: Map<string, any>,
119
209
  prefix: string[],
120
210
  groups: Map<string, string> = new Map(),
211
+ rootCommand?:
212
+ | {
213
+ description?: string | undefined
214
+ args?: any
215
+ env?: any
216
+ hint?: string | undefined
217
+ options?: any
218
+ output?: any
219
+ examples?: any[] | undefined
220
+ }
221
+ | undefined,
121
222
  ): Skill.CommandInfo[] {
122
223
  const result: Skill.CommandInfo[] = []
224
+ if (rootCommand) {
225
+ const cmd: Skill.CommandInfo = {}
226
+ if (rootCommand.description) cmd.description = rootCommand.description
227
+ if (rootCommand.args) cmd.args = rootCommand.args
228
+ if (rootCommand.env) cmd.env = rootCommand.env
229
+ if (rootCommand.hint) cmd.hint = rootCommand.hint
230
+ if (rootCommand.options) cmd.options = rootCommand.options
231
+ if (rootCommand.output) cmd.output = rootCommand.output
232
+ const examples = formatExamples(rootCommand.examples)
233
+ if (examples) cmd.examples = examples
234
+ result.push(cmd)
235
+ }
123
236
  for (const [name, entry] of commands) {
124
237
  const entryPath = [...prefix, name]
125
238
  if ('_group' in entry && entry._group) {
@@ -144,7 +257,7 @@ function collectEntries(
144
257
  result.push(cmd)
145
258
  }
146
259
  }
147
- return result.sort((a, b) => a.name.localeCompare(b.name))
260
+ return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
148
261
  }
149
262
 
150
263
  /** 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()`. */
package/src/e2e.test.ts CHANGED
@@ -967,7 +967,7 @@ describe('help', () => {
967
967
  Integrations:
968
968
  completions Generate shell completion script
969
969
  mcp add Register as MCP server
970
- skills add Sync skill files to agents
970
+ skills Sync skill files to agents (add, list)
971
971
 
972
972
  Global Options:
973
973
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
@@ -1742,7 +1742,7 @@ describe('root command with subcommands', () => {
1742
1742
  Integrations:
1743
1743
  completions Generate shell completion script
1744
1744
  mcp add Register as MCP server
1745
- skills add Sync skill files to agents
1745
+ skills Sync skill files to agents (add, list)
1746
1746
 
1747
1747
  Global Options:
1748
1748
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
@@ -422,6 +422,10 @@ export const builtinCommands = [
422
422
  noGlobal: z.boolean().optional().describe('Install to project instead of globally'),
423
423
  }),
424
424
  }),
425
+ subcommand({
426
+ name: 'list',
427
+ description: 'List skills',
428
+ }),
425
429
  ],
426
430
  },
427
431
  ] satisfies {