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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -0
  3. package/SKILL.md +664 -0
  4. package/dist/Cli.d.ts +255 -0
  5. package/dist/Cli.d.ts.map +1 -0
  6. package/dist/Cli.js +900 -0
  7. package/dist/Cli.js.map +1 -0
  8. package/dist/Errors.d.ts +92 -0
  9. package/dist/Errors.d.ts.map +1 -0
  10. package/dist/Errors.js +75 -0
  11. package/dist/Errors.js.map +1 -0
  12. package/dist/Formatter.d.ts +5 -0
  13. package/dist/Formatter.d.ts.map +1 -0
  14. package/dist/Formatter.js +91 -0
  15. package/dist/Formatter.js.map +1 -0
  16. package/dist/Help.d.ts +53 -0
  17. package/dist/Help.d.ts.map +1 -0
  18. package/dist/Help.js +231 -0
  19. package/dist/Help.js.map +1 -0
  20. package/dist/Mcp.d.ts +13 -0
  21. package/dist/Mcp.d.ts.map +1 -0
  22. package/dist/Mcp.js +140 -0
  23. package/dist/Mcp.js.map +1 -0
  24. package/dist/Parser.d.ts +24 -0
  25. package/dist/Parser.d.ts.map +1 -0
  26. package/dist/Parser.js +215 -0
  27. package/dist/Parser.js.map +1 -0
  28. package/dist/Register.d.ts +19 -0
  29. package/dist/Register.d.ts.map +1 -0
  30. package/dist/Register.js +2 -0
  31. package/dist/Register.js.map +1 -0
  32. package/dist/Schema.d.ts +4 -0
  33. package/dist/Schema.d.ts.map +1 -0
  34. package/dist/Schema.js +8 -0
  35. package/dist/Schema.js.map +1 -0
  36. package/dist/Skill.d.ts +29 -0
  37. package/dist/Skill.d.ts.map +1 -0
  38. package/dist/Skill.js +196 -0
  39. package/dist/Skill.js.map +1 -0
  40. package/dist/Skillgen.d.ts +3 -0
  41. package/dist/Skillgen.d.ts.map +1 -0
  42. package/dist/Skillgen.js +67 -0
  43. package/dist/Skillgen.js.map +1 -0
  44. package/dist/SyncMcp.d.ts +23 -0
  45. package/dist/SyncMcp.d.ts.map +1 -0
  46. package/dist/SyncMcp.js +100 -0
  47. package/dist/SyncMcp.js.map +1 -0
  48. package/dist/SyncSkills.d.ts +38 -0
  49. package/dist/SyncSkills.d.ts.map +1 -0
  50. package/dist/SyncSkills.js +163 -0
  51. package/dist/SyncSkills.js.map +1 -0
  52. package/dist/Typegen.d.ts +6 -0
  53. package/dist/Typegen.d.ts.map +1 -0
  54. package/dist/Typegen.js +92 -0
  55. package/dist/Typegen.js.map +1 -0
  56. package/dist/bin.d.ts +14 -0
  57. package/dist/bin.d.ts.map +1 -0
  58. package/dist/bin.js +30 -0
  59. package/dist/bin.js.map +1 -0
  60. package/dist/index.d.ts +15 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +14 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/internal/pm.d.ts +3 -0
  65. package/dist/internal/pm.d.ts.map +1 -0
  66. package/dist/internal/pm.js +11 -0
  67. package/dist/internal/pm.js.map +1 -0
  68. package/dist/internal/types.d.ts +11 -0
  69. package/dist/internal/types.d.ts.map +1 -0
  70. package/dist/internal/types.js +2 -0
  71. package/dist/internal/types.js.map +1 -0
  72. package/dist/internal/utils.d.ts +8 -0
  73. package/dist/internal/utils.d.ts.map +1 -0
  74. package/dist/internal/utils.js +51 -0
  75. package/dist/internal/utils.js.map +1 -0
  76. package/examples/npm/cli.ts +180 -0
  77. package/examples/npm/node_modules/.bin/incur.src +21 -0
  78. package/examples/npm/node_modules/.bin/tsx +21 -0
  79. package/examples/npm/package.json +14 -0
  80. package/examples/npm/tsconfig.json +9 -0
  81. package/examples/presto/cli.ts +246 -0
  82. package/examples/presto/node_modules/.bin/incur.src +21 -0
  83. package/examples/presto/node_modules/.bin/tsx +21 -0
  84. package/examples/presto/package.json +14 -0
  85. package/examples/presto/tsconfig.json +9 -0
  86. package/package.json +53 -2
  87. package/src/Cli.test-d.ts +135 -0
  88. package/src/Cli.test.ts +1373 -0
  89. package/src/Cli.ts +1470 -0
  90. package/src/Errors.test.ts +96 -0
  91. package/src/Errors.ts +139 -0
  92. package/src/Formatter.test.ts +245 -0
  93. package/src/Formatter.ts +106 -0
  94. package/src/Help.test.ts +124 -0
  95. package/src/Help.ts +302 -0
  96. package/src/Mcp.test.ts +254 -0
  97. package/src/Mcp.ts +195 -0
  98. package/src/Parser.test-d.ts +45 -0
  99. package/src/Parser.test.ts +118 -0
  100. package/src/Parser.ts +247 -0
  101. package/src/Register.ts +18 -0
  102. package/src/Schema.test.ts +125 -0
  103. package/src/Schema.ts +8 -0
  104. package/src/Skill.test.ts +293 -0
  105. package/src/Skill.ts +253 -0
  106. package/src/Skillgen.ts +66 -0
  107. package/src/SyncMcp.test.ts +75 -0
  108. package/src/SyncMcp.ts +132 -0
  109. package/src/SyncSkills.test.ts +92 -0
  110. package/src/SyncSkills.ts +205 -0
  111. package/src/Typegen.test.ts +150 -0
  112. package/src/Typegen.ts +107 -0
  113. package/src/bin.ts +33 -0
  114. package/src/e2e.test.ts +1710 -0
  115. package/src/index.ts +14 -0
  116. package/src/internal/pm.test.ts +38 -0
  117. package/src/internal/pm.ts +8 -0
  118. package/src/internal/types.ts +22 -0
  119. package/src/internal/utils.ts +50 -0
  120. 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
+ }
@@ -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 {}