incur 0.3.4 → 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.
- package/README.md +1 -1
- package/dist/Cli.d.ts +2 -7
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +232 -358
- 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/Help.d.ts +2 -0
- package/dist/Help.d.ts.map +1 -1
- package/dist/Help.js +19 -9
- 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/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/internal/command.d.ts +116 -0
- package/dist/internal/command.d.ts.map +1 -0
- package/dist/internal/command.js +275 -0
- package/dist/internal/command.js.map +1 -0
- package/package.json +1 -1
- package/src/Cli.test.ts +150 -3
- package/src/Cli.ts +286 -438
- package/src/Completions.test.ts +35 -9
- package/src/Completions.ts +1 -2
- package/src/Help.test.ts +11 -0
- package/src/Help.ts +20 -9
- package/src/Mcp.test.ts +143 -0
- package/src/Mcp.ts +92 -84
- package/src/Skill.ts +5 -1
- package/src/SyncSkills.ts +11 -1
- package/src/e2e.test.ts +29 -16
- package/src/internal/command.ts +425 -0
package/src/Completions.test.ts
CHANGED
|
@@ -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 <
|
|
297
|
+
Usage: mycli completions <bash|fish|nushell|zsh>
|
|
298
298
|
|
|
299
|
-
|
|
300
|
-
|
|
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('
|
|
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('
|
|
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', '--'], {
|
package/src/Completions.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { z } from 'zod'
|
|
2
2
|
|
|
3
|
-
|
|
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
|
@@ -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({
|
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
|
-
|
|
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:
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
'
|
|
352
|
+
'Integrations:',
|
|
342
353
|
...builtins.map((b) => ` ${b.name}${' '.repeat(maxCmd - b.name.length)} ${b.desc}`),
|
|
343
354
|
)
|
|
344
355
|
}
|
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,
|
|
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/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/e2e.test.ts
CHANGED
|
@@ -963,7 +963,7 @@ describe('help', () => {
|
|
|
963
963
|
stream-throw Stream that throws
|
|
964
964
|
validate-fail Fails validation
|
|
965
965
|
|
|
966
|
-
|
|
966
|
+
Integrations:
|
|
967
967
|
completions Generate shell completion script
|
|
968
968
|
mcp add Register as MCP server
|
|
969
969
|
skills add Sync skill files to agents
|
|
@@ -1738,7 +1738,7 @@ describe('root command with subcommands', () => {
|
|
|
1738
1738
|
info Show info
|
|
1739
1739
|
version Show version
|
|
1740
1740
|
|
|
1741
|
-
|
|
1741
|
+
Integrations:
|
|
1742
1742
|
completions Generate shell completion script
|
|
1743
1743
|
mcp add Register as MCP server
|
|
1744
1744
|
skills add Sync skill files to agents
|
|
@@ -2441,6 +2441,18 @@ describe('fetch api', () => {
|
|
|
2441
2441
|
},
|
|
2442
2442
|
"meta": {
|
|
2443
2443
|
"command": "project create",
|
|
2444
|
+
"cta": {
|
|
2445
|
+
"commands": [
|
|
2446
|
+
{
|
|
2447
|
+
"command": "app project get p-new",
|
|
2448
|
+
"description": "View "MyProject"",
|
|
2449
|
+
},
|
|
2450
|
+
{
|
|
2451
|
+
"command": "app project list",
|
|
2452
|
+
},
|
|
2453
|
+
],
|
|
2454
|
+
"description": "Suggested commands:",
|
|
2455
|
+
},
|
|
2444
2456
|
"duration": "<stripped>",
|
|
2445
2457
|
},
|
|
2446
2458
|
"ok": true,
|
|
@@ -2524,21 +2536,22 @@ describe('fetch api', () => {
|
|
|
2524
2536
|
const cli = createApp()
|
|
2525
2537
|
expect(await fetchJson(cli, new Request('http://localhost/explode-clac')))
|
|
2526
2538
|
.toMatchInlineSnapshot(`
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
"
|
|
2535
|
-
|
|
2539
|
+
{
|
|
2540
|
+
"body": {
|
|
2541
|
+
"error": {
|
|
2542
|
+
"code": "QUOTA_EXCEEDED",
|
|
2543
|
+
"message": "Rate limit exceeded",
|
|
2544
|
+
"retryable": true,
|
|
2545
|
+
},
|
|
2546
|
+
"meta": {
|
|
2547
|
+
"command": "explode-clac",
|
|
2548
|
+
"duration": "<stripped>",
|
|
2549
|
+
},
|
|
2550
|
+
"ok": false,
|
|
2536
2551
|
},
|
|
2537
|
-
"
|
|
2538
|
-
}
|
|
2539
|
-
|
|
2540
|
-
}
|
|
2541
|
-
`)
|
|
2552
|
+
"status": 500,
|
|
2553
|
+
}
|
|
2554
|
+
`)
|
|
2542
2555
|
})
|
|
2543
2556
|
|
|
2544
2557
|
test('validation error → 400', async () => {
|