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.
Files changed (115) hide show
  1. package/README.md +83 -22
  2. package/SKILL.md +6 -6
  3. package/dist/Cli.d.ts +46 -26
  4. package/dist/Cli.d.ts.map +1 -1
  5. package/dist/Cli.js +728 -441
  6. package/dist/Cli.js.map +1 -1
  7. package/dist/Completions.d.ts +4 -3
  8. package/dist/Completions.d.ts.map +1 -1
  9. package/dist/Completions.js +17 -10
  10. package/dist/Completions.js.map +1 -1
  11. package/dist/Fetch.d.ts.map +1 -1
  12. package/dist/Fetch.js +10 -9
  13. package/dist/Fetch.js.map +1 -1
  14. package/dist/Filter.js +0 -18
  15. package/dist/Filter.js.map +1 -1
  16. package/dist/Formatter.d.ts.map +1 -1
  17. package/dist/Formatter.js +6 -1
  18. package/dist/Formatter.js.map +1 -1
  19. package/dist/Help.d.ts +7 -1
  20. package/dist/Help.d.ts.map +1 -1
  21. package/dist/Help.js +44 -27
  22. package/dist/Help.js.map +1 -1
  23. package/dist/Mcp.d.ts +37 -5
  24. package/dist/Mcp.d.ts.map +1 -1
  25. package/dist/Mcp.js +71 -72
  26. package/dist/Mcp.js.map +1 -1
  27. package/dist/Openapi.d.ts.map +1 -1
  28. package/dist/Openapi.js +22 -14
  29. package/dist/Openapi.js.map +1 -1
  30. package/dist/Parser.d.ts +4 -0
  31. package/dist/Parser.d.ts.map +1 -1
  32. package/dist/Parser.js +70 -38
  33. package/dist/Parser.js.map +1 -1
  34. package/dist/Schema.d.ts +5 -1
  35. package/dist/Schema.d.ts.map +1 -1
  36. package/dist/Schema.js +13 -2
  37. package/dist/Schema.js.map +1 -1
  38. package/dist/Skill.d.ts +2 -1
  39. package/dist/Skill.d.ts.map +1 -1
  40. package/dist/Skill.js +33 -19
  41. package/dist/Skill.js.map +1 -1
  42. package/dist/Skillgen.js +1 -1
  43. package/dist/Skillgen.js.map +1 -1
  44. package/dist/SyncSkills.d.ts +48 -0
  45. package/dist/SyncSkills.d.ts.map +1 -1
  46. package/dist/SyncSkills.js +108 -10
  47. package/dist/SyncSkills.js.map +1 -1
  48. package/dist/Typegen.js +4 -2
  49. package/dist/Typegen.js.map +1 -1
  50. package/dist/bin.d.ts +2 -1
  51. package/dist/bin.d.ts.map +1 -1
  52. package/dist/bin.js +17 -2
  53. package/dist/bin.js.map +1 -1
  54. package/dist/internal/command.d.ts +170 -0
  55. package/dist/internal/command.d.ts.map +1 -0
  56. package/dist/internal/command.js +292 -0
  57. package/dist/internal/command.js.map +1 -0
  58. package/dist/internal/configSchema.d.ts +8 -0
  59. package/dist/internal/configSchema.d.ts.map +1 -0
  60. package/dist/internal/configSchema.js +57 -0
  61. package/dist/internal/configSchema.js.map +1 -0
  62. package/dist/internal/dereference.d.ts +12 -0
  63. package/dist/internal/dereference.d.ts.map +1 -0
  64. package/dist/internal/dereference.js +71 -0
  65. package/dist/internal/dereference.js.map +1 -0
  66. package/dist/internal/helpers.d.ts +9 -0
  67. package/dist/internal/helpers.d.ts.map +1 -0
  68. package/dist/internal/helpers.js +54 -0
  69. package/dist/internal/helpers.js.map +1 -0
  70. package/dist/middleware.d.ts +6 -8
  71. package/dist/middleware.d.ts.map +1 -1
  72. package/dist/middleware.js +1 -1
  73. package/dist/middleware.js.map +1 -1
  74. package/examples/npm/.npmrc.json +21 -0
  75. package/examples/npm/config.schema.json +134 -0
  76. package/package.json +6 -29
  77. package/src/Cli.test-d.ts +44 -33
  78. package/src/Cli.test.ts +1231 -101
  79. package/src/Cli.ts +877 -569
  80. package/src/Completions.test.ts +136 -12
  81. package/src/Completions.ts +18 -13
  82. package/src/Fetch.test.ts +21 -0
  83. package/src/Fetch.ts +8 -10
  84. package/src/Filter.ts +0 -17
  85. package/src/Formatter.test.ts +15 -2
  86. package/src/Formatter.ts +5 -1
  87. package/src/Help.test.ts +184 -20
  88. package/src/Help.ts +52 -28
  89. package/src/Mcp.test.ts +159 -0
  90. package/src/Mcp.ts +108 -86
  91. package/src/Openapi.test.ts +17 -5
  92. package/src/Openapi.ts +21 -15
  93. package/src/Parser.test-d.ts +22 -0
  94. package/src/Parser.test.ts +89 -0
  95. package/src/Parser.ts +87 -36
  96. package/src/Schema.test.ts +29 -0
  97. package/src/Schema.ts +12 -2
  98. package/src/Skill.test.ts +87 -6
  99. package/src/Skill.ts +38 -21
  100. package/src/Skillgen.ts +1 -1
  101. package/src/SyncMcp.test.ts +6 -8
  102. package/src/SyncSkills.test.ts +146 -3
  103. package/src/SyncSkills.ts +191 -10
  104. package/src/Typegen.test.ts +15 -0
  105. package/src/Typegen.ts +4 -2
  106. package/src/bin.ts +21 -2
  107. package/src/e2e.test.ts +188 -98
  108. package/src/internal/command.ts +449 -0
  109. package/src/internal/configSchema.test.ts +193 -0
  110. package/src/internal/configSchema.ts +66 -0
  111. package/src/internal/dereference.test.ts +695 -0
  112. package/src/internal/dereference.ts +75 -0
  113. package/src/internal/helpers.test.ts +75 -0
  114. package/src/internal/helpers.ts +59 -0
  115. package/src/middleware.ts +5 -12
@@ -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(name, Skill.hash(hashEntries), [...currentNames])
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(fsSync.realpathSync(bin))
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(file, JSON.stringify({ hash, skills, at: new Date().toISOString() }) + '\n')
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(name: string): { hash: string; skills?: string[] } | undefined {
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
@@ -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
- await Typegen.generate(entry, output)
27
- return { dir, entry, output }
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