incur 0.4.7 → 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/Openapi.d.ts +14 -0
- package/dist/Openapi.d.ts.map +1 -1
- package/dist/Openapi.js +85 -1
- package/dist/Openapi.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/Openapi.test.ts +179 -0
- package/src/Openapi.ts +122 -2
- 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/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
|
-
}
|
package/src/SyncMcp.test.ts
CHANGED
|
@@ -123,6 +123,95 @@ test('register with agents: ["amp"] skips add-mcp', async () => {
|
|
|
123
123
|
expect(result.agents).toEqual(['Amp'])
|
|
124
124
|
})
|
|
125
125
|
|
|
126
|
+
test('register handles quoted command paths with spaces', async () => {
|
|
127
|
+
const { execFile } = await import('node:child_process')
|
|
128
|
+
vi.mocked(execFile).mockClear()
|
|
129
|
+
|
|
130
|
+
const result = await register('my-cli', {
|
|
131
|
+
command: '"/path/to my/cli" --mcp',
|
|
132
|
+
agents: ['amp'],
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
expect(result.agents).toEqual(['Amp'])
|
|
136
|
+
|
|
137
|
+
const configPath = join(fakeHome!, '.config', 'amp', 'settings.json')
|
|
138
|
+
const config = JSON.parse(readFileSync(configPath, 'utf-8'))
|
|
139
|
+
expect(config['amp.mcpServers']['my-cli']).toEqual({
|
|
140
|
+
command: '/path/to my/cli',
|
|
141
|
+
args: ['--mcp'],
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('register uses bare name for global binary installs', async () => {
|
|
146
|
+
process.argv[1] = '/usr/local/bin/my-cli'
|
|
147
|
+
|
|
148
|
+
const { execFile } = await import('node:child_process')
|
|
149
|
+
vi.mocked(execFile).mockClear()
|
|
150
|
+
|
|
151
|
+
const result = await register('my-cli', { agents: ['amp'] })
|
|
152
|
+
|
|
153
|
+
expect(result.command).toBe('my-cli --mcp')
|
|
154
|
+
|
|
155
|
+
const configPath = join(fakeHome!, '.config', 'amp', 'settings.json')
|
|
156
|
+
const config = JSON.parse(readFileSync(configPath, 'utf-8'))
|
|
157
|
+
expect(config['amp.mcpServers']['my-cli']).toEqual({
|
|
158
|
+
command: 'my-cli',
|
|
159
|
+
args: ['--mcp'],
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('register uses runner for source entrypoints outside node_modules', async () => {
|
|
164
|
+
process.argv[1] = join(tmp, 'dist', 'bin.js')
|
|
165
|
+
|
|
166
|
+
const { execFile } = await import('node:child_process')
|
|
167
|
+
vi.mocked(execFile).mockClear()
|
|
168
|
+
|
|
169
|
+
const result = await register('my-cli', { agents: ['amp'] })
|
|
170
|
+
|
|
171
|
+
expect(result.command).toMatch(/^(npx|pnpx|bunx)\s/)
|
|
172
|
+
expect(result.command).toContain('my-cli --mcp')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('register uses bare name for global package entrypoints under node_modules', async () => {
|
|
176
|
+
process.argv[1] = join(tmp, 'global', 'node_modules', 'my-cli', 'dist', 'bin.js')
|
|
177
|
+
|
|
178
|
+
const { execFile } = await import('node:child_process')
|
|
179
|
+
vi.mocked(execFile).mockClear()
|
|
180
|
+
|
|
181
|
+
const result = await register('my-cli', { agents: ['amp'] })
|
|
182
|
+
|
|
183
|
+
expect(result.command).toBe('my-cli --mcp')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('register uses runner for project dev dependency entrypoints under node_modules', async () => {
|
|
187
|
+
writeFileSync(
|
|
188
|
+
join(tmp, 'package.json'),
|
|
189
|
+
JSON.stringify({ devDependencies: { 'my-cli': '1.0.0' } }),
|
|
190
|
+
)
|
|
191
|
+
process.argv[1] = join(tmp, 'node_modules', 'my-cli', 'dist', 'bin.js')
|
|
192
|
+
|
|
193
|
+
const { execFile } = await import('node:child_process')
|
|
194
|
+
vi.mocked(execFile).mockClear()
|
|
195
|
+
|
|
196
|
+
const result = await register('my-cli', { agents: ['amp'] })
|
|
197
|
+
|
|
198
|
+
expect(result.command).toMatch(/^(npx|pnpx|bunx)\s/)
|
|
199
|
+
expect(result.command).toContain('my-cli --mcp')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('register uses runner for node_modules installs', async () => {
|
|
203
|
+
process.argv[1] = join(tmp, 'node_modules', '.bin', 'my-cli')
|
|
204
|
+
|
|
205
|
+
const { execFile } = await import('node:child_process')
|
|
206
|
+
vi.mocked(execFile).mockClear()
|
|
207
|
+
|
|
208
|
+
const result = await register('my-cli', { agents: ['amp'] })
|
|
209
|
+
|
|
210
|
+
// Should include a runner prefix (npx, pnpx, or bunx)
|
|
211
|
+
expect(result.command).toMatch(/^(npx|pnpx|bunx)\s/)
|
|
212
|
+
expect(result.command).toContain('--mcp')
|
|
213
|
+
})
|
|
214
|
+
|
|
126
215
|
test('register writes amp config to existing settings', async () => {
|
|
127
216
|
const configDir = join(fakeHome!, '.config', 'amp')
|
|
128
217
|
mkdirSync(configDir, { recursive: true })
|
package/src/SyncMcp.ts
CHANGED
|
@@ -11,7 +11,7 @@ export async function register(
|
|
|
11
11
|
options: register.Options = {},
|
|
12
12
|
): Promise<register.Result> {
|
|
13
13
|
const runner = detectRunner()
|
|
14
|
-
const command = options.command ??
|
|
14
|
+
const command = options.command ?? defaultCommand(name, runner)
|
|
15
15
|
const targetAgents = options.agents ?? []
|
|
16
16
|
const ampOnly = targetAgents.length === 1 && targetAgents[0] === 'amp'
|
|
17
17
|
|
|
@@ -64,7 +64,7 @@ function registerAmp(name: string, command: string): boolean {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
const [cmd, ...args] = command
|
|
67
|
+
const [cmd, ...args] = splitCommand(command)
|
|
68
68
|
if (!cmd) return false
|
|
69
69
|
|
|
70
70
|
const servers: Record<string, any> = config['amp.mcpServers'] ?? {}
|
|
@@ -98,16 +98,56 @@ export declare namespace register {
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
/** @internal
|
|
102
|
-
|
|
101
|
+
/** @internal Builds the default MCP command for the current launch mode. */
|
|
102
|
+
function defaultCommand(name: string, runner: string): string {
|
|
103
|
+
return shouldUseBareCommand(name)
|
|
104
|
+
? `${name} --mcp`
|
|
105
|
+
: `${runner} ${detectPackageSpecifier(name)} --mcp`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @internal Returns node_modules path details for the current entrypoint. */
|
|
109
|
+
function nodeModulesInfo(): { entry: string; root: string } | null {
|
|
110
|
+
const normalized = process.argv[1]?.replace(/\\/g, '/')
|
|
111
|
+
const match = normalized?.match(/^(.+)\/node_modules\/(.+)$/)
|
|
112
|
+
if (!match) return null
|
|
113
|
+
return { root: match[1]!, entry: match[2]! }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** @internal Uses the bare command only when the binary is expected on PATH. */
|
|
117
|
+
function shouldUseBareCommand(name: string): boolean {
|
|
103
118
|
const bin = process.argv[1]
|
|
104
|
-
if (!bin) return
|
|
119
|
+
if (!bin) return false
|
|
120
|
+
|
|
121
|
+
const info = nodeModulesInfo()
|
|
122
|
+
if (info) return !info.entry.startsWith('.bin/') && !packageDependsOn(info.root, name)
|
|
105
123
|
|
|
106
|
-
const
|
|
107
|
-
|
|
124
|
+
const file = bin.replace(/\\/g, '/').split('/').pop()
|
|
125
|
+
return file === name || file === `${name}.cmd` || file === `${name}.ps1`
|
|
126
|
+
}
|
|
108
127
|
|
|
128
|
+
/** @internal Checks whether the entrypoint came from a project dependency install. */
|
|
129
|
+
function packageDependsOn(root: string, name: string): boolean {
|
|
109
130
|
try {
|
|
110
|
-
const pkg = JSON.parse(readFileSync(join(
|
|
131
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'))
|
|
132
|
+
const dependencyFields = [
|
|
133
|
+
pkg.dependencies,
|
|
134
|
+
pkg.devDependencies,
|
|
135
|
+
pkg.optionalDependencies,
|
|
136
|
+
pkg.peerDependencies,
|
|
137
|
+
]
|
|
138
|
+
return dependencyFields.some((dependencies) => name in (dependencies ?? {}))
|
|
139
|
+
} catch {
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** @internal Detects the package specifier used to run this CLI (handles dlx/npx URL and version installs). */
|
|
145
|
+
export function detectPackageSpecifier(name: string): string {
|
|
146
|
+
const info = nodeModulesInfo()
|
|
147
|
+
if (!info) return name
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const pkg = JSON.parse(readFileSync(join(info.root, 'package.json'), 'utf-8'))
|
|
111
151
|
const deps = pkg.dependencies ?? {}
|
|
112
152
|
const spec = deps[name]
|
|
113
153
|
if (!spec || Object.keys(deps).length !== 1) return name
|
|
@@ -119,6 +159,30 @@ export function detectPackageSpecifier(name: string): string {
|
|
|
119
159
|
return name
|
|
120
160
|
}
|
|
121
161
|
|
|
162
|
+
/** Splits a command string into tokens, respecting single and double quotes. */
|
|
163
|
+
function splitCommand(input: string): string[] {
|
|
164
|
+
const tokens: string[] = []
|
|
165
|
+
let current = ''
|
|
166
|
+
let quote: string | null = null
|
|
167
|
+
|
|
168
|
+
for (let i = 0; i < input.length; i++) {
|
|
169
|
+
const ch = input[i]!
|
|
170
|
+
if (quote) {
|
|
171
|
+
if (ch === quote) quote = null
|
|
172
|
+
else current += ch
|
|
173
|
+
} else if (ch === '"' || ch === "'") {
|
|
174
|
+
quote = ch
|
|
175
|
+
} else if (ch === ' ') {
|
|
176
|
+
if (current) tokens.push(current)
|
|
177
|
+
current = ''
|
|
178
|
+
} else {
|
|
179
|
+
current += ch
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (current) tokens.push(current)
|
|
183
|
+
return tokens
|
|
184
|
+
}
|
|
185
|
+
|
|
122
186
|
/** Promisified execFile with stderr in error message. */
|
|
123
187
|
function exec(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
124
188
|
return new Promise((resolve, reject) => {
|
package/src/SyncSkills.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Cli, SyncSkills } from 'incur'
|
|
1
|
+
import { Cli, SyncSkills, z } from 'incur'
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
@@ -161,6 +161,45 @@ test('installed SKILL.md contains frontmatter', async () => {
|
|
|
161
161
|
rmSync(tmp, { recursive: true, force: true })
|
|
162
162
|
})
|
|
163
163
|
|
|
164
|
+
test('installed SKILL.md marks destructive commands', async () => {
|
|
165
|
+
const tmp = join(tmpdir(), `clac-destructive-test-${Date.now()}`)
|
|
166
|
+
mkdirSync(tmp, { recursive: true })
|
|
167
|
+
|
|
168
|
+
const cli = Cli.create('my-tool')
|
|
169
|
+
cli.command('destroy', {
|
|
170
|
+
description: 'Destroy data',
|
|
171
|
+
destructive: true,
|
|
172
|
+
hint: 'Deletes all data.',
|
|
173
|
+
run: () => ({}),
|
|
174
|
+
})
|
|
175
|
+
cli.command('status', {
|
|
176
|
+
description: 'Check status',
|
|
177
|
+
hint: 'Shows current status.',
|
|
178
|
+
run: () => ({}),
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const commands = Cli.toCommands.get(cli)!
|
|
182
|
+
const installDir = join(tmp, 'install')
|
|
183
|
+
mkdirSync(join(installDir, '.agents', 'skills'), { recursive: true })
|
|
184
|
+
|
|
185
|
+
const result = await SyncSkills.sync('my-tool', commands, {
|
|
186
|
+
depth: 0,
|
|
187
|
+
global: false,
|
|
188
|
+
cwd: installDir,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const content = readFileSync(join(result.paths[0]!, 'SKILL.md'), 'utf8')
|
|
192
|
+
expect(content).toContain(
|
|
193
|
+
'Deletes all data. Confirm with the user before executing this destructive command.',
|
|
194
|
+
)
|
|
195
|
+
expect(content).toContain('Shows current status.')
|
|
196
|
+
expect(content).not.toContain(
|
|
197
|
+
'Shows current status. Confirm with the user before executing this destructive command.',
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
201
|
+
})
|
|
202
|
+
|
|
164
203
|
test('sync returns unquoted descriptions from YAML frontmatter', async () => {
|
|
165
204
|
const tmp = join(tmpdir(), `clac-quoted-description-test-${Date.now()}`)
|
|
166
205
|
mkdirSync(tmp, { recursive: true })
|
|
@@ -288,6 +327,44 @@ test('list includes root command skill', async () => {
|
|
|
288
327
|
expect(names).toContain('test-ping')
|
|
289
328
|
})
|
|
290
329
|
|
|
330
|
+
test('sync uses CLI skill projection for aliases, fetch gateways, examples, and output', async () => {
|
|
331
|
+
const tmp = join(tmpdir(), `clac-sync-drift-test-${Date.now()}`)
|
|
332
|
+
mkdirSync(tmp, { recursive: true })
|
|
333
|
+
|
|
334
|
+
const cli = Cli.create('tool')
|
|
335
|
+
.command('real', {
|
|
336
|
+
description: 'Real command',
|
|
337
|
+
aliases: ['r'],
|
|
338
|
+
options: z.object({ dryRun: z.boolean().default(false) }),
|
|
339
|
+
output: z.object({ value: z.string() }),
|
|
340
|
+
examples: [{ options: { dryRun: true }, description: 'Preview' }],
|
|
341
|
+
run: () => ({ value: 'ok' }),
|
|
342
|
+
})
|
|
343
|
+
.command('api', { description: 'Raw API', fetch: () => new Response('{}') })
|
|
344
|
+
|
|
345
|
+
const commands = Cli.toCommands.get(cli)!
|
|
346
|
+
const listed = await SyncSkills.list('tool', commands)
|
|
347
|
+
const names = listed.map((skill) => skill.name)
|
|
348
|
+
expect(names).toContain('tool-api')
|
|
349
|
+
expect(names).toContain('tool-real')
|
|
350
|
+
expect(names).not.toContain('tool-r')
|
|
351
|
+
|
|
352
|
+
const installDir = join(tmp, 'install')
|
|
353
|
+
mkdirSync(join(installDir, '.agents', 'skills'), { recursive: true })
|
|
354
|
+
const synced = await SyncSkills.sync('tool', commands, {
|
|
355
|
+
depth: 0,
|
|
356
|
+
global: false,
|
|
357
|
+
cwd: installDir,
|
|
358
|
+
})
|
|
359
|
+
const content = readFileSync(join(synced.paths[0]!, 'SKILL.md'), 'utf8')
|
|
360
|
+
expect(content).toContain('Preview')
|
|
361
|
+
expect(content).toContain('## Output')
|
|
362
|
+
expect(content).toContain('Fetch gateway. Pass path segments')
|
|
363
|
+
expect(content).not.toMatch(/^# tool r$/m)
|
|
364
|
+
|
|
365
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
366
|
+
})
|
|
367
|
+
|
|
291
368
|
test('list results are sorted alphabetically', async () => {
|
|
292
369
|
const cli = Cli.create('test')
|
|
293
370
|
cli.command('zebra', { description: 'Z command', run: () => ({}) })
|