incur 0.3.4 → 0.3.6

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 (68) hide show
  1. package/README.md +62 -1
  2. package/dist/Cli.d.ts +17 -7
  3. package/dist/Cli.d.ts.map +1 -1
  4. package/dist/Cli.js +435 -365
  5. package/dist/Cli.js.map +1 -1
  6. package/dist/Completions.d.ts +1 -2
  7. package/dist/Completions.d.ts.map +1 -1
  8. package/dist/Completions.js.map +1 -1
  9. package/dist/Filter.js +0 -18
  10. package/dist/Filter.js.map +1 -1
  11. package/dist/Help.d.ts +6 -0
  12. package/dist/Help.d.ts.map +1 -1
  13. package/dist/Help.js +35 -22
  14. package/dist/Help.js.map +1 -1
  15. package/dist/Mcp.d.ts +25 -5
  16. package/dist/Mcp.d.ts.map +1 -1
  17. package/dist/Mcp.js +61 -69
  18. package/dist/Mcp.js.map +1 -1
  19. package/dist/Parser.d.ts +2 -0
  20. package/dist/Parser.d.ts.map +1 -1
  21. package/dist/Parser.js +69 -37
  22. package/dist/Parser.js.map +1 -1
  23. package/dist/Skill.d.ts.map +1 -1
  24. package/dist/Skill.js +5 -1
  25. package/dist/Skill.js.map +1 -1
  26. package/dist/SyncSkills.d.ts.map +1 -1
  27. package/dist/SyncSkills.js +10 -1
  28. package/dist/SyncSkills.js.map +1 -1
  29. package/dist/bin.d.ts +1 -0
  30. package/dist/bin.d.ts.map +1 -1
  31. package/dist/bin.js +17 -2
  32. package/dist/bin.js.map +1 -1
  33. package/dist/internal/command.d.ts +118 -0
  34. package/dist/internal/command.d.ts.map +1 -0
  35. package/dist/internal/command.js +276 -0
  36. package/dist/internal/command.js.map +1 -0
  37. package/dist/internal/configSchema.d.ts +8 -0
  38. package/dist/internal/configSchema.d.ts.map +1 -0
  39. package/dist/internal/configSchema.js +57 -0
  40. package/dist/internal/configSchema.js.map +1 -0
  41. package/dist/internal/helpers.d.ts +5 -0
  42. package/dist/internal/helpers.d.ts.map +1 -0
  43. package/dist/internal/helpers.js +9 -0
  44. package/dist/internal/helpers.js.map +1 -0
  45. package/examples/npm/.npmrc.json +21 -0
  46. package/examples/npm/config.schema.json +137 -0
  47. package/package.json +1 -1
  48. package/src/Cli.test-d.ts +39 -0
  49. package/src/Cli.test.ts +704 -6
  50. package/src/Cli.ts +551 -448
  51. package/src/Completions.test.ts +35 -9
  52. package/src/Completions.ts +1 -2
  53. package/src/Filter.ts +0 -17
  54. package/src/Help.test.ts +77 -0
  55. package/src/Help.ts +39 -21
  56. package/src/Mcp.test.ts +143 -0
  57. package/src/Mcp.ts +92 -84
  58. package/src/Parser.test-d.ts +22 -0
  59. package/src/Parser.test.ts +89 -0
  60. package/src/Parser.ts +86 -35
  61. package/src/Skill.ts +5 -1
  62. package/src/SyncSkills.ts +11 -1
  63. package/src/bin.ts +21 -2
  64. package/src/e2e.test.ts +30 -17
  65. package/src/internal/command.ts +428 -0
  66. package/src/internal/configSchema.test.ts +193 -0
  67. package/src/internal/configSchema.ts +66 -0
  68. package/src/internal/helpers.ts +9 -0
package/src/Mcp.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import type { Readable, Writable } from 'node:stream'
4
+ import type { z } from 'zod'
4
5
 
6
+ import * as Command from './internal/command.js'
7
+ import type { Handler as MiddlewareHandler } from './middleware.js'
5
8
  import * as Schema from './Schema.js'
