incur 0.0.0 → 0.0.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/LICENSE +21 -0
- package/README.md +141 -0
- package/SKILL.md +664 -0
- package/dist/Cli.d.ts +255 -0
- package/dist/Cli.d.ts.map +1 -0
- package/dist/Cli.js +900 -0
- package/dist/Cli.js.map +1 -0
- package/dist/Errors.d.ts +92 -0
- package/dist/Errors.d.ts.map +1 -0
- package/dist/Errors.js +75 -0
- package/dist/Errors.js.map +1 -0
- package/dist/Formatter.d.ts +5 -0
- package/dist/Formatter.d.ts.map +1 -0
- package/dist/Formatter.js +91 -0
- package/dist/Formatter.js.map +1 -0
- package/dist/Help.d.ts +53 -0
- package/dist/Help.d.ts.map +1 -0
- package/dist/Help.js +231 -0
- package/dist/Help.js.map +1 -0
- package/dist/Mcp.d.ts +13 -0
- package/dist/Mcp.d.ts.map +1 -0
- package/dist/Mcp.js +140 -0
- package/dist/Mcp.js.map +1 -0
- package/dist/Parser.d.ts +24 -0
- package/dist/Parser.d.ts.map +1 -0
- package/dist/Parser.js +215 -0
- package/dist/Parser.js.map +1 -0
- package/dist/Register.d.ts +19 -0
- package/dist/Register.d.ts.map +1 -0
- package/dist/Register.js +2 -0
- package/dist/Register.js.map +1 -0
- package/dist/Schema.d.ts +4 -0
- package/dist/Schema.d.ts.map +1 -0
- package/dist/Schema.js +8 -0
- package/dist/Schema.js.map +1 -0
- package/dist/Skill.d.ts +29 -0
- package/dist/Skill.d.ts.map +1 -0
- package/dist/Skill.js +196 -0
- package/dist/Skill.js.map +1 -0
- package/dist/Skillgen.d.ts +3 -0
- package/dist/Skillgen.d.ts.map +1 -0
- package/dist/Skillgen.js +67 -0
- package/dist/Skillgen.js.map +1 -0
- package/dist/SyncMcp.d.ts +23 -0
- package/dist/SyncMcp.d.ts.map +1 -0
- package/dist/SyncMcp.js +100 -0
- package/dist/SyncMcp.js.map +1 -0
- package/dist/SyncSkills.d.ts +38 -0
- package/dist/SyncSkills.d.ts.map +1 -0
- package/dist/SyncSkills.js +163 -0
- package/dist/SyncSkills.js.map +1 -0
- package/dist/Typegen.d.ts +6 -0
- package/dist/Typegen.d.ts.map +1 -0
- package/dist/Typegen.js +92 -0
- package/dist/Typegen.js.map +1 -0
- package/dist/bin.d.ts +14 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +30 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/pm.d.ts +3 -0
- package/dist/internal/pm.d.ts.map +1 -0
- package/dist/internal/pm.js +11 -0
- package/dist/internal/pm.js.map +1 -0
- package/dist/internal/types.d.ts +11 -0
- package/dist/internal/types.d.ts.map +1 -0
- package/dist/internal/types.js +2 -0
- package/dist/internal/types.js.map +1 -0
- package/dist/internal/utils.d.ts +8 -0
- package/dist/internal/utils.d.ts.map +1 -0
- package/dist/internal/utils.js +51 -0
- package/dist/internal/utils.js.map +1 -0
- package/examples/npm/cli.ts +180 -0
- package/examples/npm/node_modules/.bin/incur.src +21 -0
- package/examples/npm/node_modules/.bin/tsx +21 -0
- package/examples/npm/package.json +14 -0
- package/examples/npm/tsconfig.json +9 -0
- package/examples/presto/cli.ts +246 -0
- package/examples/presto/node_modules/.bin/incur.src +21 -0
- package/examples/presto/node_modules/.bin/tsx +21 -0
- package/examples/presto/package.json +14 -0
- package/examples/presto/tsconfig.json +9 -0
- package/package.json +53 -2
- package/src/Cli.test-d.ts +135 -0
- package/src/Cli.test.ts +1373 -0
- package/src/Cli.ts +1470 -0
- package/src/Errors.test.ts +96 -0
- package/src/Errors.ts +139 -0
- package/src/Formatter.test.ts +245 -0
- package/src/Formatter.ts +106 -0
- package/src/Help.test.ts +124 -0
- package/src/Help.ts +302 -0
- package/src/Mcp.test.ts +254 -0
- package/src/Mcp.ts +195 -0
- package/src/Parser.test-d.ts +45 -0
- package/src/Parser.test.ts +118 -0
- package/src/Parser.ts +247 -0
- package/src/Register.ts +18 -0
- package/src/Schema.test.ts +125 -0
- package/src/Schema.ts +8 -0
- package/src/Skill.test.ts +293 -0
- package/src/Skill.ts +253 -0
- package/src/Skillgen.ts +66 -0
- package/src/SyncMcp.test.ts +75 -0
- package/src/SyncMcp.ts +132 -0
- package/src/SyncSkills.test.ts +92 -0
- package/src/SyncSkills.ts +205 -0
- package/src/Typegen.test.ts +150 -0
- package/src/Typegen.ts +107 -0
- package/src/bin.ts +33 -0
- package/src/e2e.test.ts +1710 -0
- package/src/index.ts +14 -0
- package/src/internal/pm.test.ts +38 -0
- package/src/internal/pm.ts +8 -0
- package/src/internal/types.ts +22 -0
- package/src/internal/utils.ts +50 -0
- package/src/tsconfig.json +8 -0
package/src/Mcp.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
3
|
+
import type { Readable, Writable } from 'node:stream'
|
|
4
|
+
|
|
5
|
+
import * as Schema from './Schema.js'
|
|
6
|
+
|
|
7
|
+
/** Starts a stdio MCP server that exposes commands as tools. */
|
|
8
|
+
export async function serve(
|
|
9
|
+
name: string,
|
|
10
|
+
version: string,
|
|
11
|
+
commands: Map<string, any>,
|
|
12
|
+
options: serve.Options = {},
|
|
13
|
+
): Promise<void> {
|
|
14
|
+
const server = new McpServer({ name, version })
|
|
15
|
+
|
|
16
|
+
for (const tool of collectTools(commands, [])) {
|
|
17
|
+
const mergedShape: Record<string, any> = {
|
|
18
|
+
...tool.command.args?.shape,
|
|
19
|
+
...tool.command.options?.shape,
|
|
20
|
+
}
|
|
21
|
+
const hasInput = Object.keys(mergedShape).length > 0
|
|
22
|
+
|
|
23
|
+
server.registerTool(
|
|
24
|
+
tool.name,
|
|
25
|
+
{
|
|
26
|
+
...(tool.description ? { description: tool.description } : undefined),
|
|
27
|
+
...(hasInput ? { inputSchema: mergedShape } : undefined),
|
|
28
|
+
},
|
|
29
|
+
async (...callArgs: any[]) => {
|
|
30
|
+
// registerTool passes (args, extra) when inputSchema is set, (extra) when not
|
|
31
|
+
const params = hasInput ? (callArgs[0] as Record<string, unknown>) : {}
|
|
32
|
+
const extra = hasInput ? callArgs[1] : callArgs[0]
|
|
33
|
+
return callTool(tool, params, extra)
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const input = options.input ?? process.stdin
|
|
39
|
+
const output = options.output ?? process.stdout
|
|
40
|
+
const transport = new StdioServerTransport(input as any, output as any)
|
|
41
|
+
await server.connect(transport)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export declare namespace serve {
|
|
45
|
+
/** Options for the MCP server. */
|
|
46
|
+
type Options = {
|
|
47
|
+
/** Override input stream. Defaults to `process.stdin`. */
|
|
48
|
+
input?: Readable | undefined
|
|
49
|
+
/** Override output stream. Defaults to `process.stdout`. */
|
|
50
|
+
output?: Writable | undefined
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @internal Executes a tool call and returns a CallToolResult. */
|
|
55
|
+
async function callTool(
|
|
56
|
+
tool: ToolEntry,
|
|
57
|
+
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
|
+
|
|
82
|
+
// 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
|
+
}
|
|
96
|
+
chunks.push(chunk)
|
|
97
|
+
if (progressToken !== undefined && extra?.sendNotification)
|
|
98
|
+
await extra.sendNotification({
|
|
99
|
+
method: 'notifications/progress' as const,
|
|
100
|
+
params: { progressToken, progress: ++i, message: JSON.stringify(chunk) },
|
|
101
|
+
})
|
|
102
|
+
}
|
|
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) }] }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { content: [{ type: 'text', text: JSON.stringify(awaited) }] }
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: 'text', text: err instanceof Error ? err.message : String(err) }],
|
|
122
|
+
isError: true,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
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
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @internal A resolved tool entry from the command tree. */
|
|
138
|
+
type ToolEntry = {
|
|
139
|
+
name: string
|
|
140
|
+
description?: string | undefined
|
|
141
|
+
inputSchema: { type: 'object'; properties: Record<string, unknown>; required?: string[] }
|
|
142
|
+
command: any
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** @internal Recursively collects leaf commands as tool entries. */
|
|
146
|
+
function collectTools(commands: Map<string, any>, prefix: string[]): ToolEntry[] {
|
|
147
|
+
const result: ToolEntry[] = []
|
|
148
|
+
for (const [name, entry] of commands) {
|
|
149
|
+
const path = [...prefix, name]
|
|
150
|
+
if ('_group' in entry && entry._group) result.push(...collectTools(entry.commands, path))
|
|
151
|
+
else {
|
|
152
|
+
result.push({
|
|
153
|
+
name: path.join('_'),
|
|
154
|
+
description: entry.description,
|
|
155
|
+
inputSchema: buildToolSchema(entry.args, entry.options),
|
|
156
|
+
command: entry,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return result.sort((a, b) => a.name.localeCompare(b.name))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** @internal Builds a merged JSON Schema from args and options Zod schemas. */
|
|
164
|
+
function buildToolSchema(
|
|
165
|
+
args: any | undefined,
|
|
166
|
+
options: any | undefined,
|
|
167
|
+
): { type: 'object'; properties: Record<string, unknown>; required?: string[] } {
|
|
168
|
+
const properties: Record<string, unknown> = {}
|
|
169
|
+
const required: string[] = []
|
|
170
|
+
|
|
171
|
+
for (const schema of [args, options]) {
|
|
172
|
+
if (!schema) continue
|
|
173
|
+
const json = Schema.toJsonSchema(schema)
|
|
174
|
+
Object.assign(properties, (json.properties as Record<string, unknown>) ?? {})
|
|
175
|
+
required.push(...((json.required as string[]) ?? []))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (required.length > 0) return { type: 'object', properties, required }
|
|
179
|
+
return { type: 'object', properties }
|
|
180
|
+
}
|
|
181
|
+
|
|
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
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Parser, z } from 'incur'
|
|
2
|
+
import { expectTypeOf, test } from 'vitest'
|
|
3
|
+
|
|
4
|
+
test('narrows args from schema', () => {
|
|
5
|
+
const result = Parser.parse(['hello'], {
|
|
6
|
+
args: z.object({ name: z.string() }),
|
|
7
|
+
})
|
|
8
|
+
expectTypeOf(result.args).toEqualTypeOf<{ name: string }>()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('narrows options from schema', () => {
|
|
12
|
+
const result = Parser.parse(['--state', 'open'], {
|
|
13
|
+
options: z.object({ state: z.string() }),
|
|
14
|
+
})
|
|
15
|
+
expectTypeOf(result.options).toEqualTypeOf<{ state: string }>()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('defaults to empty objects when no schemas', () => {
|
|
19
|
+
const result = Parser.parse([])
|
|
20
|
+
expectTypeOf(result.args).toEqualTypeOf<{}>()
|
|
21
|
+
expectTypeOf(result.options).toEqualTypeOf<{}>()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('z.output reflects .default() as non-optional', () => {
|
|
25
|
+
const result = Parser.parse([], {
|
|
26
|
+
options: z.object({ limit: z.number().default(30) }),
|
|
27
|
+
})
|
|
28
|
+
expectTypeOf(result.options).toEqualTypeOf<{ limit: number }>()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('z.output reflects .optional() as optional', () => {
|
|
32
|
+
const result = Parser.parse([], {
|
|
33
|
+
options: z.object({ verbose: z.boolean().optional() }),
|
|
34
|
+
})
|
|
35
|
+
expectTypeOf(result.options).toEqualTypeOf<{ verbose?: boolean | undefined }>()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('narrows both args and options together', () => {
|
|
39
|
+
const result = Parser.parse(['myrepo', '--limit', '5'], {
|
|
40
|
+
args: z.object({ repo: z.string() }),
|
|
41
|
+
options: z.object({ limit: z.number() }),
|
|
42
|
+
})
|
|
43
|
+
expectTypeOf(result.args).toEqualTypeOf<{ repo: string }>()
|
|
44
|
+
expectTypeOf(result.options).toEqualTypeOf<{ limit: number }>()
|
|
45
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Parser, z } from 'incur'
|
|
2
|
+
|
|
3
|
+
describe('parse', () => {
|
|
4
|
+
test('returns empty args and options when no schemas', () => {
|
|
5
|
+
expect(Parser.parse([])).toEqual({ args: {}, options: {} })
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
test('parses positional args in schema key order', () => {
|
|
9
|
+
const result = Parser.parse(['hello', 'world'], {
|
|
10
|
+
args: z.object({ greeting: z.string(), name: z.string() }),
|
|
11
|
+
})
|
|
12
|
+
expect(result.args).toEqual({ greeting: 'hello', name: 'world' })
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('parses --flag value options', () => {
|
|
16
|
+
const result = Parser.parse(['--state', 'open'], {
|
|
17
|
+
options: z.object({ state: z.string() }),
|
|
18
|
+
})
|
|
19
|
+
expect(result.options).toEqual({ state: 'open' })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('parses --flag=value syntax', () => {
|
|
23
|
+
const result = Parser.parse(['--state=closed'], {
|
|
24
|
+
options: z.object({ state: z.string() }),
|
|
25
|
+
})
|
|
26
|
+
expect(result.options).toEqual({ state: 'closed' })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('parses -f value short aliases', () => {
|
|
30
|
+
const result = Parser.parse(['-s', 'open'], {
|
|
31
|
+
options: z.object({ state: z.string() }),
|
|
32
|
+
alias: { state: 's' },
|
|
33
|
+
})
|
|
34
|
+
expect(result.options).toEqual({ state: 'open' })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('parses --verbose as true', () => {
|
|
38
|
+
const result = Parser.parse(['--verbose'], {
|
|
39
|
+
options: z.object({ verbose: z.boolean() }),
|
|
40
|
+
})
|
|
41
|
+
expect(result.options).toEqual({ verbose: true })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('parses --no-verbose as false', () => {
|
|
45
|
+
const result = Parser.parse(['--no-verbose'], {
|
|
46
|
+
options: z.object({ verbose: z.boolean() }),
|
|
47
|
+
})
|
|
48
|
+
expect(result.options).toEqual({ verbose: false })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('parses repeated flags as array', () => {
|
|
52
|
+
const result = Parser.parse(['--label', 'bug', '--label', 'feature'], {
|
|
53
|
+
options: z.object({ label: z.array(z.string()) }),
|
|
54
|
+
})
|
|
55
|
+
expect(result.options).toEqual({ label: ['bug', 'feature'] })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('coerces string to number', () => {
|
|
59
|
+
const result = Parser.parse(['--limit', '10'], {
|
|
60
|
+
options: z.object({ limit: z.number() }),
|
|
61
|
+
})
|
|
62
|
+
expect(result.options).toEqual({ limit: 10 })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('coerces string to boolean', () => {
|
|
66
|
+
const result = Parser.parse(['--dry', 'true'], {
|
|
67
|
+
options: z.object({ dry: z.boolean() }),
|
|
68
|
+
})
|
|
69
|
+
expect(result.options).toEqual({ dry: true })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('applies default values for missing options', () => {
|
|
73
|
+
const result = Parser.parse([], {
|
|
74
|
+
options: z.object({ limit: z.number().default(30) }),
|
|
75
|
+
})
|
|
76
|
+
expect(result.options).toEqual({ limit: 30 })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('allows optional fields to be omitted', () => {
|
|
80
|
+
const result = Parser.parse([], {
|
|
81
|
+
options: z.object({ verbose: z.boolean().optional() }),
|
|
82
|
+
})
|
|
83
|
+
expect(result.options).toEqual({})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('throws ParseError on unknown flags', () => {
|
|
87
|
+
expect(() =>
|
|
88
|
+
Parser.parse(['--unknown', 'val'], {
|
|
89
|
+
options: z.object({ state: z.string() }),
|
|
90
|
+
}),
|
|
91
|
+
).toThrow(expect.objectContaining({ name: 'Incur.ParseError' }))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('throws ValidationError on missing required positional args', () => {
|
|
95
|
+
expect(() =>
|
|
96
|
+
Parser.parse([], {
|
|
97
|
+
args: z.object({ name: z.string() }),
|
|
98
|
+
}),
|
|
99
|
+
).toThrow(expect.objectContaining({ name: 'Incur.ValidationError' }))
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('throws ValidationError on enum mismatch', () => {
|
|
103
|
+
expect(() =>
|
|
104
|
+
Parser.parse(['--state', 'invalid'], {
|
|
105
|
+
options: z.object({ state: z.enum(['open', 'closed']) }),
|
|
106
|
+
}),
|
|
107
|
+
).toThrow(expect.objectContaining({ name: 'Incur.ValidationError' }))
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('parses positional args and options together', () => {
|
|
111
|
+
const result = Parser.parse(['myrepo', '--limit', '5'], {
|
|
112
|
+
args: z.object({ repo: z.string() }),
|
|
113
|
+
options: z.object({ limit: z.number() }),
|
|
114
|
+
})
|
|
115
|
+
expect(result.args).toEqual({ repo: 'myrepo' })
|
|
116
|
+
expect(result.options).toEqual({ limit: 5 })
|
|
117
|
+
})
|
|
118
|
+
})
|
package/src/Parser.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import type { FieldError } from './Errors.js'
|
|
4
|
+
import { ParseError, ValidationError } from './Errors.js'
|
|
5
|
+
|
|
6
|
+
/** Parses raw argv tokens against Zod schemas for args and options. */
|
|
7
|
+
export function parse<
|
|
8
|
+
const args extends z.ZodObject<any> | undefined = undefined,
|
|
9
|
+
const options extends z.ZodObject<any> | undefined = undefined,
|
|
10
|
+
>(argv: string[], options: parse.Options<args, options> = {}): parse.ReturnType<args, options> {
|
|
11
|
+
const { args: argsSchema, options: optionsSchema, alias } = options
|
|
12
|
+
|
|
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
|
+
}
|
|
24
|
+
|
|
25
|
+
// First pass: split argv into positional tokens and raw option values
|
|
26
|
+
const positionals: string[] = []
|
|
27
|
+
const rawOptions: Record<string, unknown> = {}
|
|
28
|
+
|
|
29
|
+
let i = 0
|
|
30
|
+
while (i < argv.length) {
|
|
31
|
+
const token = argv[i]!
|
|
32
|
+
|
|
33
|
+
if (token.startsWith('--no-') && token.length > 5) {
|
|
34
|
+
// --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
|
|
39
|
+
i++
|
|
40
|
+
} else if (token.startsWith('--')) {
|
|
41
|
+
const eqIdx = token.indexOf('=')
|
|
42
|
+
if (eqIdx !== -1) {
|
|
43
|
+
// --flag=value
|
|
44
|
+
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)
|
|
48
|
+
i++
|
|
49
|
+
} else {
|
|
50
|
+
// --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}` })
|
|
54
|
+
if (isBooleanOption(name, optionsSchema)) {
|
|
55
|
+
rawOptions[name] = true
|
|
56
|
+
i++
|
|
57
|
+
} else {
|
|
58
|
+
const value = argv[i + 1]
|
|
59
|
+
if (value === undefined)
|
|
60
|
+
throw new ParseError({ message: `Missing value for flag: ${token}` })
|
|
61
|
+
setOption(rawOptions, name, value, optionsSchema)
|
|
62
|
+
i += 2
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} else if (token.startsWith('-') && token.length === 2) {
|
|
66
|
+
// -f [value]
|
|
67
|
+
const short = token.slice(1)
|
|
68
|
+
const name = aliasToName.get(short)
|
|
69
|
+
if (!name) throw new ParseError({ message: `Unknown flag: ${token}` })
|
|
70
|
+
if (isBooleanOption(name, optionsSchema)) {
|
|
71
|
+
rawOptions[name] = true
|
|
72
|
+
i++
|
|
73
|
+
} else {
|
|
74
|
+
const value = argv[i + 1]
|
|
75
|
+
if (value === undefined)
|
|
76
|
+
throw new ParseError({ message: `Missing value for flag: ${token}` })
|
|
77
|
+
setOption(rawOptions, name, value, optionsSchema)
|
|
78
|
+
i += 2
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
positionals.push(token)
|
|
82
|
+
i++
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Assign positionals to args schema keys in order
|
|
87
|
+
const rawArgs: Record<string, string> = {}
|
|
88
|
+
if (argsSchema) {
|
|
89
|
+
const keys = Object.keys(argsSchema.shape)
|
|
90
|
+
for (let j = 0; j < keys.length; j++) {
|
|
91
|
+
const key = keys[j]!
|
|
92
|
+
if (positionals[j] !== undefined) {
|
|
93
|
+
rawArgs[key] = positionals[j]!
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Validate args through zod
|
|
99
|
+
const args = argsSchema ? zodParse(argsSchema, rawArgs) : {}
|
|
100
|
+
|
|
101
|
+
// Coerce raw option values before zod validation
|
|
102
|
+
if (optionsSchema) {
|
|
103
|
+
for (const [name, value] of Object.entries(rawOptions)) {
|
|
104
|
+
rawOptions[name] = coerce(value, name, optionsSchema)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Validate options through zod
|
|
109
|
+
const parsedOptions = optionsSchema ? zodParse(optionsSchema, rawOptions) : {}
|
|
110
|
+
|
|
111
|
+
return { args, options: parsedOptions } as parse.ReturnType<args, options>
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export declare namespace parse {
|
|
115
|
+
/** Options for parsing. */
|
|
116
|
+
type Options<
|
|
117
|
+
args extends z.ZodObject<any> | undefined = undefined,
|
|
118
|
+
options extends z.ZodObject<any> | undefined = undefined,
|
|
119
|
+
> = {
|
|
120
|
+
/** Zod schema for positional arguments. Keys define order. */
|
|
121
|
+
args?: args
|
|
122
|
+
/** Zod schema for named options/flags. */
|
|
123
|
+
options?: options
|
|
124
|
+
/** Map of option names to single-char aliases. */
|
|
125
|
+
alias?: Record<string, string> | undefined
|
|
126
|
+
}
|
|
127
|
+
/** Parsed result with args and options. */
|
|
128
|
+
type ReturnType<
|
|
129
|
+
args extends z.ZodObject<any> | undefined = undefined,
|
|
130
|
+
options extends z.ZodObject<any> | undefined = undefined,
|
|
131
|
+
> = {
|
|
132
|
+
/** Parsed positional arguments. */
|
|
133
|
+
args: args extends z.ZodObject<any> ? z.output<args> : {}
|
|
134
|
+
/** Parsed named options. */
|
|
135
|
+
options: options extends z.ZodObject<any> ? z.output<options> : {}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Unwraps ZodDefault/ZodOptional to get the inner type. */
|
|
140
|
+
function unwrap(schema: z.ZodType): z.ZodType {
|
|
141
|
+
let s = schema as any
|
|
142
|
+
while (s._zod?.def?.innerType) s = s._zod.def.innerType
|
|
143
|
+
return s
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Checks if an option's inner type is boolean. */
|
|
147
|
+
function isBooleanOption(name: string, schema: z.ZodObject<any> | undefined): boolean {
|
|
148
|
+
if (!schema) return false
|
|
149
|
+
const field = schema.shape[name]
|
|
150
|
+
if (!field) return false
|
|
151
|
+
return unwrap(field).constructor.name === 'ZodBoolean'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Checks if an option's inner type is an array. */
|
|
155
|
+
function isArrayOption(name: string, schema: z.ZodObject<any> | undefined): boolean {
|
|
156
|
+
if (!schema) return false
|
|
157
|
+
const field = schema.shape[name]
|
|
158
|
+
if (!field) return false
|
|
159
|
+
return unwrap(field).constructor.name === 'ZodArray'
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Sets an option value, collecting into arrays for array schemas. */
|
|
163
|
+
function setOption(
|
|
164
|
+
raw: Record<string, unknown>,
|
|
165
|
+
name: string,
|
|
166
|
+
value: string,
|
|
167
|
+
schema: z.ZodObject<any> | undefined,
|
|
168
|
+
) {
|
|
169
|
+
if (isArrayOption(name, schema)) {
|
|
170
|
+
const existing = raw[name]
|
|
171
|
+
if (Array.isArray(existing)) {
|
|
172
|
+
existing.push(value)
|
|
173
|
+
} else {
|
|
174
|
+
raw[name] = [value]
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
raw[name] = value
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Wraps zod schema.parse(), converting ZodError to ValidationError. */
|
|
182
|
+
function zodParse(schema: z.ZodObject<any>, data: Record<string, unknown>) {
|
|
183
|
+
try {
|
|
184
|
+
return schema.parse(data)
|
|
185
|
+
} catch (err: any) {
|
|
186
|
+
const issues: any[] = err?.issues ?? err?.error?.issues ?? []
|
|
187
|
+
const fieldErrors: FieldError[] = issues.map((issue: any) => ({
|
|
188
|
+
path: (issue.path ?? []).join('.'),
|
|
189
|
+
expected: issue.expected ?? '',
|
|
190
|
+
received: issue.received ?? '',
|
|
191
|
+
message: issue.message ?? '',
|
|
192
|
+
}))
|
|
193
|
+
throw new ValidationError({
|
|
194
|
+
message: issues.map((i: any) => i.message).join('; ') || 'Validation failed',
|
|
195
|
+
fieldErrors,
|
|
196
|
+
cause: err instanceof Error ? err : undefined,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Parses environment variables against a Zod schema. Falls back to `process.env` → `Deno.env` when no source is provided. */
|
|
202
|
+
export function parseEnv<const env extends z.ZodObject<any>>(
|
|
203
|
+
schema: env,
|
|
204
|
+
source: Record<string, string | undefined> = defaultEnvSource(),
|
|
205
|
+
): z.output<env> {
|
|
206
|
+
const raw: Record<string, unknown> = {}
|
|
207
|
+
for (const [key, field] of Object.entries(schema.shape)) {
|
|
208
|
+
const value = source[key]
|
|
209
|
+
if (value !== undefined) raw[key] = coerceEnv(value, field as z.ZodType)
|
|
210
|
+
}
|
|
211
|
+
return zodParse(schema, raw) as z.output<env>
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Coerces an env var string to the type expected by the schema field. */
|
|
215
|
+
function coerceEnv(value: string, field: z.ZodType): unknown {
|
|
216
|
+
const inner = unwrap(field)
|
|
217
|
+
const typeName = inner.constructor.name
|
|
218
|
+
if (typeName === 'ZodNumber') return Number(value)
|
|
219
|
+
if (typeName === 'ZodBoolean') return value === 'true' || value === '1'
|
|
220
|
+
return value
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Coerces a raw string value to the type expected by the schema. */
|
|
224
|
+
function coerce(value: unknown, name: string, schema: z.ZodObject<any>): unknown {
|
|
225
|
+
const field = schema.shape[name]
|
|
226
|
+
if (!field) return value
|
|
227
|
+
const inner = unwrap(field)
|
|
228
|
+
const typeName = inner.constructor.name
|
|
229
|
+
|
|
230
|
+
if (typeName === 'ZodNumber' && typeof value === 'string') {
|
|
231
|
+
return Number(value)
|
|
232
|
+
}
|
|
233
|
+
if (typeName === 'ZodBoolean' && typeof value === 'string') {
|
|
234
|
+
return value === 'true'
|
|
235
|
+
}
|
|
236
|
+
return value
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Returns the best available env source for the current runtime. */
|
|
240
|
+
function defaultEnvSource(): Record<string, string | undefined> {
|
|
241
|
+
if (typeof globalThis !== 'undefined') {
|
|
242
|
+
const g = globalThis as any
|
|
243
|
+
if (g.process?.env) return g.process.env
|
|
244
|
+
if (g.Deno?.env) return new Proxy({}, { get: (_, key) => g.Deno.env.get(key) }) as any
|
|
245
|
+
}
|
|
246
|
+
return {}
|
|
247
|
+
}
|
package/src/Register.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-safe registration interface. Populate via declaration merging or codegen to enable CTA autocomplete.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* // codegen: run `mycli --codegen` to generate this file
|
|
7
|
+
* declare module 'incur' {
|
|
8
|
+
* interface Register {
|
|
9
|
+
* commands: {
|
|
10
|
+
* get: { args: { id: number }; options: {} }
|
|
11
|
+
* list: { args: {}; options: { limit: number } }
|
|
12
|
+
* }
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
// biome-ignore lint/suspicious/noEmptyInterface: populated via declaration merging
|
|
18
|
+
export interface Register {}
|