incur 0.4.8 → 0.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/Cli.d.ts +69 -19
  2. package/dist/Cli.d.ts.map +1 -1
  3. package/dist/Cli.js +419 -73
  4. package/dist/Cli.js.map +1 -1
  5. package/dist/Completions.d.ts +2 -1
  6. package/dist/Completions.d.ts.map +1 -1
  7. package/dist/Completions.js +52 -13
  8. package/dist/Completions.js.map +1 -1
  9. package/dist/Formatter.d.ts.map +1 -1
  10. package/dist/Formatter.js +6 -5
  11. package/dist/Formatter.js.map +1 -1
  12. package/dist/Help.d.ts +5 -0
  13. package/dist/Help.d.ts.map +1 -1
  14. package/dist/Help.js +19 -5
  15. package/dist/Help.js.map +1 -1
  16. package/dist/Mcp.d.ts +21 -0
  17. package/dist/Mcp.d.ts.map +1 -1
  18. package/dist/Mcp.js +44 -13
  19. package/dist/Mcp.js.map +1 -1
  20. package/dist/Parser.d.ts +14 -0
  21. package/dist/Parser.d.ts.map +1 -1
  22. package/dist/Parser.js +127 -1
  23. package/dist/Parser.js.map +1 -1
  24. package/dist/Skill.d.ts.map +1 -1
  25. package/dist/Skill.js +5 -2
  26. package/dist/Skill.js.map +1 -1
  27. package/dist/Skillgen.d.ts.map +1 -1
  28. package/dist/Skillgen.js +4 -38
  29. package/dist/Skillgen.js.map +1 -1
  30. package/dist/SyncMcp.d.ts.map +1 -1
  31. package/dist/SyncMcp.js +75 -8
  32. package/dist/SyncMcp.js.map +1 -1
  33. package/dist/SyncSkills.d.ts +9 -0
  34. package/dist/SyncSkills.d.ts.map +1 -1
  35. package/dist/SyncSkills.js +13 -74
  36. package/dist/SyncSkills.js.map +1 -1
  37. package/dist/bin.d.ts +1 -1
  38. package/dist/bin.d.ts.map +1 -1
  39. package/dist/internal/command.d.ts +31 -19
  40. package/dist/internal/command.d.ts.map +1 -1
  41. package/dist/internal/command.js +10 -5
  42. package/dist/internal/command.js.map +1 -1
  43. package/dist/internal/cta.d.ts +22 -0
  44. package/dist/internal/cta.d.ts.map +1 -0
  45. package/dist/internal/cta.js +25 -0
  46. package/dist/internal/cta.js.map +1 -0
  47. package/dist/internal/json.d.ts +5 -0
  48. package/dist/internal/json.d.ts.map +1 -0
  49. package/dist/internal/json.js +13 -0
  50. package/dist/internal/json.js.map +1 -0
  51. package/dist/internal/yaml.d.ts +12 -0
  52. package/dist/internal/yaml.d.ts.map +1 -0
  53. package/dist/internal/yaml.js +20 -0
  54. package/dist/internal/yaml.js.map +1 -0
  55. package/dist/middleware.d.ts +8 -4
  56. package/dist/middleware.d.ts.map +1 -1
  57. package/dist/middleware.js +1 -1
  58. package/dist/middleware.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/Cli.test-d.ts +73 -0
  61. package/src/Cli.test.ts +1536 -48
  62. package/src/Cli.ts +606 -108
  63. package/src/Completions.test.ts +85 -0
  64. package/src/Completions.ts +48 -9
  65. package/src/Formatter.test.ts +17 -0
  66. package/src/Formatter.ts +7 -5
  67. package/src/Help.test.ts +32 -1
  68. package/src/Help.ts +42 -4
  69. package/src/Mcp.test.ts +289 -0
  70. package/src/Mcp.ts +68 -13
  71. package/src/Parser.test.ts +173 -0
  72. package/src/Parser.ts +132 -1
  73. package/src/Skill.test.ts +6 -0
  74. package/src/Skill.ts +8 -5
  75. package/src/Skillgen.test.ts +55 -6
  76. package/src/Skillgen.ts +9 -35
  77. package/src/SyncMcp.test.ts +89 -0
  78. package/src/SyncMcp.ts +72 -8
  79. package/src/SyncSkills.test.ts +78 -1
  80. package/src/SyncSkills.ts +20 -78
  81. package/src/bun-compile.test.ts +5 -0
  82. package/src/e2e.test.ts +166 -12
  83. package/src/internal/command.ts +12 -4
  84. package/src/internal/cta.ts +58 -0
  85. package/src/internal/json.ts +16 -0
  86. package/src/internal/yaml.ts +23 -0
  87. package/src/lazy-imports.test.ts +94 -0
  88. 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 = yamlStringify(
141
- { name: slugify(title), description, requires_bin: cli, command: title },
142
- { lineWidth: 0 },
143
- ).trimEnd()
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,
@@ -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 = collectEntries(commands, [], groups)
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
- }
@@ -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 ?? `${runner} ${detectPackageSpecifier(name)} --mcp`
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.split(' ')
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 Detects the package specifier used to run this CLI (handles dlx/npx URL and version installs). */
102
- export function detectPackageSpecifier(name: string): string {
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 name
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 match = bin.match(/^(.+)[/\\]node_modules[/\\]/)
107
- if (!match) return name
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(match[1]!, 'package.json'), 'utf-8'))
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) => {
@@ -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: () => ({}) })