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.
- package/README.md +62 -1
- package/dist/Cli.d.ts +17 -7
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +435 -365
- package/dist/Cli.js.map +1 -1
- package/dist/Completions.d.ts +1 -2
- package/dist/Completions.d.ts.map +1 -1
- package/dist/Completions.js.map +1 -1
- package/dist/Filter.js +0 -18
- package/dist/Filter.js.map +1 -1
- package/dist/Help.d.ts +6 -0
- package/dist/Help.d.ts.map +1 -1
- package/dist/Help.js +35 -22
- package/dist/Help.js.map +1 -1
- package/dist/Mcp.d.ts +25 -5
- package/dist/Mcp.d.ts.map +1 -1
- package/dist/Mcp.js +61 -69
- package/dist/Mcp.js.map +1 -1
- package/dist/Parser.d.ts +2 -0
- package/dist/Parser.d.ts.map +1 -1
- package/dist/Parser.js +69 -37
- package/dist/Parser.js.map +1 -1
- package/dist/Skill.d.ts.map +1 -1
- package/dist/Skill.js +5 -1
- package/dist/Skill.js.map +1 -1
- package/dist/SyncSkills.d.ts.map +1 -1
- package/dist/SyncSkills.js +10 -1
- package/dist/SyncSkills.js.map +1 -1
- package/dist/bin.d.ts +1 -0
- 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 +118 -0
- package/dist/internal/command.d.ts.map +1 -0
- package/dist/internal/command.js +276 -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/helpers.d.ts +5 -0
- package/dist/internal/helpers.d.ts.map +1 -0
- package/dist/internal/helpers.js +9 -0
- package/dist/internal/helpers.js.map +1 -0
- package/examples/npm/.npmrc.json +21 -0
- package/examples/npm/config.schema.json +137 -0
- package/package.json +1 -1
- package/src/Cli.test-d.ts +39 -0
- package/src/Cli.test.ts +704 -6
- package/src/Cli.ts +551 -448
- package/src/Completions.test.ts +35 -9
- package/src/Completions.ts +1 -2
- package/src/Filter.ts +0 -17
- package/src/Help.test.ts +77 -0
- package/src/Help.ts +39 -21
- package/src/Mcp.test.ts +143 -0
- package/src/Mcp.ts +92 -84
- package/src/Parser.test-d.ts +22 -0
- package/src/Parser.test.ts +89 -0
- package/src/Parser.ts +86 -35
- package/src/Skill.ts +5 -1
- package/src/SyncSkills.ts +11 -1
- package/src/bin.ts +21 -2
- package/src/e2e.test.ts +30 -17
- package/src/internal/command.ts +428 -0
- package/src/internal/configSchema.test.ts +193 -0
- package/src/internal/configSchema.ts +66 -0
- 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,
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
for await (const chunk of
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
119
|
-
} catch (err) {
|
|
133
|
+
if (!result.ok)
|
|
120
134
|
return {
|
|
121
|
-
content: [{ type: 'text', text:
|
|
135
|
+
content: [{ type: 'text', text: result.error.message ?? 'Command failed' }],
|
|
122
136
|
isError: true,
|
|
123
137
|
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
138
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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(
|
|
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)
|
|
151
|
-
|
|
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
|
-
}
|
package/src/Parser.test-d.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/Parser.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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 =
|
|
46
|
-
if (!
|
|
47
|
-
setOption(
|
|
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
|
|
52
|
-
|
|
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
|
-
|
|
44
|
+
rawArgvOptions[name] = ((rawArgvOptions[name] as number) ?? 0) + 1
|
|
56
45
|
i++
|
|
57
46
|
} else if (isBooleanOption(name, optionsSchema)) {
|
|
58
|
-
|
|
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(
|
|
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
|
-
|
|
67
|
+
rawArgvOptions[name] = ((rawArgvOptions[name] as number) ?? 0) + 1
|
|
79
68
|
} else if (isBooleanOption(name, optionsSchema)) {
|
|
80
|
-
|
|
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
|
-
|
|
76
|
+
rawArgvOptions[name] = ((rawArgvOptions[name] as number) ?? 0) + 1
|
|
88
77
|
} else if (isBooleanOption(name, optionsSchema)) {
|
|
89
|
-
|
|
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(
|
|
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(
|
|
123
|
-
|
|
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,
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|