incur 0.4.8 → 0.4.9
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/dist/Cli.d.ts +65 -19
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +294 -66
- package/dist/Cli.js.map +1 -1
- package/dist/Completions.d.ts +2 -1
- package/dist/Completions.d.ts.map +1 -1
- package/dist/Completions.js +52 -13
- package/dist/Completions.js.map +1 -1
- package/dist/Formatter.d.ts.map +1 -1
- package/dist/Formatter.js +6 -5
- package/dist/Formatter.js.map +1 -1
- package/dist/Help.d.ts +5 -0
- package/dist/Help.d.ts.map +1 -1
- package/dist/Help.js +19 -5
- package/dist/Help.js.map +1 -1
- package/dist/Mcp.d.ts +21 -0
- package/dist/Mcp.d.ts.map +1 -1
- package/dist/Mcp.js +25 -8
- package/dist/Mcp.js.map +1 -1
- package/dist/Parser.d.ts +14 -0
- package/dist/Parser.d.ts.map +1 -1
- package/dist/Parser.js +127 -1
- package/dist/Parser.js.map +1 -1
- package/dist/Skill.d.ts.map +1 -1
- package/dist/Skill.js +5 -2
- package/dist/Skill.js.map +1 -1
- package/dist/Skillgen.d.ts.map +1 -1
- package/dist/Skillgen.js +4 -38
- package/dist/Skillgen.js.map +1 -1
- package/dist/SyncMcp.d.ts.map +1 -1
- package/dist/SyncMcp.js +75 -8
- package/dist/SyncMcp.js.map +1 -1
- package/dist/SyncSkills.d.ts +9 -0
- package/dist/SyncSkills.d.ts.map +1 -1
- package/dist/SyncSkills.js +13 -74
- package/dist/SyncSkills.js.map +1 -1
- package/dist/bin.d.ts +1 -1
- package/dist/bin.d.ts.map +1 -1
- package/dist/internal/command.d.ts +2 -0
- package/dist/internal/command.d.ts.map +1 -1
- package/dist/internal/command.js +6 -5
- package/dist/internal/command.js.map +1 -1
- package/dist/internal/cta.d.ts +22 -0
- package/dist/internal/cta.d.ts.map +1 -0
- package/dist/internal/cta.js +25 -0
- package/dist/internal/cta.js.map +1 -0
- package/dist/internal/json.d.ts +5 -0
- package/dist/internal/json.d.ts.map +1 -0
- package/dist/internal/json.js +13 -0
- package/dist/internal/json.js.map +1 -0
- package/dist/internal/yaml.d.ts +12 -0
- package/dist/internal/yaml.d.ts.map +1 -0
- package/dist/internal/yaml.js +20 -0
- package/dist/internal/yaml.js.map +1 -0
- package/dist/middleware.d.ts +8 -4
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +1 -1
- package/dist/middleware.js.map +1 -1
- package/package.json +1 -1
- package/src/Cli.test-d.ts +71 -0
- package/src/Cli.test.ts +1164 -65
- package/src/Cli.ts +456 -101
- package/src/Completions.test.ts +84 -0
- package/src/Completions.ts +48 -9
- package/src/Formatter.test.ts +17 -0
- package/src/Formatter.ts +7 -5
- package/src/Help.test.ts +31 -0
- package/src/Help.ts +42 -4
- package/src/Mcp.test.ts +193 -0
- package/src/Mcp.ts +49 -8
- package/src/Parser.test.ts +173 -0
- package/src/Parser.ts +132 -1
- package/src/Skill.test.ts +6 -0
- package/src/Skill.ts +8 -5
- package/src/Skillgen.test.ts +55 -6
- package/src/Skillgen.ts +9 -35
- package/src/SyncMcp.test.ts +89 -0
- package/src/SyncMcp.ts +72 -8
- package/src/SyncSkills.test.ts +78 -1
- package/src/SyncSkills.ts +20 -78
- package/src/bun-compile.test.ts +5 -0
- package/src/e2e.test.ts +162 -8
- package/src/internal/command.ts +8 -4
- package/src/internal/cta.ts +58 -0
- package/src/internal/json.ts +16 -0
- package/src/internal/yaml.ts +23 -0
- package/src/lazy-imports.test.ts +94 -0
- package/src/middleware.ts +12 -3
package/src/Mcp.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'
|
|
2
1
|
import type { Readable, Writable } from 'node:stream'
|
|
3
2
|
import { z } from 'zod'
|
|
4
3
|
|
|
5
4
|
import * as Command from './internal/command.js'
|
|
5
|
+
import { formatCtaBlock, type FormattedCtaBlock } from './internal/cta.js'
|
|
6
|
+
import * as Json from './internal/json.js'
|
|
6
7
|
import type { Handler as MiddlewareHandler } from './middleware.js'
|
|
7
8
|
import * as Schema from './Schema.js'
|
|
8
9
|
|
|
@@ -13,7 +14,14 @@ export async function serve(
|
|
|
13
14
|
commands: Map<string, any>,
|
|
14
15
|
options: serve.Options = {},
|
|
15
16
|
): Promise<void> {
|
|
16
|
-
|
|
17
|
+
// Lazy: only runs when actually serving MCP, so plain command runs don't pay for the SDK import.
|
|
18
|
+
const { fromJsonSchema, McpServer, StdioServerTransport } =
|
|
19
|
+
await import('@modelcontextprotocol/server')
|
|
20
|
+
|
|
21
|
+
const server = new McpServer(
|
|
22
|
+
{ name, version },
|
|
23
|
+
options.instructions ? { instructions: options.instructions } : undefined,
|
|
24
|
+
)
|
|
17
25
|
|
|
18
26
|
for (const tool of collectTools(commands, [])) {
|
|
19
27
|
const mergedShape: Record<string, any> = {
|
|
@@ -27,7 +35,9 @@ export async function serve(
|
|
|
27
35
|
{
|
|
28
36
|
...(tool.description ? { description: tool.description } : undefined),
|
|
29
37
|
...(hasInput ? { inputSchema: z.object(mergedShape) } : undefined),
|
|
30
|
-
...(tool.outputSchema ? { outputSchema: tool.outputSchema } : undefined),
|
|
38
|
+
...(tool.outputSchema ? { outputSchema: fromJsonSchema(tool.outputSchema) } : undefined),
|
|
39
|
+
...(tool.annotations ? { annotations: tool.annotations } : undefined),
|
|
40
|
+
...(tool.instructions ? { _meta: { instructions: tool.instructions } } : undefined),
|
|
31
41
|
} as never,
|
|
32
42
|
async (...callArgs: any[]) => {
|
|
33
43
|
// registerTool passes (args, extra) when inputSchema is set, (extra) when not
|
|
@@ -67,6 +77,8 @@ export declare namespace serve {
|
|
|
67
77
|
vars?: z.ZodObject<any> | undefined
|
|
68
78
|
/** CLI version string. */
|
|
69
79
|
version?: string | undefined
|
|
80
|
+
/** Instructions describing how to use the server and its features. */
|
|
81
|
+
instructions?: string | undefined
|
|
70
82
|
}
|
|
71
83
|
}
|
|
72
84
|
|
|
@@ -88,6 +100,7 @@ export async function callTool(
|
|
|
88
100
|
): Promise<{
|
|
89
101
|
content: { type: 'text'; text: string }[]
|
|
90
102
|
structuredContent?: Record<string, unknown>
|
|
103
|
+
_meta?: { cta: FormattedCtaBlock } | undefined
|
|
91
104
|
isError?: boolean
|
|
92
105
|
}> {
|
|
93
106
|
const allMiddleware = [
|
|
@@ -122,7 +135,7 @@ export async function callTool(
|
|
|
122
135
|
if (progressToken !== undefined && options.sendNotification)
|
|
123
136
|
await options.sendNotification({
|
|
124
137
|
method: 'notifications/progress' as const,
|
|
125
|
-
params: { progressToken, progress: ++i, message:
|
|
138
|
+
params: { progressToken, progress: ++i, message: Json.stringify(chunk) },
|
|
126
139
|
})
|
|
127
140
|
}
|
|
128
141
|
} catch (err) {
|
|
@@ -131,21 +144,31 @@ export async function callTool(
|
|
|
131
144
|
isError: true,
|
|
132
145
|
}
|
|
133
146
|
}
|
|
134
|
-
return { content: [{ type: 'text', text:
|
|
147
|
+
return { content: [{ type: 'text', text: Json.stringify(chunks) }] }
|
|
135
148
|
}
|
|
136
149
|
|
|
137
150
|
if (!result.ok)
|
|
138
151
|
return {
|
|
139
|
-
content: [
|
|
152
|
+
content: [
|
|
153
|
+
{
|
|
154
|
+
type: 'text',
|
|
155
|
+
text: result.error.fieldErrors
|
|
156
|
+
? JSON.stringify(result.error)
|
|
157
|
+
: (result.error.message ?? 'Command failed'),
|
|
158
|
+
},
|
|
159
|
+
],
|
|
140
160
|
isError: true,
|
|
141
161
|
}
|
|
142
162
|
|
|
143
163
|
const data = result.data ?? null
|
|
164
|
+
const jsonData = Json.normalize(data)
|
|
165
|
+
const cta = formatCtaBlock(options.name ?? tool.name, result.cta as Command.CtaBlock | undefined)
|
|
144
166
|
return {
|
|
145
|
-
content: [{ type: 'text', text:
|
|
167
|
+
content: [{ type: 'text', text: Json.stringify(jsonData) }],
|
|
146
168
|
...(data !== null && tool.outputSchema
|
|
147
|
-
? { structuredContent:
|
|
169
|
+
? { structuredContent: jsonData as Record<string, unknown> }
|
|
148
170
|
: undefined),
|
|
171
|
+
...(cta ? { _meta: { cta } } : undefined),
|
|
149
172
|
}
|
|
150
173
|
}
|
|
151
174
|
|
|
@@ -161,10 +184,26 @@ export type ToolEntry = {
|
|
|
161
184
|
description?: string | undefined
|
|
162
185
|
inputSchema: { type: 'object'; properties: Record<string, unknown>; required?: string[] }
|
|
163
186
|
outputSchema?: Record<string, unknown> | undefined
|
|
187
|
+
annotations?: ToolAnnotations | undefined
|
|
188
|
+
instructions?: string | undefined
|
|
164
189
|
command: any
|
|
165
190
|
middlewares?: MiddlewareHandler[] | undefined
|
|
166
191
|
}
|
|
167
192
|
|
|
193
|
+
/** MCP tool annotations that describe tool behavior to clients. */
|
|
194
|
+
export type ToolAnnotations = {
|
|
195
|
+
/** A human-readable title for the tool. */
|
|
196
|
+
title?: string | undefined
|
|
197
|
+
/** If true, the tool does not modify its environment. Default: false. */
|
|
198
|
+
readOnlyHint?: boolean | undefined
|
|
199
|
+
/** If true, the tool may perform destructive updates to its environment. Meaningful only when readOnlyHint is false. Default: true. */
|
|
200
|
+
destructiveHint?: boolean | undefined
|
|
201
|
+
/** If true, calling the tool repeatedly with the same arguments has no additional effect. Meaningful only when readOnlyHint is false. Default: false. */
|
|
202
|
+
idempotentHint?: boolean | undefined
|
|
203
|
+
/** If true, the tool may interact with an open world of external entities. Default: true. */
|
|
204
|
+
openWorldHint?: boolean | undefined
|
|
205
|
+
}
|
|
206
|
+
|
|
168
207
|
/** @internal Recursively collects leaf commands as tool entries. */
|
|
169
208
|
export function collectTools(
|
|
170
209
|
commands: Map<string, any>,
|
|
@@ -189,6 +228,8 @@ export function collectTools(
|
|
|
189
228
|
...(entry.output
|
|
190
229
|
? { outputSchema: Schema.toJsonSchema(entry.output) as Record<string, unknown> }
|
|
191
230
|
: undefined),
|
|
231
|
+
...(entry.mcp?.annotations ? { annotations: entry.mcp.annotations } : undefined),
|
|
232
|
+
...(entry.mcp?.instructions ? { instructions: entry.mcp.instructions } : undefined),
|
|
192
233
|
command: entry,
|
|
193
234
|
...(parentMiddlewares.length > 0 ? { middlewares: parentMiddlewares } : undefined),
|
|
194
235
|
})
|
package/src/Parser.test.ts
CHANGED
|
@@ -376,3 +376,176 @@ describe('parse', () => {
|
|
|
376
376
|
expect(result.options).toEqual({ min: 1, max: 3 })
|
|
377
377
|
})
|
|
378
378
|
})
|
|
379
|
+
|
|
380
|
+
describe('parseGlobals', () => {
|
|
381
|
+
test('extracts known globals and returns rest', () => {
|
|
382
|
+
const schema = z.object({ rpcUrl: z.string() })
|
|
383
|
+
const result = Parser.parseGlobals(['--rpc-url', 'http://example.com', 'deploy'], schema)
|
|
384
|
+
expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' })
|
|
385
|
+
expect(result.rest).toEqual(['deploy'])
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
test('unknown flags pass through to rest', () => {
|
|
389
|
+
const schema = z.object({ rpcUrl: z.string() })
|
|
390
|
+
const result = Parser.parseGlobals(
|
|
391
|
+
['--rpc-url', 'http://example.com', '--unknown', 'val', 'deploy'],
|
|
392
|
+
schema,
|
|
393
|
+
)
|
|
394
|
+
expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' })
|
|
395
|
+
expect(result.rest).toEqual(['--unknown', 'val', 'deploy'])
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
test('handles --flag=value syntax', () => {
|
|
399
|
+
const schema = z.object({ rpcUrl: z.string() })
|
|
400
|
+
const result = Parser.parseGlobals(['--rpc-url=http://example.com', 'deploy'], schema)
|
|
401
|
+
expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' })
|
|
402
|
+
expect(result.rest).toEqual(['deploy'])
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
test('handles short aliases', () => {
|
|
406
|
+
const schema = z.object({ rpcUrl: z.string() })
|
|
407
|
+
const result = Parser.parseGlobals(
|
|
408
|
+
['-r', 'http://example.com', 'deploy'],
|
|
409
|
+
schema,
|
|
410
|
+
{ rpcUrl: 'r' },
|
|
411
|
+
)
|
|
412
|
+
expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' })
|
|
413
|
+
expect(result.rest).toEqual(['deploy'])
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test('handles boolean globals', () => {
|
|
417
|
+
const schema = z.object({ verbose: z.boolean().default(false) })
|
|
418
|
+
const result = Parser.parseGlobals(['--verbose', 'deploy'], schema)
|
|
419
|
+
expect(result.parsed).toEqual({ verbose: true })
|
|
420
|
+
expect(result.rest).toEqual(['deploy'])
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
test('validates against schema', () => {
|
|
424
|
+
const schema = z.object({ count: z.number() })
|
|
425
|
+
expect(() => Parser.parseGlobals(['--count', 'not-a-number'], schema)).toThrow()
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
test('coerces string to number', () => {
|
|
429
|
+
const schema = z.object({ limit: z.number() })
|
|
430
|
+
const result = Parser.parseGlobals(['--limit', '42', 'deploy'], schema)
|
|
431
|
+
expect(result.parsed).toEqual({ limit: 42 })
|
|
432
|
+
expect(result.rest).toEqual(['deploy'])
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
test('positionals pass through to rest', () => {
|
|
436
|
+
const schema = z.object({ verbose: z.boolean().default(false) })
|
|
437
|
+
const result = Parser.parseGlobals(['deploy', 'contract', '--verbose'], schema)
|
|
438
|
+
expect(result.parsed).toEqual({ verbose: true })
|
|
439
|
+
expect(result.rest).toEqual(['deploy', 'contract'])
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
test('-- separator: everything after -- passes through to rest including the --', () => {
|
|
443
|
+
const schema = z.object({ verbose: z.boolean().default(false) })
|
|
444
|
+
const result = Parser.parseGlobals(
|
|
445
|
+
['--verbose', '--', '--unknown', 'positional', '--also-unknown'],
|
|
446
|
+
schema,
|
|
447
|
+
)
|
|
448
|
+
expect(result.parsed).toEqual({ verbose: true })
|
|
449
|
+
expect(result.rest).toEqual(['--', '--unknown', 'positional', '--also-unknown'])
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
test('stacked short aliases: -rv where both are known boolean globals', () => {
|
|
453
|
+
const schema = z.object({
|
|
454
|
+
recursive: z.boolean().default(false),
|
|
455
|
+
verbose: z.boolean().default(false),
|
|
456
|
+
})
|
|
457
|
+
const result = Parser.parseGlobals(['-rv', 'deploy'], schema, {
|
|
458
|
+
recursive: 'r',
|
|
459
|
+
verbose: 'v',
|
|
460
|
+
})
|
|
461
|
+
expect(result.parsed).toEqual({ recursive: true, verbose: true })
|
|
462
|
+
expect(result.rest).toEqual(['deploy'])
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
test('count options: --verbose --verbose accumulates', () => {
|
|
466
|
+
const schema = z.object({ verbose: z.number().default(0).meta({ count: true }) })
|
|
467
|
+
const result = Parser.parseGlobals(['--verbose', '--verbose', 'deploy'], schema)
|
|
468
|
+
expect(result.parsed).toEqual({ verbose: 2 })
|
|
469
|
+
expect(result.rest).toEqual(['deploy'])
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
test('array options: --tag foo --tag bar collects into array', () => {
|
|
473
|
+
const schema = z.object({ tag: z.array(z.string()).default([]) })
|
|
474
|
+
const result = Parser.parseGlobals(['--tag', 'foo', '--tag', 'bar', 'deploy'], schema)
|
|
475
|
+
expect(result.parsed).toEqual({ tag: ['foo', 'bar'] })
|
|
476
|
+
expect(result.rest).toEqual(['deploy'])
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
test('unknown --no-* flags pass through to rest', () => {
|
|
480
|
+
const schema = z.object({ verbose: z.boolean().default(false) })
|
|
481
|
+
const result = Parser.parseGlobals(['--no-color', '--verbose'], schema)
|
|
482
|
+
expect(result.parsed).toEqual({ verbose: true })
|
|
483
|
+
expect(result.rest).toEqual(['--no-color'])
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
test('unknown --flag=value passes through as single token', () => {
|
|
487
|
+
const schema = z.object({ verbose: z.boolean().default(false) })
|
|
488
|
+
const result = Parser.parseGlobals(['--output=json', '--verbose'], schema)
|
|
489
|
+
expect(result.parsed).toEqual({ verbose: true })
|
|
490
|
+
expect(result.rest).toEqual(['--output=json'])
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
test('missing value for known flag throws ParseError', () => {
|
|
494
|
+
const schema = z.object({ rpcUrl: z.string() })
|
|
495
|
+
expect(() => Parser.parseGlobals(['--rpc-url'], schema)).toThrow(
|
|
496
|
+
expect.objectContaining({ name: 'Incur.ParseError' }),
|
|
497
|
+
)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
test('stacked short: count in non-last position', () => {
|
|
501
|
+
const schema = z.object({
|
|
502
|
+
verbose: z.number().default(0).meta({ count: true }),
|
|
503
|
+
recursive: z.boolean().default(false),
|
|
504
|
+
})
|
|
505
|
+
const result = Parser.parseGlobals(['-vr'], schema, { verbose: 'v', recursive: 'r' })
|
|
506
|
+
expect(result.parsed).toEqual({ verbose: 1, recursive: true })
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
test('stacked short: non-boolean in non-last position throws', () => {
|
|
510
|
+
const schema = z.object({
|
|
511
|
+
output: z.string(),
|
|
512
|
+
verbose: z.boolean().default(false),
|
|
513
|
+
})
|
|
514
|
+
expect(() => Parser.parseGlobals(['-ov', 'file'], schema, { output: 'o', verbose: 'v' })).toThrow(
|
|
515
|
+
/must be last/,
|
|
516
|
+
)
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
test('short flag value-taking as last in stacked alias', () => {
|
|
520
|
+
const schema = z.object({
|
|
521
|
+
verbose: z.boolean().default(false),
|
|
522
|
+
output: z.string(),
|
|
523
|
+
})
|
|
524
|
+
const result = Parser.parseGlobals(['-vo', 'file', 'deploy'], schema, {
|
|
525
|
+
verbose: 'v',
|
|
526
|
+
output: 'o',
|
|
527
|
+
})
|
|
528
|
+
expect(result.parsed).toEqual({ verbose: true, output: 'file' })
|
|
529
|
+
expect(result.rest).toEqual(['deploy'])
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
test('short flag missing value throws ParseError', () => {
|
|
533
|
+
const schema = z.object({ output: z.string() })
|
|
534
|
+
expect(() => Parser.parseGlobals(['-o'], schema, { output: 'o' })).toThrow(
|
|
535
|
+
expect.objectContaining({ name: 'Incur.ParseError' }),
|
|
536
|
+
)
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
test('known --no- negation for boolean global', () => {
|
|
540
|
+
const schema = z.object({ verbose: z.boolean().default(true) })
|
|
541
|
+
const result = Parser.parseGlobals(['--no-verbose', 'deploy'], schema)
|
|
542
|
+
expect(result.parsed).toEqual({ verbose: false })
|
|
543
|
+
expect(result.rest).toEqual(['deploy'])
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
test('known --flag=value with setOption', () => {
|
|
547
|
+
const schema = z.object({ tag: z.array(z.string()).default([]) })
|
|
548
|
+
const result = Parser.parseGlobals(['--tag=foo', '--tag=bar'], schema)
|
|
549
|
+
expect(result.parsed).toEqual({ tag: ['foo', 'bar'] })
|
|
550
|
+
})
|
|
551
|
+
})
|
package/src/Parser.ts
CHANGED
|
@@ -257,7 +257,7 @@ function setOption(
|
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
/** Wraps zod schema.parse(), converting ZodError to ValidationError. */
|
|
260
|
-
function zodParse(schema: z.ZodObject<any>, data: Record<string, unknown>) {
|
|
260
|
+
export function zodParse(schema: z.ZodObject<any>, data: Record<string, unknown>) {
|
|
261
261
|
try {
|
|
262
262
|
return schema.parse(data)
|
|
263
263
|
} catch (err: any) {
|
|
@@ -330,6 +330,137 @@ function coerce(value: unknown, name: string, schema: z.ZodObject<any>): unknown
|
|
|
330
330
|
return value
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
+
/** Parses known global options from argv, passing unknown flags and positionals through to `rest`. */
|
|
334
|
+
export function parseGlobals<const globals extends z.ZodObject<any>>(
|
|
335
|
+
argv: string[],
|
|
336
|
+
schema: globals,
|
|
337
|
+
alias?: Record<string, string>,
|
|
338
|
+
options: parseGlobals.Options = {},
|
|
339
|
+
): { parsed: z.output<globals>; rest: string[] } {
|
|
340
|
+
const optionNames = createOptionNames(schema, alias)
|
|
341
|
+
|
|
342
|
+
const rest: string[] = []
|
|
343
|
+
const rawOptions: Record<string, unknown> = {}
|
|
344
|
+
|
|
345
|
+
let i = 0
|
|
346
|
+
while (i < argv.length) {
|
|
347
|
+
const token = argv[i]!
|
|
348
|
+
|
|
349
|
+
if (token === '--') {
|
|
350
|
+
for (let j = i; j < argv.length; j++) rest.push(argv[j]!)
|
|
351
|
+
break
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (token.startsWith('--no-') && token.length > 5) {
|
|
355
|
+
const name = normalizeOptionName(token.slice(5), optionNames)
|
|
356
|
+
if (!name) {
|
|
357
|
+
rest.push(token)
|
|
358
|
+
} else {
|
|
359
|
+
rawOptions[name] = false
|
|
360
|
+
}
|
|
361
|
+
i++
|
|
362
|
+
} else if (token.startsWith('--')) {
|
|
363
|
+
const eqIdx = token.indexOf('=')
|
|
364
|
+
if (eqIdx !== -1) {
|
|
365
|
+
// --flag=value
|
|
366
|
+
const raw = token.slice(2, eqIdx)
|
|
367
|
+
const name = normalizeOptionName(raw, optionNames)
|
|
368
|
+
if (!name) {
|
|
369
|
+
rest.push(token)
|
|
370
|
+
} else {
|
|
371
|
+
setOption(rawOptions, name, token.slice(eqIdx + 1), schema)
|
|
372
|
+
}
|
|
373
|
+
i++
|
|
374
|
+
} else {
|
|
375
|
+
// --flag [value]
|
|
376
|
+
const name = normalizeOptionName(token.slice(2), optionNames)
|
|
377
|
+
if (!name) {
|
|
378
|
+
// Unknown flag — pass through as-is
|
|
379
|
+
rest.push(token)
|
|
380
|
+
i++
|
|
381
|
+
} else if (isCountOption(name, schema)) {
|
|
382
|
+
rawOptions[name] = ((rawOptions[name] as number) ?? 0) + 1
|
|
383
|
+
i++
|
|
384
|
+
} else if (isBooleanOption(name, schema)) {
|
|
385
|
+
rawOptions[name] = true
|
|
386
|
+
i++
|
|
387
|
+
} else {
|
|
388
|
+
const value = argv[i + 1]
|
|
389
|
+
if (value === undefined)
|
|
390
|
+
throw new ParseError({ message: `Missing value for flag: ${token}` })
|
|
391
|
+
setOption(rawOptions, name, value, schema)
|
|
392
|
+
i += 2
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
} else if (token.startsWith('-') && !token.startsWith('--') && token.length >= 2) {
|
|
396
|
+
// Short flag(s)
|
|
397
|
+
const chars = token.slice(1)
|
|
398
|
+
let allKnown = true
|
|
399
|
+
for (let j = 0; j < chars.length; j++) {
|
|
400
|
+
if (!optionNames.aliasToName.has(chars[j]!)) {
|
|
401
|
+
allKnown = false
|
|
402
|
+
break
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (!allKnown) {
|
|
407
|
+
// Unknown short flag — pass through as-is
|
|
408
|
+
rest.push(token)
|
|
409
|
+
i++
|
|
410
|
+
} else {
|
|
411
|
+
for (let j = 0; j < chars.length; j++) {
|
|
412
|
+
const short = chars[j]!
|
|
413
|
+
const name = optionNames.aliasToName.get(short)!
|
|
414
|
+
const isLast = j === chars.length - 1
|
|
415
|
+
if (!isLast) {
|
|
416
|
+
if (isCountOption(name, schema)) {
|
|
417
|
+
rawOptions[name] = ((rawOptions[name] as number) ?? 0) + 1
|
|
418
|
+
} else if (isBooleanOption(name, schema)) {
|
|
419
|
+
rawOptions[name] = true
|
|
420
|
+
} else {
|
|
421
|
+
throw new ParseError({
|
|
422
|
+
message: `Non-boolean flag -${short} must be last in a stacked alias`,
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
} else if (isCountOption(name, schema)) {
|
|
426
|
+
rawOptions[name] = ((rawOptions[name] as number) ?? 0) + 1
|
|
427
|
+
} else if (isBooleanOption(name, schema)) {
|
|
428
|
+
rawOptions[name] = true
|
|
429
|
+
} else {
|
|
430
|
+
const value = argv[i + 1]
|
|
431
|
+
if (value === undefined)
|
|
432
|
+
throw new ParseError({ message: `Missing value for flag: -${short}` })
|
|
433
|
+
setOption(rawOptions, name, value, schema)
|
|
434
|
+
i++
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
i++
|
|
438
|
+
}
|
|
439
|
+
} else {
|
|
440
|
+
// Positional — pass through
|
|
441
|
+
rest.push(token)
|
|
442
|
+
i++
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (options.validate === false) return { parsed: rawOptions as z.output<globals>, rest }
|
|
447
|
+
|
|
448
|
+
// Coerce raw option values before zod validation
|
|
449
|
+
for (const [name, value] of Object.entries(rawOptions))
|
|
450
|
+
rawOptions[name] = coerce(value, name, schema)
|
|
451
|
+
|
|
452
|
+
const parsed = zodParse(schema, rawOptions) as z.output<globals>
|
|
453
|
+
return { parsed, rest }
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export declare namespace parseGlobals {
|
|
457
|
+
/** Options for parsing global flags. */
|
|
458
|
+
type Options = {
|
|
459
|
+
/** Whether to validate parsed globals against the schema. */
|
|
460
|
+
validate?: boolean | undefined
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
333
464
|
/** Returns the best available env source for the current runtime. */
|
|
334
465
|
export function defaultEnvSource(): Record<string, string | undefined> {
|
|
335
466
|
if (typeof globalThis !== 'undefined') {
|
package/src/Skill.test.ts
CHANGED
|
@@ -204,6 +204,12 @@ describe('hash', () => {
|
|
|
204
204
|
expect(a).not.toBe(b)
|
|
205
205
|
})
|
|
206
206
|
|
|
207
|
+
test('changes when hint changes', () => {
|
|
208
|
+
const a = Skill.hash([{ name: 'ping', hint: 'Read status.' }])
|
|
209
|
+
const b = Skill.hash([{ name: 'ping', hint: 'Delete status.' }])
|
|
210
|
+
expect(a).not.toBe(b)
|
|
211
|
+
})
|
|
212
|
+
|
|
207
213
|
test('changes when schema changes', () => {
|
|
208
214
|
const a = Skill.hash([{ name: 'greet', args: z.object({ name: z.string() }) }])
|
|
209
215
|
const b = Skill.hash([{ name: 'greet', args: z.object({ name: z.string(), age: z.number() }) }])
|
package/src/Skill.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import { stringify as yamlStringify } from 'yaml'
|
|
3
2
|
import type { z } from 'zod'
|
|
4
3
|
|
|
4
|
+
import * as Yaml from './internal/yaml.js'
|
|
5
5
|
import * as Schema from './Schema.js'
|
|
6
6
|
|
|
7
7
|
/** Information about a single command, passed to `generate()`. */
|
|
@@ -137,10 +137,12 @@ function renderGroup(
|
|
|
137
137
|
? `${desc.replace(/\.$/, '')}. Run \`${title} --help\` for usage details.`
|
|
138
138
|
: `Run \`${title} --help\` for usage details.`
|
|
139
139
|
|
|
140
|
-
const fm =
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
const fm = Yaml.loadSync()
|
|
141
|
+
.stringify(
|
|
142
|
+
{ name: slugify(title), description, requires_bin: cli, command: title },
|
|
143
|
+
{ lineWidth: 0 },
|
|
144
|
+
)
|
|
145
|
+
.trimEnd()
|
|
144
146
|
const fmBlock = `---\n${fm}\n---`
|
|
145
147
|
|
|
146
148
|
const body = cmds.map((cmd) => renderCommandBody(cli, cmd)).join('\n\n---\n\n')
|
|
@@ -245,6 +247,7 @@ export function hash(commands: CommandInfo[]): string {
|
|
|
245
247
|
const data = commands.map((cmd) => ({
|
|
246
248
|
name: cmd.name,
|
|
247
249
|
description: cmd.description,
|
|
250
|
+
hint: cmd.hint,
|
|
248
251
|
args: cmd.args ? Schema.toJsonSchema(cmd.args) : undefined,
|
|
249
252
|
env: cmd.env ? Schema.toJsonSchema(cmd.env) : undefined,
|
|
250
253
|
options: cmd.options ? Schema.toJsonSchema(cmd.options) : undefined,
|
package/src/Skillgen.test.ts
CHANGED
|
@@ -60,13 +60,20 @@ test('collects group descriptions', async () => {
|
|
|
60
60
|
test('includes args, options, and examples in output', async () => {
|
|
61
61
|
const cli = Cli.create('tool', {
|
|
62
62
|
description: 'A tool',
|
|
63
|
-
}).command('greet', {
|
|
64
|
-
description: 'Greet someone',
|
|
65
|
-
args: z.object({ name: z.string().describe('Name to greet') }),
|
|
66
|
-
options: z.object({ loud: z.boolean().default(false).describe('Shout') }),
|
|
67
|
-
examples: [{ args: { name: 'world' }, description: 'Greet the world' }],
|
|
68
|
-
run: () => ({}),
|
|
69
63
|
})
|
|
64
|
+
.command('greet', {
|
|
65
|
+
description: 'Greet someone',
|
|
66
|
+
aliases: ['hi'],
|
|
67
|
+
args: z.object({ name: z.string().describe('Name to greet') }),
|
|
68
|
+
options: z.object({ loud: z.boolean().default(false).describe('Shout') }),
|
|
69
|
+
output: z.object({ message: z.string() }),
|
|
70
|
+
examples: [{ args: { name: 'world' }, description: 'Greet the world' }],
|
|
71
|
+
run: () => ({ message: 'hi' }),
|
|
72
|
+
})
|
|
73
|
+
.command('api', {
|
|
74
|
+
description: 'Proxy API',
|
|
75
|
+
fetch: () => new Response('{}'),
|
|
76
|
+
})
|
|
70
77
|
vi.mocked(importCli).mockResolvedValue(cli)
|
|
71
78
|
|
|
72
79
|
const files = await generate('fake-input', tmp, 0)
|
|
@@ -74,4 +81,46 @@ test('includes args, options, and examples in output', async () => {
|
|
|
74
81
|
expect(content).toContain('Name to greet')
|
|
75
82
|
expect(content).toContain('Shout')
|
|
76
83
|
expect(content).toContain('Greet the world')
|
|
84
|
+
expect(content).toContain('## Output')
|
|
85
|
+
expect(content).toContain('Fetch gateway. Pass path segments')
|
|
86
|
+
expect(content).not.toContain('# tool hi')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('appends confirmation hint for destructive commands', async () => {
|
|
90
|
+
const cli = Cli.create('tool')
|
|
91
|
+
.command('destroy', {
|
|
92
|
+
description: 'Destroy data',
|
|
93
|
+
destructive: true,
|
|
94
|
+
hint: 'Deletes all data.',
|
|
95
|
+
run: () => ({}),
|
|
96
|
+
})
|
|
97
|
+
.command('status', {
|
|
98
|
+
description: 'Check status',
|
|
99
|
+
hint: 'Shows current status.',
|
|
100
|
+
run: () => ({}),
|
|
101
|
+
})
|
|
102
|
+
vi.mocked(importCli).mockResolvedValue(cli)
|
|
103
|
+
|
|
104
|
+
const files = await generate('fake-input', tmp, 0)
|
|
105
|
+
const content = readFileSync(files[0]!, 'utf-8')
|
|
106
|
+
expect(content).toContain(
|
|
107
|
+
'Deletes all data. Confirm with the user before executing this destructive command.',
|
|
108
|
+
)
|
|
109
|
+
expect(content).toContain('Shows current status.')
|
|
110
|
+
expect(content).not.toContain(
|
|
111
|
+
'Shows current status. Confirm with the user before executing this destructive command.',
|
|
112
|
+
)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('uses MCP destructiveHint when generating skill files', async () => {
|
|
116
|
+
const cli = Cli.create('tool').command('deploy', {
|
|
117
|
+
description: 'Deploy',
|
|
118
|
+
mcp: { annotations: { destructiveHint: true } },
|
|
119
|
+
run: () => ({}),
|
|
120
|
+
})
|
|
121
|
+
vi.mocked(importCli).mockResolvedValue(cli)
|
|
122
|
+
|
|
123
|
+
const files = await generate('fake-input', tmp, 0)
|
|
124
|
+
const content = readFileSync(files[0]!, 'utf-8')
|
|
125
|
+
expect(content).toContain('Confirm with the user before executing this destructive command.')
|
|
77
126
|
})
|
package/src/Skillgen.ts
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path'
|
|
|
3
3
|
|
|
4
4
|
import * as Cli from './Cli.js'
|
|
5
5
|
import { importCli } from './internal/utils.js'
|
|
6
|
+
import * as Yaml from './internal/yaml.js'
|
|
6
7
|
import * as Skill from './Skill.js'
|
|
7
8
|
|
|
8
9
|
/** Imports a CLI from `input`, generates Markdown skill files, and writes them to `output`. */
|
|
@@ -13,7 +14,14 @@ export async function generate(input: string, output: string, depth = 1): Promis
|
|
|
13
14
|
|
|
14
15
|
const groups = new Map<string, string>()
|
|
15
16
|
if (cli.description) groups.set(cli.name, cli.description)
|
|
16
|
-
const entries =
|
|
17
|
+
const entries = Cli.collectSkillCommands(
|
|
18
|
+
commands,
|
|
19
|
+
[],
|
|
20
|
+
groups,
|
|
21
|
+
Cli.toRootDefinition.get(cli as unknown as Cli.Root),
|
|
22
|
+
)
|
|
23
|
+
// Pre-load yaml for `Skill.split`'s sync call path.
|
|
24
|
+
await Yaml.load()
|
|
17
25
|
const files = Skill.split(cli.name, entries, depth, groups)
|
|
18
26
|
|
|
19
27
|
if (depth > 0) await fs.rm(output, { recursive: true, force: true })
|
|
@@ -30,37 +38,3 @@ export async function generate(input: string, output: string, depth = 1): Promis
|
|
|
30
38
|
|
|
31
39
|
return written
|
|
32
40
|
}
|
|
33
|
-
|
|
34
|
-
/** Recursively collects leaf commands as `Skill.CommandInfo` and group descriptions. */
|
|
35
|
-
function collectEntries(
|
|
36
|
-
commands: Map<string, any>,
|
|
37
|
-
prefix: string[],
|
|
38
|
-
groups: Map<string, string> = new Map(),
|
|
39
|
-
): Skill.CommandInfo[] {
|
|
40
|
-
const result: Skill.CommandInfo[] = []
|
|
41
|
-
for (const [name, entry] of commands) {
|
|
42
|
-
const path = [...prefix, name]
|
|
43
|
-
if ('_group' in entry && entry._group) {
|
|
44
|
-
if (entry.description) groups.set(path.join(' '), entry.description)
|
|
45
|
-
result.push(...collectEntries(entry.commands, path, groups))
|
|
46
|
-
} else {
|
|
47
|
-
const cmd: Skill.CommandInfo = { name: path.join(' ') }
|
|
48
|
-
if (entry.description) cmd.description = entry.description
|
|
49
|
-
if (entry.args) cmd.args = entry.args
|
|
50
|
-
if (entry.env) cmd.env = entry.env
|
|
51
|
-
if (entry.hint) cmd.hint = entry.hint
|
|
52
|
-
if (entry.options) cmd.options = entry.options
|
|
53
|
-
if (entry.output) cmd.output = entry.output
|
|
54
|
-
const examples = Cli.formatExamples(entry.examples)
|
|
55
|
-
if (examples) {
|
|
56
|
-
const cmdName = path.join(' ')
|
|
57
|
-
cmd.examples = examples.map((e) => ({
|
|
58
|
-
...e,
|
|
59
|
-
command: e.command ? `${cmdName} ${e.command}` : cmdName,
|
|
60
|
-
}))
|
|
61
|
-
}
|
|
62
|
-
result.push(cmd)
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
|
|
66
|
-
}
|