incur 0.3.3 → 0.3.5

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.
@@ -294,27 +294,24 @@ describe('completions built-in command', () => {
294
294
  expect(output).toMatchInlineSnapshot(`
295
295
  "mycli completions — Generate shell completion script
296
296
 
297
- Usage: mycli completions <shell>
297
+ Usage: mycli completions <bash|fish|nushell|zsh>
298
298
 
299
- Shells:
300
- bash
301
- fish
302
- nushell
303
- zsh
299
+ Arguments:
300
+ shell Shell to generate completions for
304
301
 
305
302
  Setup:
306
303
  bash eval "$(mycli completions bash)" # add to ~/.bashrc
307
- zsh eval "$(mycli completions zsh)" # add to ~/.zshrc
308
304
  fish mycli completions fish | source # add to ~/.config/fish/config.fish
309
305
  nushell see \`mycli completions nushell\` # add to config.nu
306
+ zsh eval "$(mycli completions zsh)" # add to ~/.zshrc
310
307
  "
311
308
  `)
312
309
  })
313
310
 
314
- test('errors on missing shell argument', async () => {
311
+ test('shows help on missing shell argument', async () => {
315
312
  const cli = makeCli()
316
313
  const output = await serve(cli, ['completions'])
317
- expect(output).toContain('Missing shell argument')
314
+ expect(output).toContain('Generate shell completion script')
318
315
  })
319
316
 
320
317
  test('errors on unknown shell', async () => {
@@ -391,6 +388,35 @@ describe('serve integration', () => {
391
388
  expect(output).toContain('db')
392
389
  })
393
390
 
391
+ test('COMPLETE=bash includes built-in commands at root', async () => {
392
+ const cli = makeCli()
393
+ const output = await serve(cli, ['--', 'mycli', ''], {
394
+ COMPLETE: 'bash',
395
+ _COMPLETE_INDEX: '1',
396
+ })
397
+ expect(output).toContain('completions')
398
+ expect(output).toContain('mcp')
399
+ expect(output).toContain('skills')
400
+ })
401
+
402
+ test('COMPLETE=bash suggests add for skills subcommand', async () => {
403
+ const cli = makeCli()
404
+ const output = await serve(cli, ['--', 'mycli', 'skills', ''], {
405
+ COMPLETE: 'bash',
406
+ _COMPLETE_INDEX: '2',
407
+ })
408
+ expect(output).toContain('add')
409
+ })
410
+
411
+ test('COMPLETE=bash suggests add for mcp subcommand', async () => {
412
+ const cli = makeCli()
413
+ const output = await serve(cli, ['--', 'mycli', 'mcp', ''], {
414
+ COMPLETE: 'bash',
415
+ _COMPLETE_INDEX: '2',
416
+ })
417
+ expect(output).toContain('add')
418
+ })
419
+
394
420
  test('COMPLETE=zsh with words outputs candidates in zsh format', async () => {
395
421
  const cli = makeCli()
396
422
  const output = await serve(cli, ['--', 'mycli', '--'], {
@@ -1,7 +1,6 @@
1
1
  import type { z } from 'zod'
2
2
 
3
- /** Supported shells for completion generation. */
4
- export type Shell = 'bash' | 'fish' | 'nushell' | 'zsh'
3
+ import type { Shell } from './internal/command.js'
5
4
 
6
5
  /** A completion candidate with an optional description. */
7
6
  export type Candidate = {
package/src/Help.test.ts CHANGED
@@ -29,7 +29,7 @@ describe('formatCommand', () => {
29
29
  --format <toon|json|yaml|md|jsonl> Output format
30
30
  --help Show help
31
31
  --llms, --llms-full Print LLM-readable manifest
32
- --schema Show JSON Schema for a command
32
+ --schema Show JSON Schema for command
33
33
  --token-count Print token count of output (instead of output)
34
34
  --token-limit <n> Limit output to n tokens
35
35
  --token-offset <n> Skip first n tokens of output
@@ -51,7 +51,7 @@ describe('formatCommand', () => {
51
51
  --format <toon|json|yaml|md|jsonl> Output format
52
52
  --help Show help
53
53
  --llms, --llms-full Print LLM-readable manifest
54
- --schema Show JSON Schema for a command
54
+ --schema Show JSON Schema for command
55
55
  --token-count Print token count of output (instead of output)
56
56
  --token-limit <n> Limit output to n tokens
57
57
  --token-offset <n> Skip first n tokens of output
@@ -80,7 +80,7 @@ describe('formatCommand', () => {
80
80
  --format <toon|json|yaml|md|jsonl> Output format
81
81
  --help Show help
82
82
  --llms, --llms-full Print LLM-readable manifest
83
- --schema Show JSON Schema for a command
83
+ --schema Show JSON Schema for command
84
84
  --token-count Print token count of output (instead of output)
85
85
  --token-limit <n> Limit output to n tokens
86
86
  --token-offset <n> Skip first n tokens of output
@@ -88,6 +88,17 @@ describe('formatCommand', () => {
88
88
  `)
89
89
  })
90
90
 
91
+ test('synopsis uses key name for non-union args and expanded values for enums', () => {
92
+ const result = Help.formatCommand('tool run', {
93
+ args: z.object({
94
+ port: z.number().describe('Port number'),
95
+ verbose: z.boolean().optional().describe('Verbose'),
96
+ mode: z.enum(['fast', 'slow']).describe('Mode'),
97
+ }),
98
+ })
99
+ expect(result).toContain('Usage: tool run <port> [verbose] <fast|slow>')
100
+ })
101
+
91
102
  test('shows count type in help for meta count', () => {
92
103
  const result = Help.formatCommand('tool run', {
93
104
  options: z.object({
@@ -157,7 +168,7 @@ describe('formatRoot', () => {
157
168
  --format <toon|json|yaml|md|jsonl> Output format
158
169
  --help Show help
159
170
  --llms, --llms-full Print LLM-readable manifest
160
- --schema Show JSON Schema for a command
171
+ --schema Show JSON Schema for command
161
172
  --token-count Print token count of output (instead of output)
162
173
  --token-limit <n> Limit output to n tokens
163
174
  --token-offset <n> Skip first n tokens of output
@@ -182,7 +193,7 @@ describe('formatRoot', () => {
182
193
  --format <toon|json|yaml|md|jsonl> Output format
183
194
  --help Show help
184
195
  --llms, --llms-full Print LLM-readable manifest
185
- --schema Show JSON Schema for a command
196
+ --schema Show JSON Schema for command
186
197
  --token-count Print token count of output (instead of output)
187
198
  --token-limit <n> Limit output to n tokens
188
199
  --token-offset <n> Skip first n tokens of output
@@ -211,7 +222,7 @@ describe('formatRoot', () => {
211
222
  --format <toon|json|yaml|md|jsonl> Output format
212
223
  --help Show help
213
224
  --llms, --llms-full Print LLM-readable manifest
214
- --schema Show JSON Schema for a command
225
+ --schema Show JSON Schema for command
215
226
  --token-count Print token count of output (instead of output)
216
227
  --token-limit <n> Limit output to n tokens
217
228
  --token-offset <n> Skip first n tokens of output
@@ -240,7 +251,7 @@ describe('formatRoot', () => {
240
251
  --format <toon|json|yaml|md|jsonl> Output format
241
252
  --help Show help
242
253
  --llms, --llms-full Print LLM-readable manifest
243
- --schema Show JSON Schema for a command
254
+ --schema Show JSON Schema for command
244
255
  --token-count Print token count of output (instead of output)
245
256
  --token-limit <n> Limit output to n tokens
246
257
  --token-offset <n> Skip first n tokens of output
package/src/Help.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod'
2
2
 
3
+ import { builtinCommands } from './internal/command.js'
4
+
3
5
  /** Formats help text for a router CLI or command group. */
4
6
  export function formatRoot(name: string, options: formatRoot.Options = {}): string {
5
7
  const { aliases, description, version, commands = [], root = false } = options
@@ -67,6 +69,8 @@ export declare namespace formatCommand {
67
69
  examples?: { command: string; description?: string }[] | undefined
68
70
  /** Plain text hint displayed after examples and before global options. */
69
71
  hint?: string | undefined
72
+ /** Hide global options section. */
73
+ hideGlobalOptions?: boolean | undefined
70
74
  /** Zod schema for named options/flags. */
71
75
  options?: z.ZodObject<any> | undefined
72
76
  /** Show root-level built-in commands and flags. */
@@ -195,7 +199,7 @@ export function formatCommand(name: string, options: formatCommand.Options = {})
195
199
  }
196
200
  }
197
201
 
198
- lines.push(...globalOptionsLines(root))
202
+ if (!options.hideGlobalOptions) lines.push(...globalOptionsLines(root))
199
203
 
200
204
  // Environment Variables
201
205
  if (env) {
@@ -223,8 +227,11 @@ export function formatCommand(name: string, options: formatCommand.Options = {})
223
227
  function buildSynopsis(name: string, args?: z.ZodObject<any>): string {
224
228
  if (!args) return name
225
229
  const parts = [name]
226
- for (const [key, schema] of Object.entries(args.shape))
227
- parts.push((schema as z.ZodType)._zod.optout === 'optional' ? `[${key}]` : `<${key}>`)
230
+ for (const [key, schema] of Object.entries(args.shape)) {
231
+ const type = resolveTypeName(schema)
232
+ const label = type.includes('|') ? type : key
233
+ parts.push((schema as z.ZodType)._zod.optout === 'optional' ? `[${label}]` : `<${label}>`)
234
+ }
228
235
  return parts.join(' ')
229
236
  }
230
237
 
@@ -330,15 +337,19 @@ function globalOptionsLines(root = false): string[] {
330
337
  const lines: string[] = []
331
338
 
332
339
  if (root) {
333
- const builtins = [
334
- { name: 'completions', desc: 'Generate shell completion script' },
335
- { name: 'mcp add', desc: 'Register as an MCP server' },
336
- { name: 'skills add', desc: 'Sync skill files to your agent' },
337
- ]
340
+ const builtins = builtinCommands.flatMap((b) => {
341
+ if (!b.subcommands) return [{ name: b.name, desc: b.description }]
342
+ if (b.subcommands.length === 1)
343
+ return [
344
+ { name: `${b.name} ${b.subcommands[0]!.name}`, desc: b.subcommands[0]!.description },
345
+ ]
346
+ const names = b.subcommands.map((s) => s.name).join(', ')
347
+ return [{ name: b.name, desc: `${b.description} (${names})` }]
348
+ })
338
349
  const maxCmd = Math.max(...builtins.map((b) => b.name.length))
339
350
  lines.push(
340
351
  '',
341
- 'Built-in Commands:',
352
+ 'Integrations:',
342
353
  ...builtins.map((b) => ` ${b.name}${' '.repeat(maxCmd - b.name.length)} ${b.desc}`),
343
354
  )
344
355
  }
@@ -352,7 +363,7 @@ function globalOptionsLines(root = false): string[] {
352
363
  { flag: '--help', desc: 'Show help' },
353
364
  { flag: '--llms, --llms-full', desc: 'Print LLM-readable manifest' },
354
365
  ...(root ? [{ flag: '--mcp', desc: 'Start as MCP stdio server' }] : []),
355
- { flag: '--schema', desc: 'Show JSON Schema for a command' },
366
+ { flag: '--schema', desc: 'Show JSON Schema for command' },
356
367
  { flag: '--token-count', desc: 'Print token count of output (instead of output)' },
357
368
  { flag: '--token-limit <n>', desc: 'Limit output to n tokens' },
358
369
  { flag: '--token-offset <n>', desc: 'Skip first n tokens of output' },
package/src/Mcp.test.ts CHANGED
@@ -216,6 +216,149 @@ describe('Mcp', () => {
216
216
  ])
217
217
  })
218
218
 
219
+ test('middleware runs for tool calls', async () => {
220
+ const commands = new Map<string, any>()
221
+ commands.set('secret', {
222
+ description: 'Protected command',
223
+ run: () => ({ secret: 'data' }),
224
+ })
225
+ const middlewares = [
226
+ async (_c: any, next: () => Promise<void>) => {
227
+ _c.set('ran', true)
228
+ await next()
229
+ },
230
+ ]
231
+ const input = new PassThrough()
232
+ const output = new PassThrough()
233
+ const chunks: string[] = []
234
+ output.on('data', (chunk: Buffer) => chunks.push(chunk.toString()))
235
+
236
+ const done = Mcp.serve('test-cli', '1.0.0', commands, {
237
+ input,
238
+ output,
239
+ middlewares,
240
+ vars: z.object({ ran: z.boolean().default(false) }),
241
+ })
242
+
243
+ input.write(
244
+ `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: initParams })}\n`,
245
+ )
246
+ await new Promise((r) => setTimeout(r, 10))
247
+ input.write(
248
+ `${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'secret', arguments: {} } })}\n`,
249
+ )
250
+ await new Promise((r) => setTimeout(r, 20))
251
+ input.end()
252
+ await done
253
+
254
+ const responses = chunks.map((c) => JSON.parse(c.trim()))
255
+ const callRes = responses.find((r: any) => r.id === 2)
256
+ expect(callRes.result.content).toEqual([{ type: 'text', text: '{"secret":"data"}' }])
257
+ })
258
+
259
+ test('middleware error blocks tool call', async () => {
260
+ const commands = new Map<string, any>()
261
+ commands.set('secret', {
262
+ description: 'Protected',
263
+ run: () => ({ secret: true }),
264
+ })
265
+ const middlewares = [
266
+ (c: any) => {
267
+ c.error({ code: 'FORBIDDEN', message: 'not allowed' })
268
+ },
269
+ ]
270
+ const input = new PassThrough()
271
+ const output = new PassThrough()
272
+ const chunks: string[] = []
273
+ output.on('data', (chunk: Buffer) => chunks.push(chunk.toString()))
274
+
275
+ const done = Mcp.serve('test-cli', '1.0.0', commands, {
276
+ input,
277
+ output,
278
+ middlewares,
279
+ })
280
+
281
+ input.write(
282
+ `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: initParams })}\n`,
283
+ )
284
+ await new Promise((r) => setTimeout(r, 10))
285
+ input.write(
286
+ `${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'secret', arguments: {} } })}\n`,
287
+ )
288
+ await new Promise((r) => setTimeout(r, 20))
289
+ input.end()
290
+ await done
291
+
292
+ const responses = chunks.map((c) => JSON.parse(c.trim()))
293
+ const callRes = responses.find((r: any) => r.id === 2)
294
+ expect(callRes.result.isError).toBe(true)
295
+ expect(callRes.result.content[0].text).toBe('not allowed')
296
+ })
297
+
298
+ test('group middleware runs for nested tool calls', async () => {
299
+ const commands = new Map<string, any>()
300
+ const groupMiddleware = async (c: any, next: () => Promise<void>) => {
301
+ c.set('group', 'admin')
302
+ await next()
303
+ }
304
+ commands.set('admin', {
305
+ _group: true,
306
+ description: 'Admin commands',
307
+ middlewares: [groupMiddleware],
308
+ commands: new Map([
309
+ [
310
+ 'status',
311
+ {
312
+ description: 'Admin status',
313
+ run: (c: any) => ({ group: c.var.group }),
314
+ },
315
+ ],
316
+ ]),
317
+ })
318
+
319
+ const input = new PassThrough()
320
+ const output = new PassThrough()
321
+ const chunks: string[] = []
322
+ output.on('data', (chunk: Buffer) => chunks.push(chunk.toString()))
323
+
324
+ const done = Mcp.serve('test-cli', '1.0.0', commands, {
325
+ input,
326
+ output,
327
+ vars: z.object({ group: z.string().default('none') }),
328
+ })
329
+
330
+ input.write(
331
+ `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: initParams })}\n`,
332
+ )
333
+ await new Promise((r) => setTimeout(r, 10))
334
+ input.write(
335
+ `${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'admin_status', arguments: {} } })}\n`,
336
+ )
337
+ await new Promise((r) => setTimeout(r, 20))
338
+ input.end()
339
+ await done
340
+
341
+ const responses = chunks.map((c) => JSON.parse(c.trim()))
342
+ const callRes = responses.find((r: any) => r.id === 2)
343
+ expect(callRes.result.content).toEqual([{ type: 'text', text: '{"group":"admin"}' }])
344
+ })
345
+
346
+ test('env schema is parsed for tool calls', async () => {
347
+ const commands = new Map<string, any>()
348
+ commands.set('check-env', {
349
+ description: 'Check env',
350
+ env: z.object({ MY_VAR: z.string().default('default-val') }),
351
+ run: (c: any) => ({ val: c.env.MY_VAR }),
352
+ })
353
+
354
+ const [, res] = await mcpSession(commands, [
355
+ { id: 1, method: 'initialize', params: initParams },
356
+ { id: 2, method: 'tools/call', params: { name: 'check-env', arguments: {} } },
357
+ ])
358
+ const data = JSON.parse(res.result.content[0].text)
359
+ expect(data.val).toBe('default-val')
360
+ })
361
+
219
362
  test('streaming command sends progress notifications', async () => {
220
363
  const input = new PassThrough()
221
364
  const output = new PassThrough()
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
- }
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 {