6
9
 
7
10
  /** Starts a stdio MCP server that exposes commands as tools. */
@@ -25,12 +28,20 @@ export async function serve(
25
28
  {
26
29
  ...(tool.description ? { description: tool.description } : undefined),
27
30
  ...(hasInput ? { inputSchema: mergedShape } : undefined),
28
- },
31
+ ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : undefined),
32
+ } as never,
29
33
  async (...callArgs: any[]) => {
30
34
  // registerTool passes (args, extra) when inputSchema is set, (extra) when not
31
35
  const params = hasInput ? (callArgs[0] as Record<string, unknown>) : {}
32
36
  const extra = hasInput ? callArgs[1] : callArgs[0]
33
- return callTool(tool, params, extra)
37
+ return callTool(tool, params, {
38
+ extra,
39
+ name,
40
+ version,
41
+ middlewares: options.middlewares,
42
+ env: options.env,
43
+ vars: options.vars,
44
+ })
34
45
  },
35
46
  )
36
47
  }
@@ -44,10 +55,18 @@ export async function serve(
44
55
  export declare namespace serve {
45
56
  /** Options for the MCP server. */
46
57
  type Options = {
58
+ /** CLI-level env schema. */
59
+ env?: z.ZodObject<any> | undefined
47
60
  /** Override input stream. Defaults to `process.stdin`. */
48
61
  input?: Readable | undefined
62
+ /** Middleware handlers registered on the root CLI. */
63
+ middlewares?: MiddlewareHandler[] | undefined
49
64
  /** Override output stream. Defaults to `process.stdout`. */
50
65
  output?: Writable | undefined
66
+ /** Vars schema for middleware variables. */
67
+ vars?: z.ZodObject<any> | undefined
68
+ /** CLI version string. */
69
+ version?: string | undefined
51
70
  }
52
71
  }
53
72
 
@@ -55,83 +74,73 @@ export declare namespace serve {
55
74
  export async function callTool(
56
75
  tool: ToolEntry,
57
76
  params: Record<string, unknown>,
58
- extra?: {
59
- _meta?: { progressToken?: string | number }
60
- sendNotification?: (n: any) => Promise<void>
61
- },
62
- ): Promise<{ content: { type: 'text'; text: string }[]; isError?: boolean }> {
63
- try {
64
- const { args, options } = splitParams(params, tool.command)
65
- const parsedArgs = tool.command.args ? tool.command.args.parse(args) : {}
66
- const parsedOptions = tool.command.options ? tool.command.options.parse(options) : {}
67
- const parsedEnv = tool.command.env ? tool.command.env.parse(process.env) : {}
68
-
69
- const sentinel = Symbol.for('incur.sentinel')
70
- const okFn = (data: unknown): never => ({ [sentinel]: 'ok', data }) as never
71
- const errorFn = (opts: { code: string; message: string }): never =>
72
- ({ [sentinel]: 'error', ...opts }) as never
73
-
74
- const raw = tool.command.run({
75
- args: parsedArgs,
76
- env: parsedEnv,
77
- options: parsedOptions,
78
- ok: okFn,
79
- error: errorFn,
80
- })
81
-
77
+ options: {
78
+ extra?: {
79
+ _meta?: { progressToken?: string | number }
80
+ sendNotification?: (n: any) => Promise<void>
81
+ }
82
+ name?: string | undefined
83
+ version?: string | undefined
84
+ middlewares?: MiddlewareHandler[] | undefined
85
+ env?: z.ZodObject<any> | undefined
86
+ vars?: z.ZodObject<any> | undefined
87
+ } = {},
88
+ ): Promise<{ content: { type: 'text'; text: string }[]; structuredContent?: Record<string, unknown>; isError?: boolean }> {
89
+ const allMiddleware = [
90
+ ...(options.middlewares ?? []),
91
+ ...((tool.middlewares as MiddlewareHandler[] | undefined) ?? []),
92
+ ...((tool.command.middleware as MiddlewareHandler[] | undefined) ?? []),
93
+ ]
94
+
95
+ const result = await Command.execute(tool.command, {
96
+ agent: true,
97
+ argv: [],
98
+ env: options.env,
99
+ format: 'json',
100
+ formatExplicit: true,
101
+ inputOptions: params,
102
+ middlewares: allMiddleware,
103
+ name: options.name ?? tool.name,
104
+ parseMode: 'flat',
105
+ path: tool.name,
106
+ vars: options.vars,
107
+ version: options.version,
108
+ })
109
+
110
+ if ('stream' in result) {
82
111
  // Streaming: send progress notifications per chunk, then return buffered result
83
- if (isAsyncGenerator(raw)) {
84
- const chunks: unknown[] = []
85
- const progressToken = extra?._meta?.progressToken
86
- let i = 0
87
- for await (const chunk of raw) {
88
- if (typeof chunk === 'object' && chunk !== null && sentinel in chunk) {
89
- const tagged = chunk as any
90
- if (tagged[sentinel] === 'error')
91
- return {
92
- content: [{ type: 'text', text: tagged.message ?? 'Command failed' }],
93
- isError: true,
94
- }
95
- }
112
+ const chunks: unknown[] = []
113
+ const progressToken = options.extra?._meta?.progressToken
114
+ let i = 0
115
+ try {
116
+ for await (const chunk of result.stream) {
96
117
  chunks.push(chunk)
97
- if (progressToken !== undefined && extra?.sendNotification)
98
- await extra.sendNotification({
118
+ if (progressToken !== undefined && options.extra?.sendNotification)
119
+ await options.extra.sendNotification({
99
120
  method: 'notifications/progress' as const,
100
121
  params: { progressToken, progress: ++i, message: JSON.stringify(chunk) },
101
122
  })
102
123
  }
103
- return { content: [{ type: 'text', text: JSON.stringify(chunks) }] }
104
- }
105
-
106
- const awaited = await raw
107
-
108
- if (typeof awaited === 'object' && awaited !== null && sentinel in awaited) {
109
- const tagged = awaited as any
110
- if (tagged[sentinel] === 'error')
111
- return {
112
- content: [{ type: 'text', text: tagged.message ?? 'Command failed' }],
113
- isError: true,
114
- }
115
- return { content: [{ type: 'text', text: JSON.stringify(tagged.data ?? null) }] }
124
+ } catch (err) {
125
+ return {
126
+ content: [{ type: 'text', text: err instanceof Error ? err.message : String(err) }],
127
+ isError: true,
128
+ }
116
129
  }
130
+ return { content: [{ type: 'text', text: JSON.stringify(chunks) }] }
131
+ }
117
132
 
118
- return { content: [{ type: 'text', text: JSON.stringify(awaited ?? null) }] }
119
- } catch (err) {
133
+ if (!result.ok)
120
134
  return {
121
- content: [{ type: 'text', text: err instanceof Error ? err.message : String(err) }],
135
+ content: [{ type: 'text', text: result.error.message ?? 'Command failed' }],
122
136
  isError: true,
123
137
  }
124
- }
125
- }
126
138
 
127
- /** @internal Type guard for async generators. */
128
- function isAsyncGenerator(value: unknown): value is AsyncGenerator<unknown, unknown, unknown> {
129
- return (
130
- typeof value === 'object' &&
131
- value !== null &&
132
- Symbol.asyncIterator in value &&
133
- typeof (value as any).next === 'function'
134
- )
139
+ const data = result.data ?? null
140
+ return {
141
+ content: [{ type: 'text', text: JSON.stringify(data) }],
142
+ ...(data !== null && tool.outputSchema ? { structuredContent: data as Record<string, unknown> } : undefined),
143
+ }
135
144
  }
136
145
 
137
146
  /** @internal A resolved tool entry from the command tree. */
@@ -139,21 +148,34 @@ export type ToolEntry = {
139
148
  name: string
140
149
  description?: string | undefined
141
150
  inputSchema: { type: 'object'; properties: Record<string, unknown>; required?: string[] }
151
+ outputSchema?: Record<string, unknown> | undefined
142
152
  command: any
153
+ middlewares?: MiddlewareHandler[] | undefined
143
154
  }
144
155
 
145
156
  /** @internal Recursively collects leaf commands as tool entries. */
146
- export function collectTools(commands: Map<string, any>, prefix: string[]): ToolEntry[] {
157
+ export function collectTools(
158
+ commands: Map<string, any>,
159
+ prefix: string[],
160
+ parentMiddlewares: MiddlewareHandler[] = [],
161
+ ): ToolEntry[] {
147
162
  const result: ToolEntry[] = []
148
163
  for (const [name, entry] of commands) {
149
164
  const path = [...prefix, name]
150
- if ('_group' in entry && entry._group) result.push(...collectTools(entry.commands, path))
151
- else {
165
+ if ('_group' in entry && entry._group) {
166
+ const groupMw = [
167
+ ...parentMiddlewares,
168
+ ...((entry.middlewares as MiddlewareHandler[] | undefined) ?? []),
169
+ ]
170
+ result.push(...collectTools(entry.commands, path, groupMw))
171
+ } else {
152
172
  result.push({
153
173
  name: path.join('_'),
154
174
  description: entry.description,
155
175
  inputSchema: buildToolSchema(entry.args, entry.options),
176
+ ...(entry.output ? { outputSchema: Schema.toJsonSchema(entry.output) as Record<string, unknown> } : undefined),
156
177
  command: entry,
178
+ ...(parentMiddlewares.length > 0 ? { middlewares: parentMiddlewares } : undefined),
157
179
  })
158
180
  }
159
181
  }
@@ -179,17 +201,3 @@ function buildToolSchema(
179
201
  return { type: 'object', properties }
180
202
  }
181
203
 
182
- /** @internal Splits flat params into args vs options using schema shapes. */
183
- function splitParams(
184
- params: Record<string, unknown>,
185
- command: any,
186
- ): { args: Record<string, unknown>; options: Record<string, unknown> } {
187
- const argKeys = new Set(command.args ? Object.keys(command.args.shape) : [])
188
- const a: Record<string, unknown> = {}
189
- const o: Record<string, unknown> = {}
190
- for (const [key, value] of Object.entries(params)) {
191
- if (argKeys.has(key)) a[key] = value
192
- else o[key] = value
193
- }
194
- return { args: a, options: o }
195
- }
@@ -43,3 +43,25 @@ test('narrows both args and options together', () => {
43
43
  expectTypeOf(result.args).toEqualTypeOf<{ repo: string }>()
44
44
  expectTypeOf(result.options).toEqualTypeOf<{ limit: number }>()
45
45
  })
46
+
47
+ test('defaults are typed from z.input of the options schema', () => {
48
+ const result = Parser.parse([], {
49
+ defaults: { limit: '5' },
50
+ options: z.object({ limit: z.coerce.number().default(30) }),
51
+ })
52
+ expectTypeOf(result.options).toEqualTypeOf<{ limit: number }>()
53
+ })
54
+
55
+ test('defaults do not leak any', () => {
56
+ type Options = z.ZodObject<{
57
+ limit: z.ZodDefault<z.ZodNumber>
58
+ saveDev: z.ZodOptional<z.ZodBoolean>
59
+ }>
60
+
61
+ expectTypeOf<Parser.parse.Options<undefined, Options>>().toEqualTypeOf<{
62
+ args?: undefined
63
+ alias?: Record<string, string> | undefined
64
+ defaults?: Partial<z.input<Options>> | undefined
65
+ options?: Options
66
+ }>()
67
+ })
@@ -252,4 +252,93 @@ describe('parse', () => {
252
252
  expect(result.args).toEqual({ repo: 'myrepo' })
253
253
  expect(result.options).toEqual({ limit: 5 })
254
254
  })
255
+
256
+ test('applies config defaults when argv omits an option', () => {
257
+ const result = Parser.parse([], {
258
+ defaults: { limit: 10 },
259
+ options: z.object({ limit: z.number().default(30) }),
260
+ })
261
+ expect(result.options).toEqual({ limit: 10 })
262
+ })
263
+
264
+ test('argv overrides config defaults', () => {
265
+ const result = Parser.parse(['--limit', '5'], {
266
+ defaults: { limit: 10 },
267
+ options: z.object({ limit: z.number().default(30) }),
268
+ })
269
+ expect(result.options).toEqual({ limit: 5 })
270
+ })
271
+
272
+ test('argv arrays replace config arrays', () => {
273
+ const result = Parser.parse(['--label', 'bug', '--label', 'feature'], {
274
+ defaults: { label: ['ops'] },
275
+ options: z.object({ label: z.array(z.string()).default([]) }),
276
+ })
277
+ expect(result.options).toEqual({ label: ['bug', 'feature'] })
278
+ })
279
+
280
+ test('kebab-case config keys map to camelCase schema names', () => {
281
+ const result = Parser.parse([], {
282
+ defaults: { 'save-dev': true } as any,
283
+ options: z.object({ saveDev: z.boolean().default(false) }),
284
+ })
285
+ expect(result.options).toEqual({ saveDev: true })
286
+ })
287
+
288
+ test('throws ParseError on unknown config option keys', () => {
289
+ expect(() =>
290
+ Parser.parse([], {
291
+ defaults: { missing: true } as any,
292
+ options: z.object({ saveDev: z.boolean().default(false) }),
293
+ }),
294
+ ).toThrow(expect.objectContaining({ name: 'Incur.ParseError' }))
295
+ })
296
+
297
+ test('throws ValidationError for invalid config defaults when argv does not override them', () => {
298
+ expect(() =>
299
+ Parser.parse([], {
300
+ defaults: { limit: 'oops' } as any,
301
+ options: z.object({ limit: z.number() }),
302
+ }),
303
+ ).toThrow(expect.objectContaining({ name: 'Incur.ValidationError' }))
304
+ })
305
+
306
+ test('argv overrides invalid config defaults', () => {
307
+ const result = Parser.parse(['--limit', '5'], {
308
+ defaults: { limit: 'oops' } as any,
309
+ options: z.object({ limit: z.number() }),
310
+ })
311
+ expect(result.options).toEqual({ limit: 5 })
312
+ })
313
+
314
+ test('defaults with no options schema throws on non-empty defaults', () => {
315
+ expect(() =>
316
+ Parser.parse([], {
317
+ defaults: { limit: 10 } as any,
318
+ }),
319
+ ).toThrow(expect.objectContaining({ name: 'Incur.ParseError' }))
320
+ })
321
+
322
+ test('defaults with no options schema and empty defaults is a no-op', () => {
323
+ const result = Parser.parse([], { defaults: {} as any })
324
+ expect(result.options).toEqual({})
325
+ })
326
+
327
+ test('config array defaults are used when argv omits the option', () => {
328
+ const result = Parser.parse([], {
329
+ defaults: { label: ['bug', 'feature'] },
330
+ options: z.object({ label: z.array(z.string()).default([]) }),
331
+ })
332
+ expect(result.options).toEqual({ label: ['bug', 'feature'] })
333
+ })
334
+
335
+ test('refined option schemas validate only the merged winning values', () => {
336
+ const result = Parser.parse(['--min', '1', '--max', '3'], {
337
+ defaults: { min: 'oops' } as any,
338
+ options: z
339
+ .object({ min: z.number(), max: z.number() })
340
+ .refine((value) => value.min < value.max, { message: 'min must be less than max' }),
341
+ })
342
+ expect(result.options).toEqual({ min: 1, max: 3 })
343
+ })
255
344
  })
package/src/Parser.ts CHANGED
@@ -2,29 +2,20 @@ import type { z } from 'zod'
2
2
 
3
3
  import type { FieldError } from './Errors.js'
4
4
  import { ParseError, ValidationError } from './Errors.js'
5
+ import { isRecord, toKebab } from './internal/helpers.js'
5
6
 
6
7
  /** Parses raw argv tokens against Zod schemas for args and options. */
7
8
  export function parse<
8
9
  const args extends z.ZodObject<any> | undefined = undefined,
9
10
  const options extends z.ZodObject<any> | undefined = undefined,
10
11
  >(argv: string[], options: parse.Options<args, options> = {}): parse.ReturnType<args, options> {
11
- const { args: argsSchema, options: optionsSchema, alias } = options
12
+ const { args: argsSchema, options: optionsSchema, alias, defaults } = options
12
13
 
13
- // Build reverse alias map: short char → long name
14
- const aliasToName = new Map<string, string>()
15
- if (alias) for (const [name, short] of Object.entries(alias)) aliasToName.set(short, name)
16
-
17
- // Known option names from schema, plus kebab-case → camelCase map
18
- const knownOptions = new Set(optionsSchema ? Object.keys(optionsSchema.shape) : [])
19
- const kebabToCamel = new Map<string, string>()
20
- for (const name of knownOptions) {
21
- const kebab = name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
22
- if (kebab !== name) kebabToCamel.set(kebab, name)
23
- }
14
+ const optionNames = createOptionNames(optionsSchema, alias)
24
15
 
25
16
  // First pass: split argv into positional tokens and raw option values
26
17
  const positionals: string[] = []
27
- const rawOptions: Record<string, unknown> = {}
18
+ const rawArgvOptions: Record<string, unknown> = {}
28
19
 
29
20
  let i = 0
30
21
  while (i < argv.length) {
@@ -32,36 +23,34 @@ export function parse<
32
23
 
33
24
  if (token.startsWith('--no-') && token.length > 5) {
34
25
  // --no-flag negation
35
- const raw = token.slice(5)
36
- const name = kebabToCamel.get(raw) ?? raw
37
- if (!knownOptions.has(name)) throw new ParseError({ message: `Unknown flag: ${token}` })
38
- rawOptions[name] = false
26
+ const name = normalizeOptionName(token.slice(5), optionNames)
27
+ if (!name) throw new ParseError({ message: `Unknown flag: ${token}` })
28
+ rawArgvOptions[name] = false
39
29
  i++
40
30
  } else if (token.startsWith('--')) {
41
31
  const eqIdx = token.indexOf('=')
42
32
  if (eqIdx !== -1) {
43
33
  // --flag=value
44
34
  const raw = token.slice(2, eqIdx)
45
- const name = kebabToCamel.get(raw) ?? raw
46
- if (!knownOptions.has(name)) throw new ParseError({ message: `Unknown flag: --${raw}` })
47
- setOption(rawOptions, name, token.slice(eqIdx + 1), optionsSchema)
35
+ const name = normalizeOptionName(raw, optionNames)
36
+ if (!name) throw new ParseError({ message: `Unknown flag: --${raw}` })
37
+ setOption(rawArgvOptions, name, token.slice(eqIdx + 1), optionsSchema)
48
38
  i++
49
39
  } else {
50
40
  // --flag [value]
51
- const raw = token.slice(2)
52
- const name = kebabToCamel.get(raw) ?? raw
53
- if (!knownOptions.has(name)) throw new ParseError({ message: `Unknown flag: ${token}` })
41
+ const name = normalizeOptionName(token.slice(2), optionNames)
42
+ if (!name) throw new ParseError({ message: `Unknown flag: ${token}` })
54
43
  if (isCountOption(name, optionsSchema)) {
55
- rawOptions[name] = ((rawOptions[name] as number) ?? 0) + 1
44
+ rawArgvOptions[name] = ((rawArgvOptions[name] as number) ?? 0) + 1
56
45
  i++
57
46
  } else if (isBooleanOption(name, optionsSchema)) {
58
- rawOptions[name] = true
47
+ rawArgvOptions[name] = true
59
48
  i++
60
49
  } else {
61
50
  const value = argv[i + 1]
62
51
  if (value === undefined)
63
52
  throw new ParseError({ message: `Missing value for flag: ${token}` })
64
- setOption(rawOptions, name, value, optionsSchema)
53
+ setOption(rawArgvOptions, name, value, optionsSchema)
65
54
  i += 2
66
55
  }
67
56
  }
@@ -70,28 +59,28 @@ export function parse<
70
59
  const chars = token.slice(1)
71
60
  for (let j = 0; j < chars.length; j++) {
72
61
  const short = chars[j]!
73
- const name = aliasToName.get(short)
62
+ const name = optionNames.aliasToName.get(short)
74
63
  if (!name) throw new ParseError({ message: `Unknown flag: -${short}` })
75
64
  const isLast = j === chars.length - 1
76
65
  if (!isLast) {
77
66
  if (isCountOption(name, optionsSchema)) {
78
- rawOptions[name] = ((rawOptions[name] as number) ?? 0) + 1
67
+ rawArgvOptions[name] = ((rawArgvOptions[name] as number) ?? 0) + 1
79
68
  } else if (isBooleanOption(name, optionsSchema)) {
80
- rawOptions[name] = true
69
+ rawArgvOptions[name] = true
81
70
  } else {
82
71
  throw new ParseError({
83
72
  message: `Non-boolean flag -${short} must be last in a stacked alias`,
84
73
  })
85
74
  }
86
75
  } else if (isCountOption(name, optionsSchema)) {
87
- rawOptions[name] = ((rawOptions[name] as number) ?? 0) + 1
76
+ rawArgvOptions[name] = ((rawArgvOptions[name] as number) ?? 0) + 1
88
77
  } else if (isBooleanOption(name, optionsSchema)) {
89
- rawOptions[name] = true
78
+ rawArgvOptions[name] = true
90
79
  } else {
91
80
  const value = argv[i + 1]
92
81
  if (value === undefined)
93
82
  throw new ParseError({ message: `Missing value for flag: -${short}` })
94
- setOption(rawOptions, name, value, optionsSchema)
83
+ setOption(rawArgvOptions, name, value, optionsSchema)
95
84
  i++
96
85
  }
97
86
  }
@@ -117,15 +106,19 @@ export function parse<
117
106
  // Validate args through zod
118
107
  const args = argsSchema ? zodParse(argsSchema, rawArgs) : {}
119
108
 
109
+ const rawDefaults = normalizeOptionDefaults(defaults, optionsSchema, optionNames)
110
+
120
111
  // Coerce raw option values before zod validation
121
112
  if (optionsSchema) {
122
- for (const [name, value] of Object.entries(rawOptions)) {
123
- rawOptions[name] = coerce(value, name, optionsSchema)
113
+ for (const [name, value] of Object.entries(rawArgvOptions)) {
114
+ rawArgvOptions[name] = coerce(value, name, optionsSchema)
124
115
  }
125
116
  }
126
117
 
118
+ const mergedOptions = { ...rawDefaults, ...rawArgvOptions }
119
+
127
120
  // Validate options through zod
128
- const parsedOptions = optionsSchema ? zodParse(optionsSchema, rawOptions) : {}
121
+ const parsedOptions = optionsSchema ? zodParse(optionsSchema, mergedOptions) : {}
129
122
 
130
123
  return { args, options: parsedOptions } as parse.ReturnType<args, options>
131
124
  }
@@ -138,6 +131,8 @@ export declare namespace parse {
138
131
  > = {
139
132
  /** Zod schema for positional arguments. Keys define order. */
140
133
  args?: args
134
+ /** Config-backed option defaults merged before argv parsing. */
135
+ defaults?: options extends z.ZodObject<any> ? Partial<z.input<options>> | undefined : undefined
141
136
  /** Zod schema for named options/flags. */
142
137
  options?: options
143
138
  /** Map of option names to single-char aliases. */
@@ -155,6 +150,62 @@ export declare namespace parse {
155
150
  }
156
151
  }
157
152
 
153
+ type OptionNames = {
154
+ aliasToName: Map<string, string>
155
+ kebabToCamel: Map<string, string>
156
+ knownOptions: Set<string>
157
+ }
158
+
159
+ /** Builds lookup tables for option names and short aliases. */
160
+ function createOptionNames(
161
+ schema: z.ZodObject<any> | undefined,
162
+ alias: Record<string, string> | undefined,
163
+ ): OptionNames {
164
+ const aliasToName = new Map<string, string>()
165
+ if (alias) for (const [name, short] of Object.entries(alias)) aliasToName.set(short, name)
166
+
167
+ const knownOptions = new Set(schema ? Object.keys(schema.shape) : [])
168
+ const kebabToCamel = new Map<string, string>()
169
+ for (const name of knownOptions) {
170
+ const kebab = toKebab(name)
171
+ if (kebab !== name) kebabToCamel.set(kebab, name)
172
+ }
173
+
174
+ return { aliasToName, kebabToCamel, knownOptions }
175
+ }
176
+
177
+ /** Normalizes a long option name, accepting kebab-case aliases for camelCase schema keys. */
178
+ function normalizeOptionName(raw: string, options: OptionNames): string | undefined {
179
+ const name = options.kebabToCamel.get(raw) ?? raw
180
+ return options.knownOptions.has(name) ? name : undefined
181
+ }
182
+
183
+ /** Normalizes config-backed defaults and validates config structure/key names. */
184
+ function normalizeOptionDefaults(
185
+ defaults: unknown,
186
+ schema: z.ZodObject<any> | undefined,
187
+ optionNames: OptionNames,
188
+ ): Record<string, unknown> {
189
+ if (defaults === undefined) return {}
190
+ if (!isRecord(defaults))
191
+ throw new ParseError({
192
+ message: 'Invalid config section: expected an object of option defaults',
193
+ })
194
+ if (!schema) {
195
+ const [first] = Object.keys(defaults)
196
+ if (first) throw new ParseError({ message: `Unknown config option: ${first}` })
197
+ return {}
198
+ }
199
+
200
+ const normalized: Record<string, unknown> = {}
201
+ for (const [rawName, value] of Object.entries(defaults)) {
202
+ const name = normalizeOptionName(rawName, optionNames)
203
+ if (!name) throw new ParseError({ message: `Unknown config option: ${rawName}` })
204
+ normalized[name] = value
205
+ }
206
+ return normalized
207
+ }
208
+
158
209
  /** Unwraps ZodDefault/ZodOptional to get the inner type. */
159
210
  function unwrap(schema: z.ZodType): z.ZodType {
160
211
  let s = schema as any
package/src/Skill.ts CHANGED
@@ -127,7 +127,11 @@ function renderGroup(
127
127
  ? `${descParts.join('. ')}. Run \`${title} --help\` for usage details.`
128
128
  : `Run \`${title} --help\` for usage details.`
129
129
 
130
- const slug = title.replace(/\s+/g, '-')
130
+ const slug = title
131
+ .toLowerCase()
132
+ .replace(/[^a-z0-9-]+/g, '-')
133
+ .replace(/-{2,}/g, '-')
134
+ .replace(/^-|-$/g, '')
131
135
  const fm = ['---', `name: ${slug}`]
132
136
  fm.push(`description: ${description}`)
133
137
  fm.push(`requires_bin: ${cli}`)
package/src/SyncSkills.ts CHANGED
@@ -150,7 +150,17 @@ function collectEntries(
150
150
  function resolvePackageRoot(): string {
151
151
  const bin = process.argv[1]
152
152
  if (!bin) return process.cwd()
153
- let dir = path.dirname(fsSync.realpathSync(bin))
153
+ let dir = path.dirname(
154
+ (() => {
155
+ try {
156
+ // resolve symlinks for normal bin scripts
157
+ return fsSync.realpathSync(bin)
158
+ } catch {
159
+ // Bun compiled binaries use a virtual `/$bunfs/` path for argv[1]
160
+ return process.execPath
161
+ }
162
+ })(),
163
+ )
154
164
  const root = path.parse(dir).root
155
165
  while (dir !== root) {
156
166
  try {
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