incur 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -22
- package/SKILL.md +6 -6
- package/dist/Cli.d.ts +46 -26
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +728 -441
- package/dist/Cli.js.map +1 -1
- package/dist/Completions.d.ts +4 -3
- package/dist/Completions.d.ts.map +1 -1
- package/dist/Completions.js +17 -10
- package/dist/Completions.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/Filter.js +0 -18
- package/dist/Filter.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 +7 -1
- package/dist/Help.d.ts.map +1 -1
- package/dist/Help.js +44 -27
- package/dist/Help.js.map +1 -1
- package/dist/Mcp.d.ts +37 -5
- package/dist/Mcp.d.ts.map +1 -1
- package/dist/Mcp.js +71 -72
- package/dist/Mcp.js.map +1 -1
- package/dist/Openapi.d.ts.map +1 -1
- package/dist/Openapi.js +22 -14
- package/dist/Openapi.js.map +1 -1
- package/dist/Parser.d.ts +4 -0
- package/dist/Parser.d.ts.map +1 -1
- package/dist/Parser.js +70 -38
- package/dist/Parser.js.map +1 -1
- package/dist/Schema.d.ts +5 -1
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +13 -2
- package/dist/Schema.js.map +1 -1
- package/dist/Skill.d.ts +2 -1
- package/dist/Skill.d.ts.map +1 -1
- package/dist/Skill.js +33 -19
- 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 +48 -0
- package/dist/SyncSkills.d.ts.map +1 -1
- package/dist/SyncSkills.js +108 -10
- package/dist/SyncSkills.js.map +1 -1
- package/dist/Typegen.js +4 -2
- package/dist/Typegen.js.map +1 -1
- package/dist/bin.d.ts +2 -1
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +17 -2
- package/dist/bin.js.map +1 -1
- package/dist/internal/command.d.ts +170 -0
- package/dist/internal/command.d.ts.map +1 -0
- package/dist/internal/command.js +292 -0
- package/dist/internal/command.js.map +1 -0
- package/dist/internal/configSchema.d.ts +8 -0
- package/dist/internal/configSchema.d.ts.map +1 -0
- package/dist/internal/configSchema.js +57 -0
- package/dist/internal/configSchema.js.map +1 -0
- package/dist/internal/dereference.d.ts +12 -0
- package/dist/internal/dereference.d.ts.map +1 -0
- package/dist/internal/dereference.js +71 -0
- package/dist/internal/dereference.js.map +1 -0
- package/dist/internal/helpers.d.ts +9 -0
- package/dist/internal/helpers.d.ts.map +1 -0
- package/dist/internal/helpers.js +54 -0
- package/dist/internal/helpers.js.map +1 -0
- package/dist/middleware.d.ts +6 -8
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +1 -1
- package/dist/middleware.js.map +1 -1
- package/examples/npm/.npmrc.json +21 -0
- package/examples/npm/config.schema.json +134 -0
- package/package.json +6 -29
- package/src/Cli.test-d.ts +44 -33
- package/src/Cli.test.ts +1231 -101
- package/src/Cli.ts +877 -569
- package/src/Completions.test.ts +136 -12
- package/src/Completions.ts +18 -13
- package/src/Fetch.test.ts +21 -0
- package/src/Fetch.ts +8 -10
- package/src/Filter.ts +0 -17
- package/src/Formatter.test.ts +15 -2
- package/src/Formatter.ts +5 -1
- package/src/Help.test.ts +184 -20
- package/src/Help.ts +52 -28
- package/src/Mcp.test.ts +159 -0
- package/src/Mcp.ts +108 -86
- package/src/Openapi.test.ts +17 -5
- package/src/Openapi.ts +21 -15
- package/src/Parser.test-d.ts +22 -0
- package/src/Parser.test.ts +89 -0
- package/src/Parser.ts +87 -36
- package/src/Schema.test.ts +29 -0
- package/src/Schema.ts +12 -2
- package/src/Skill.test.ts +87 -6
- package/src/Skill.ts +38 -21
- package/src/Skillgen.ts +1 -1
- package/src/SyncMcp.test.ts +6 -8
- package/src/SyncSkills.test.ts +146 -3
- package/src/SyncSkills.ts +191 -10
- package/src/Typegen.test.ts +15 -0
- package/src/Typegen.ts +4 -2
- package/src/bin.ts +21 -2
- package/src/e2e.test.ts +188 -98
- package/src/internal/command.ts +449 -0
- package/src/internal/configSchema.test.ts +193 -0
- package/src/internal/configSchema.ts +66 -0
- package/src/internal/dereference.test.ts +695 -0
- package/src/internal/dereference.ts +75 -0
- package/src/internal/helpers.test.ts +75 -0
- package/src/internal/helpers.ts +59 -0
- package/src/middleware.ts +5 -12
package/src/SyncSkills.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Cli, SyncSkills } from 'incur'
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
|
|
@@ -34,8 +34,8 @@ test('generates skill files and installs to canonical location', async () => {
|
|
|
34
34
|
})
|
|
35
35
|
|
|
36
36
|
expect(result.skills.length).toBeGreaterThan(0)
|
|
37
|
-
expect(result.skills.map((s) => s.name)).toContain('greet')
|
|
38
|
-
expect(result.skills.map((s) => s.name)).toContain('ping')
|
|
37
|
+
expect(result.skills.map((s) => s.name)).toContain('test-greet')
|
|
38
|
+
expect(result.skills.map((s) => s.name)).toContain('test-ping')
|
|
39
39
|
|
|
40
40
|
// Verify skills were installed to canonical location
|
|
41
41
|
for (const p of result.paths) {
|
|
@@ -69,6 +69,41 @@ test('uses custom depth', async () => {
|
|
|
69
69
|
rmSync(tmp, { recursive: true, force: true })
|
|
70
70
|
})
|
|
71
71
|
|
|
72
|
+
test('sync results are sorted alphabetically', async () => {
|
|
73
|
+
const tmp = join(tmpdir(), `clac-sync-sort-test-${Date.now()}`)
|
|
74
|
+
mkdirSync(tmp, { recursive: true })
|
|
75
|
+
|
|
76
|
+
const cli = Cli.create('test')
|
|
77
|
+
const commands = Cli.toCommands.get(cli)!
|
|
78
|
+
const installDir = join(tmp, 'install')
|
|
79
|
+
mkdirSync(join(installDir, '.agents', 'skills'), { recursive: true })
|
|
80
|
+
|
|
81
|
+
mkdirSync(join(installDir, 'zeta'), { recursive: true })
|
|
82
|
+
writeFileSync(
|
|
83
|
+
join(installDir, 'zeta', 'SKILL.md'),
|
|
84
|
+
['---', 'name: zeta', 'description: Z skill.', '---', '', '# zeta'].join('\n'),
|
|
85
|
+
)
|
|
86
|
+
writeFileSync(
|
|
87
|
+
join(installDir, 'SKILL.md'),
|
|
88
|
+
['---', 'name: test', 'description: Root skill.', '---', '', '# test'].join('\n'),
|
|
89
|
+
)
|
|
90
|
+
mkdirSync(join(installDir, 'alpha'), { recursive: true })
|
|
91
|
+
writeFileSync(
|
|
92
|
+
join(installDir, 'alpha', 'SKILL.md'),
|
|
93
|
+
['---', 'name: alpha', 'description: A skill.', '---', '', '# alpha'].join('\n'),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const result = await SyncSkills.sync('test', commands, {
|
|
97
|
+
global: false,
|
|
98
|
+
cwd: installDir,
|
|
99
|
+
include: ['zeta', '_root', 'alpha'],
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
expect(result.skills.map((s) => s.name)).toEqual(['alpha', 'test', 'zeta'])
|
|
103
|
+
|
|
104
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
105
|
+
})
|
|
106
|
+
|
|
72
107
|
test('writes hash after successful sync', async () => {
|
|
73
108
|
const tmp = join(tmpdir(), `clac-hash-test-${Date.now()}`)
|
|
74
109
|
mkdirSync(tmp, { recursive: true })
|
|
@@ -125,3 +160,111 @@ test('installed SKILL.md contains frontmatter', async () => {
|
|
|
125
160
|
|
|
126
161
|
rmSync(tmp, { recursive: true, force: true })
|
|
127
162
|
})
|
|
163
|
+
|
|
164
|
+
test('list returns skills from command map', async () => {
|
|
165
|
+
const cli = Cli.create('test', { description: 'A test CLI' })
|
|
166
|
+
cli.command('ping', { description: 'Health check', run: () => ({}) })
|
|
167
|
+
cli.command('greet', { description: 'Say hello', run: () => ({}) })
|
|
168
|
+
|
|
169
|
+
const commands = Cli.toCommands.get(cli)!
|
|
170
|
+
const result = await SyncSkills.list('test', commands)
|
|
171
|
+
|
|
172
|
+
expect(result.length).toBeGreaterThan(0)
|
|
173
|
+
const names = result.map((s) => s.name)
|
|
174
|
+
expect(names).toContain('test-ping')
|
|
175
|
+
expect(names).toContain('test-greet')
|
|
176
|
+
for (const s of result) {
|
|
177
|
+
expect(s.installed).toBe(false)
|
|
178
|
+
expect(s.description).toBeDefined()
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('list shows installed status after sync', async () => {
|
|
183
|
+
const tmp = join(tmpdir(), `clac-list-test-${Date.now()}`)
|
|
184
|
+
mkdirSync(tmp, { recursive: true })
|
|
185
|
+
process.env.XDG_DATA_HOME = tmp
|
|
186
|
+
|
|
187
|
+
const cli = Cli.create('test')
|
|
188
|
+
cli.command('ping', { description: 'Ping', run: () => ({}) })
|
|
189
|
+
|
|
190
|
+
const commands = Cli.toCommands.get(cli)!
|
|
191
|
+
const installDir = join(tmp, 'install')
|
|
192
|
+
mkdirSync(join(installDir, '.agents', 'skills'), { recursive: true })
|
|
193
|
+
|
|
194
|
+
// Sync first to install
|
|
195
|
+
await SyncSkills.sync('test', commands, {
|
|
196
|
+
global: false,
|
|
197
|
+
cwd: installDir,
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// Now list should show installed
|
|
201
|
+
const result = await SyncSkills.list('test', commands)
|
|
202
|
+
expect(result.length).toBeGreaterThan(0)
|
|
203
|
+
for (const s of result) expect(s.installed).toBe(true)
|
|
204
|
+
|
|
205
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('list shows not installed when synced skills are removed', async () => {
|
|
209
|
+
const tmp = join(tmpdir(), `clac-list-missing-test-${Date.now()}`)
|
|
210
|
+
mkdirSync(tmp, { recursive: true })
|
|
211
|
+
process.env.XDG_DATA_HOME = tmp
|
|
212
|
+
|
|
213
|
+
const cli = Cli.create('test')
|
|
214
|
+
cli.command('ping', { description: 'Ping', run: () => ({}) })
|
|
215
|
+
|
|
216
|
+
const commands = Cli.toCommands.get(cli)!
|
|
217
|
+
const installDir = join(tmp, 'install')
|
|
218
|
+
mkdirSync(join(installDir, '.agents', 'skills'), { recursive: true })
|
|
219
|
+
|
|
220
|
+
const sync = await SyncSkills.sync('test', commands, {
|
|
221
|
+
global: false,
|
|
222
|
+
cwd: installDir,
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
rmSync(sync.paths[0]!, { recursive: true, force: true })
|
|
226
|
+
|
|
227
|
+
const result = await SyncSkills.list('test', commands)
|
|
228
|
+
expect(result).toHaveLength(1)
|
|
229
|
+
expect(result[0]!.installed).toBe(false)
|
|
230
|
+
|
|
231
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('list returns empty for CLI with no commands', async () => {
|
|
235
|
+
const cli = Cli.create('empty')
|
|
236
|
+
const commands = Cli.toCommands.get(cli)!
|
|
237
|
+
const result = await SyncSkills.list('empty', commands)
|
|
238
|
+
expect(result).toHaveLength(0)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('list includes root command skill', async () => {
|
|
242
|
+
const cli = Cli.create('test', {
|
|
243
|
+
description: 'A test CLI',
|
|
244
|
+
run: () => ({ ok: true }),
|
|
245
|
+
})
|
|
246
|
+
cli.command('ping', { description: 'Health check', run: () => ({}) })
|
|
247
|
+
|
|
248
|
+
const commands = Cli.toCommands.get(cli)!
|
|
249
|
+
const rootCommand = Cli.toRootDefinition.get(cli as any)!
|
|
250
|
+
const result = await SyncSkills.list('test', commands, {
|
|
251
|
+
description: 'A test CLI',
|
|
252
|
+
rootCommand,
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
const names = result.map((s) => s.name)
|
|
256
|
+
expect(names).toContain('test')
|
|
257
|
+
expect(names).toContain('test-ping')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
test('list results are sorted alphabetically', async () => {
|
|
261
|
+
const cli = Cli.create('test')
|
|
262
|
+
cli.command('zebra', { description: 'Z command', run: () => ({}) })
|
|
263
|
+
cli.command('alpha', { description: 'A command', run: () => ({}) })
|
|
264
|
+
cli.command('middle', { description: 'M command', run: () => ({}) })
|
|
265
|
+
|
|
266
|
+
const commands = Cli.toCommands.get(cli)!
|
|
267
|
+
const result = await SyncSkills.list('test', commands)
|
|
268
|
+
const names = result.map((s) => s.name)
|
|
269
|
+
expect(names).toEqual([...names].sort())
|
|
270
|
+
})
|
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}-`))
|
|
@@ -30,8 +30,9 @@ export async function sync(
|
|
|
30
30
|
: path.join(tmpDir, 'SKILL.md')
|
|
31
31
|
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
32
32
|
await fs.writeFile(filePath, `${file.content}\n`)
|
|
33
|
+
const nameMatch = file.content.match(/^name:\s*(.+)$/m)
|
|
33
34
|
const descMatch = file.content.match(/^description:\s*(.+)$/m)
|
|
34
|
-
skills.push({ name: file.dir || name, description: descMatch?.[1] })
|
|
35
|
+
skills.push({ name: nameMatch?.[1] ?? (file.dir || name), description: descMatch?.[1] })
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
// Include additional SKILL.md files matched by glob patterns
|
|
@@ -69,10 +70,15 @@ export async function sync(
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
// Write skills hash + names for staleness detection
|
|
72
|
-
const hashEntries = collectEntries(commands, [])
|
|
73
|
-
writeMeta(
|
|
73
|
+
const hashEntries = collectEntries(commands, [], undefined, options.rootCommand)
|
|
74
|
+
writeMeta(
|
|
75
|
+
name,
|
|
76
|
+
Skill.hash(hashEntries),
|
|
77
|
+
[...currentNames],
|
|
78
|
+
[...paths, ...agents.map((agent) => agent.path)],
|
|
79
|
+
)
|
|
74
80
|
|
|
75
|
-
return { skills, paths, agents }
|
|
81
|
+
return { skills: skills.sort((a, b) => a.name.localeCompare(b.name)), paths, agents }
|
|
76
82
|
} finally {
|
|
77
83
|
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
78
84
|
}
|
|
@@ -91,6 +97,18 @@ export declare namespace sync {
|
|
|
91
97
|
global?: boolean | undefined
|
|
92
98
|
/** Glob patterns for directories containing SKILL.md files to include (e.g. `"skills/*"`, `"my-skill"`). Skill name is the parent directory name. */
|
|
93
99
|
include?: string[] | undefined
|
|
100
|
+
/** Root command definition (when the CLI itself has a `run` handler). */
|
|
101
|
+
rootCommand?:
|
|
102
|
+
| {
|
|
103
|
+
description?: string | undefined
|
|
104
|
+
args?: any
|
|
105
|
+
env?: any
|
|
106
|
+
hint?: string | undefined
|
|
107
|
+
options?: any
|
|
108
|
+
output?: any
|
|
109
|
+
examples?: any[] | undefined
|
|
110
|
+
}
|
|
111
|
+
| undefined
|
|
94
112
|
}
|
|
95
113
|
/** Result of a sync operation. */
|
|
96
114
|
type Result = {
|
|
@@ -112,13 +130,133 @@ export declare namespace sync {
|
|
|
112
130
|
}
|
|
113
131
|
}
|
|
114
132
|
|
|
133
|
+
/** Lists skills derived from a CLI's command map with install status. */
|
|
134
|
+
export async function list(
|
|
135
|
+
name: string,
|
|
136
|
+
commands: Map<string, any>,
|
|
137
|
+
options: list.Options = {},
|
|
138
|
+
): Promise<list.Skill[]> {
|
|
139
|
+
const { depth = 1, description } = options
|
|
140
|
+
const cwd = options.cwd ?? process.cwd()
|
|
141
|
+
|
|
142
|
+
const groups = new Map<string, string>()
|
|
143
|
+
if (description) groups.set(name, description)
|
|
144
|
+
const entries = collectEntries(commands, [], groups, options.rootCommand)
|
|
145
|
+
const files = Skill.split(name, entries, depth, groups)
|
|
146
|
+
|
|
147
|
+
const skills: list.Skill[] = []
|
|
148
|
+
const installed = readInstalledSkills(name, { cwd })
|
|
149
|
+
|
|
150
|
+
for (const file of files) {
|
|
151
|
+
const nameMatch = file.content.match(/^name:\s*(.+)$/m)
|
|
152
|
+
const descMatch = file.content.match(/^description:\s*(.+)$/m)
|
|
153
|
+
const skillName = nameMatch?.[1] ?? (file.dir || name)
|
|
154
|
+
skills.push({
|
|
155
|
+
name: skillName,
|
|
156
|
+
description: descMatch?.[1],
|
|
157
|
+
installed: installed.has(skillName),
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Include additional SKILL.md files matched by glob patterns
|
|
162
|
+
if (options.include) {
|
|
163
|
+
for (const pattern of options.include) {
|
|
164
|
+
const globPattern = pattern === '_root' ? 'SKILL.md' : path.join(pattern, 'SKILL.md')
|
|
165
|
+
for await (const match of fs.glob(globPattern, { cwd })) {
|
|
166
|
+
try {
|
|
167
|
+
const content = await fs.readFile(path.resolve(cwd, match), 'utf8')
|
|
168
|
+
const nameMatch = content.match(/^name:\s*(.+)$/m)
|
|
169
|
+
const skillName =
|
|
170
|
+
pattern === '_root' ? (nameMatch?.[1] ?? name) : path.basename(path.dirname(match))
|
|
171
|
+
if (!skills.some((s) => s.name === skillName)) {
|
|
172
|
+
const descMatch = content.match(/^description:\s*(.+)$/m)
|
|
173
|
+
skills.push({
|
|
174
|
+
name: skillName,
|
|
175
|
+
description: descMatch?.[1],
|
|
176
|
+
installed: installed.has(skillName),
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
} catch {}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return skills.sort((a, b) => a.name.localeCompare(b.name))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Returns whether any previously synced skills are still installed on disk. */
|
|
188
|
+
export function hasInstalledSkills(
|
|
189
|
+
name: string,
|
|
190
|
+
options: { cwd?: string | undefined } = {},
|
|
191
|
+
): boolean {
|
|
192
|
+
return readInstalledSkills(name, options).size > 0
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export declare namespace list {
|
|
196
|
+
/** Options for listing skills. */
|
|
197
|
+
type Options = {
|
|
198
|
+
/** Working directory for resolving `include` globs. Defaults to `process.cwd()`. */
|
|
199
|
+
cwd?: string | undefined
|
|
200
|
+
/** Grouping depth for skill files. Defaults to `1`. */
|
|
201
|
+
depth?: number | undefined
|
|
202
|
+
/** CLI description, used as the top-level group description. */
|
|
203
|
+
description?: string | undefined
|
|
204
|
+
/** Glob patterns for directories containing SKILL.md files to include. */
|
|
205
|
+
include?: string[] | undefined
|
|
206
|
+
/** Root command definition (when the CLI itself is a command). */
|
|
207
|
+
rootCommand?:
|
|
208
|
+
| {
|
|
209
|
+
description?: string | undefined
|
|
210
|
+
args?: any
|
|
211
|
+
env?: any
|
|
212
|
+
hint?: string | undefined
|
|
213
|
+
options?: any
|
|
214
|
+
output?: any
|
|
215
|
+
examples?: any[] | undefined
|
|
216
|
+
}
|
|
217
|
+
| undefined
|
|
218
|
+
}
|
|
219
|
+
/** A skill entry with install status. */
|
|
220
|
+
type Skill = {
|
|
221
|
+
/** Description extracted from the skill frontmatter. */
|
|
222
|
+
description?: string | undefined
|
|
223
|
+
/** Whether this skill is currently installed. */
|
|
224
|
+
installed: boolean
|
|
225
|
+
/** Skill name. */
|
|
226
|
+
name: string
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
115
230
|
/** Recursively collects leaf commands as `Skill.CommandInfo`. */
|
|
116
231
|
function collectEntries(
|
|
117
232
|
commands: Map<string, any>,
|
|
118
233
|
prefix: string[],
|
|
119
234
|
groups: Map<string, string> = new Map(),
|
|
235
|
+
rootCommand?:
|
|
236
|
+
| {
|
|
237
|
+
description?: string | undefined
|
|
238
|
+
args?: any
|
|
239
|
+
env?: any
|
|
240
|
+
hint?: string | undefined
|
|
241
|
+
options?: any
|
|
242
|
+
output?: any
|
|
243
|
+
examples?: any[] | undefined
|
|
244
|
+
}
|
|
245
|
+
| undefined,
|
|
120
246
|
): Skill.CommandInfo[] {
|
|
121
247
|
const result: Skill.CommandInfo[] = []
|
|
248
|
+
if (rootCommand) {
|
|
249
|
+
const cmd: Skill.CommandInfo = {}
|
|
250
|
+
if (rootCommand.description) cmd.description = rootCommand.description
|
|
251
|
+
if (rootCommand.args) cmd.args = rootCommand.args
|
|
252
|
+
if (rootCommand.env) cmd.env = rootCommand.env
|
|
253
|
+
if (rootCommand.hint) cmd.hint = rootCommand.hint
|
|
254
|
+
if (rootCommand.options) cmd.options = rootCommand.options
|
|
255
|
+
if (rootCommand.output) cmd.output = rootCommand.output
|
|
256
|
+
const examples = formatExamples(rootCommand.examples)
|
|
257
|
+
if (examples) cmd.examples = examples
|
|
258
|
+
result.push(cmd)
|
|
259
|
+
}
|
|
122
260
|
for (const [name, entry] of commands) {
|
|
123
261
|
const entryPath = [...prefix, name]
|
|
124
262
|
if ('_group' in entry && entry._group) {
|
|
@@ -143,14 +281,24 @@ function collectEntries(
|
|
|
143
281
|
result.push(cmd)
|
|
144
282
|
}
|
|
145
283
|
}
|
|
146
|
-
return result.sort((a, b) => a.name.localeCompare(b.name))
|
|
284
|
+
return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
|
|
147
285
|
}
|
|
148
286
|
|
|
149
287
|
/** 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()`. */
|
|
150
288
|
function resolvePackageRoot(): string {
|
|
151
289
|
const bin = process.argv[1]
|
|
152
290
|
if (!bin) return process.cwd()
|
|
153
|
-
let dir = path.dirname(
|
|
291
|
+
let dir = path.dirname(
|
|
292
|
+
(() => {
|
|
293
|
+
try {
|
|
294
|
+
// resolve symlinks for normal bin scripts
|
|
295
|
+
return fsSync.realpathSync(bin)
|
|
296
|
+
} catch {
|
|
297
|
+
// Bun compiled binaries use a virtual `/$bunfs/` path for argv[1]
|
|
298
|
+
return process.execPath
|
|
299
|
+
}
|
|
300
|
+
})(),
|
|
301
|
+
)
|
|
154
302
|
const root = path.parse(dir).root
|
|
155
303
|
while (dir !== root) {
|
|
156
304
|
try {
|
|
@@ -169,15 +317,20 @@ function hashPath(name: string): string {
|
|
|
169
317
|
}
|
|
170
318
|
|
|
171
319
|
/** @internal Writes the skills metadata for staleness detection and cleanup. */
|
|
172
|
-
function writeMeta(name: string, hash: string, skills: string[]) {
|
|
320
|
+
function writeMeta(name: string, hash: string, skills: string[], paths: string[]) {
|
|
173
321
|
const file = hashPath(name)
|
|
174
322
|
const dir = path.dirname(file)
|
|
175
323
|
if (!fsSync.existsSync(dir)) fsSync.mkdirSync(dir, { recursive: true })
|
|
176
|
-
fsSync.writeFileSync(
|
|
324
|
+
fsSync.writeFileSync(
|
|
325
|
+
file,
|
|
326
|
+
JSON.stringify({ hash, skills, paths, at: new Date().toISOString() }) + '\n',
|
|
327
|
+
)
|
|
177
328
|
}
|
|
178
329
|
|
|
179
330
|
/** @internal Reads the stored metadata for a CLI. */
|
|
180
|
-
function readMeta(
|
|
331
|
+
function readMeta(
|
|
332
|
+
name: string,
|
|
333
|
+
): { hash: string; paths?: string[] | undefined; skills?: string[] | undefined } | undefined {
|
|
181
334
|
try {
|
|
182
335
|
return JSON.parse(fsSync.readFileSync(hashPath(name), 'utf-8'))
|
|
183
336
|
} catch {
|
|
@@ -185,6 +338,34 @@ function readMeta(name: string): { hash: string; skills?: string[] } | undefined
|
|
|
185
338
|
}
|
|
186
339
|
}
|
|
187
340
|
|
|
341
|
+
/** Reads the names of previously synced skills that are still installed on disk. */
|
|
342
|
+
function readInstalledSkills(
|
|
343
|
+
name: string,
|
|
344
|
+
options: { cwd?: string | undefined } = {},
|
|
345
|
+
): Set<string> {
|
|
346
|
+
const meta = readMeta(name)
|
|
347
|
+
if (!meta?.skills?.length) return new Set()
|
|
348
|
+
|
|
349
|
+
if (meta.paths?.length) {
|
|
350
|
+
const installed = meta.paths
|
|
351
|
+
.filter((skillPath) => isInstalledSkillPath(skillPath))
|
|
352
|
+
.map((skillPath) => path.basename(skillPath))
|
|
353
|
+
return new Set(installed)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const cwd = options.cwd ?? process.cwd()
|
|
357
|
+
const bases = [path.join(os.homedir(), '.agents', 'skills'), path.join(cwd, '.agents', 'skills')]
|
|
358
|
+
const installed = meta.skills.filter((skill) =>
|
|
359
|
+
bases.some((base) => isInstalledSkillPath(path.join(base, skill))),
|
|
360
|
+
)
|
|
361
|
+
return new Set(installed)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Returns whether a skill directory currently contains a skill file. */
|
|
365
|
+
function isInstalledSkillPath(skillPath: string): boolean {
|
|
366
|
+
return fsSync.existsSync(path.join(skillPath, 'SKILL.md'))
|
|
367
|
+
}
|
|
368
|
+
|
|
188
369
|
/** Reads the stored skills hash for a CLI. Returns `undefined` if no hash exists. */
|
|
189
370
|
export function readHash(name: string): string | undefined {
|
|
190
371
|
return readMeta(name)?.hash
|
package/src/Typegen.test.ts
CHANGED
|
@@ -169,6 +169,21 @@ describe('fromCli', () => {
|
|
|
169
169
|
expect(output).toContain('config: { host: string; port: number }')
|
|
170
170
|
})
|
|
171
171
|
|
|
172
|
+
test('optional properties use optional modifier', () => {
|
|
173
|
+
const cli = Cli.create('test').command('create', {
|
|
174
|
+
args: z.object({ name: z.string() }),
|
|
175
|
+
options: z.object({
|
|
176
|
+
verbose: z.boolean().optional(),
|
|
177
|
+
output: z.string(),
|
|
178
|
+
}),
|
|
179
|
+
run: () => ({}),
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const output = Typegen.fromCli(cli)
|
|
183
|
+
expect(output).toContain('verbose?: boolean')
|
|
184
|
+
expect(output).toContain('output: string')
|
|
185
|
+
})
|
|
186
|
+
|
|
172
187
|
test('mixed top-level and grouped commands', () => {
|
|
173
188
|
const cli = Cli.create('test')
|
|
174
189
|
cli.command('ping', { run: () => ({}) })
|
package/src/Typegen.ts
CHANGED
|
@@ -49,8 +49,9 @@ function schemaToType(schema: z.ZodObject<any> | undefined): string {
|
|
|
49
49
|
const defs = (json.$defs ?? {}) as Record<string, Record<string, unknown>>
|
|
50
50
|
const properties = json.properties as Record<string, Record<string, unknown>> | undefined
|
|
51
51
|
if (!properties || Object.keys(properties).length === 0) return '{}'
|
|
52
|
+
const required = new Set((json.required as string[] | undefined) ?? [])
|
|
52
53
|
const entries = Object.entries(properties).map(
|
|
53
|
-
([key, value]) => `${key}: ${resolveType(value, defs)}`,
|
|
54
|
+
([key, value]) => `${key}${required.has(key) ? '' : '?'}: ${resolveType(value, defs)}`,
|
|
54
55
|
)
|
|
55
56
|
return `{ ${entries.join('; ')} }`
|
|
56
57
|
}
|
|
@@ -96,8 +97,9 @@ function resolveType(
|
|
|
96
97
|
case 'object': {
|
|
97
98
|
const properties = schema.properties as Record<string, Record<string, unknown>> | undefined
|
|
98
99
|
if (!properties || Object.keys(properties).length === 0) return '{}'
|
|
100
|
+
const required = new Set((schema.required as string[] | undefined) ?? [])
|
|
99
101
|
const entries = Object.entries(properties).map(
|
|
100
|
-
([key, value]) => `${key}: ${resolveType(value, defs)}`,
|
|
102
|
+
([key, value]) => `${key}${required.has(key) ? '' : '?'}: ${resolveType(value, defs)}`,
|
|
101
103
|
)
|
|
102
104
|
return `{ ${entries.join('; ')} }`
|
|
103
105
|
}
|
package/src/bin.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises'
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
import { z } from 'zod'
|
|
4
5
|
|
|
5
6
|
import * as Cli from './Cli.js'
|
|
7
|
+
import * as ConfigSchema from './internal/configSchema.js'
|
|
8
|
+
import { importCli } from './internal/utils.js'
|
|
6
9
|
import * as Typegen from './Typegen.js'
|
|
7
10
|
|
|
8
11
|
const cli = Cli.create('incur', {
|
|
@@ -15,6 +18,10 @@ const cli = Cli.create('incur', {
|
|
|
15
18
|
}).command('gen', {
|
|
16
19
|
description: 'Generate type definitions for development.',
|
|
17
20
|
options: z.object({
|
|
21
|
+
configSchema: z
|
|
22
|
+
.boolean()
|
|
23
|
+
.optional()
|
|
24
|
+
.describe('Generate config JSON Schema (auto-detected by default)'),
|
|
18
25
|
dir: z.string().optional().describe('Project root directory'),
|
|
19
26
|
entry: z.string().optional().describe('Entrypoint path (absolute)'),
|
|
20
27
|
output: z.string().optional().describe('Output path (absolute)'),
|
|
@@ -23,8 +30,20 @@ const cli = Cli.create('incur', {
|
|
|
23
30
|
const dir = c.options.dir ?? '.'
|
|
24
31
|
const entry = c.options.entry ?? dir
|
|
25
32
|
const output = c.options.output ?? path.join(dir, 'incur.generated.ts')
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
|
|
34
|
+
const cli = await importCli(entry)
|
|
35
|
+
await fs.writeFile(output, Typegen.fromCli(cli))
|
|
36
|
+
|
|
37
|
+
const result: Record<string, unknown> = { dir, entry, output }
|
|
38
|
+
|
|
39
|
+
const configSchema = c.options.configSchema ?? ConfigSchema.hasConfig(cli)
|
|
40
|
+
if (configSchema) {
|
|
41
|
+
const schemaOutput = path.join(path.dirname(output), 'config.schema.json')
|
|
42
|
+
await fs.writeFile(schemaOutput, JSON.stringify(ConfigSchema.fromCli(cli), null, 2) + '\n')
|
|
43
|
+
result.configSchema = schemaOutput
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return result
|
|
28
47
|
},
|
|
29
48
|
})
|
|
30
49
|
|