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.
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +82 -10
- package/dist/Cli.js.map +1 -1
- package/dist/Fetch.d.ts.map +1 -1
- package/dist/Fetch.js +10 -9
- package/dist/Fetch.js.map +1 -1
- package/dist/Formatter.d.ts.map +1 -1
- package/dist/Formatter.js +6 -1
- package/dist/Formatter.js.map +1 -1
- package/dist/Help.d.ts +1 -1
- package/dist/Help.d.ts.map +1 -1
- package/dist/Help.js +4 -3
- package/dist/Help.js.map +1 -1
- package/dist/Openapi.js +20 -12
- package/dist/Openapi.js.map +1 -1
- package/dist/Parser.d.ts +2 -0
- package/dist/Parser.d.ts.map +1 -1
- package/dist/Parser.js +1 -1
- package/dist/Parser.js.map +1 -1
- package/dist/Skill.d.ts +2 -1
- package/dist/Skill.d.ts.map +1 -1
- package/dist/Skill.js +30 -16
- package/dist/Skill.js.map +1 -1
- package/dist/Skillgen.js +1 -1
- package/dist/Skillgen.js.map +1 -1
- package/dist/SyncSkills.d.ts +34 -0
- package/dist/SyncSkills.d.ts.map +1 -1
- package/dist/SyncSkills.js +69 -4
- package/dist/SyncSkills.js.map +1 -1
- package/dist/internal/command.d.ts +4 -2
- package/dist/internal/command.d.ts.map +1 -1
- package/dist/internal/command.js +4 -0
- package/dist/internal/command.js.map +1 -1
- package/package.json +1 -1
- package/src/Cli.test.ts +84 -3
- package/src/Cli.ts +83 -9
- package/src/Fetch.test.ts +21 -0
- package/src/Fetch.ts +8 -10
- package/src/Formatter.test.ts +15 -2
- package/src/Formatter.ts +5 -1
- package/src/Help.test.ts +39 -1
- package/src/Help.ts +6 -5
- package/src/Openapi.test.ts +7 -0
- package/src/Openapi.ts +19 -13
- package/src/Parser.ts +1 -1
- package/src/Skill.test.ts +64 -0
- package/src/Skill.ts +34 -17
- package/src/Skillgen.ts +1 -1
- package/src/SyncSkills.test.ts +63 -0
- package/src/SyncSkills.ts +116 -3
- package/src/e2e.test.ts +2 -2
- package/src/internal/command.ts +4 -0
package/src/Openapi.test.ts
CHANGED
|
@@ -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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
108
|
-
|
|
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)
|
|
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
|
|
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
package/src/SyncSkills.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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])
|
package/src/internal/command.ts
CHANGED