incur 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -294,27 +294,24 @@ describe('completions built-in command', () => {
294
294
  expect(output).toMatchInlineSnapshot(`
295
295
  "mycli completions — Generate shell completion script
296
296
 
297
- Usage: mycli completions <shell>
297
+ Usage: mycli completions <bash|fish|nushell|zsh>
298
298
 
299
- Shells:
300
- bash
301
- fish
302
- nushell
303
- zsh
299
+ Arguments:
300
+ shell Shell to generate completions for
304
301
 
305
302
  Setup:
306
303
  bash eval "$(mycli completions bash)" # add to ~/.bashrc
307
- zsh eval "$(mycli completions zsh)" # add to ~/.zshrc
308
304
  fish mycli completions fish | source # add to ~/.config/fish/config.fish
309
305
  nushell see \`mycli completions nushell\` # add to config.nu
306
+ zsh eval "$(mycli completions zsh)" # add to ~/.zshrc
310
307
  "
311
308
  `)
312
309
  })
313
310
 
314
- test('errors on missing shell argument', async () => {
311
+ test('shows help on missing shell argument', async () => {
315
312
  const cli = makeCli()
316
313
  const output = await serve(cli, ['completions'])
317
- expect(output).toContain('Missing shell argument')
314
+ expect(output).toContain('Generate shell completion script')
318
315
  })
319
316
 
320
317
  test('errors on unknown shell', async () => {
@@ -391,6 +388,35 @@ describe('serve integration', () => {
391
388
  expect(output).toContain('db')
392
389
  })
393
390
 
391
+ test('COMPLETE=bash includes built-in commands at root', async () => {
392
+ const cli = makeCli()
393
+ const output = await serve(cli, ['--', 'mycli', ''], {
394
+ COMPLETE: 'bash',
395
+ _COMPLETE_INDEX: '1',
396
+ })
397
+ expect(output).toContain('completions')
398
+ expect(output).toContain('mcp')
399
+ expect(output).toContain('skills')
400
+ })
401
+
402
+ test('COMPLETE=bash suggests add for skills subcommand', async () => {
403
+ const cli = makeCli()
404
+ const output = await serve(cli, ['--', 'mycli', 'skills', ''], {
405
+ COMPLETE: 'bash',
406
+ _COMPLETE_INDEX: '2',
407
+ })
408
+ expect(output).toContain('add')
409
+ })
410
+
411
+ test('COMPLETE=bash suggests add for mcp subcommand', async () => {
412
+ const cli = makeCli()
413
+ const output = await serve(cli, ['--', 'mycli', 'mcp', ''], {
414
+ COMPLETE: 'bash',
415
+ _COMPLETE_INDEX: '2',
416
+ })
417
+ expect(output).toContain('add')
418
+ })
419
+
394
420
  test('COMPLETE=zsh with words outputs candidates in zsh format', async () => {
395
421
  const cli = makeCli()
396
422
  const output = await serve(cli, ['--', 'mycli', '--'], {
@@ -1,7 +1,6 @@
1
1
  import type { z } from 'zod'
2
2
 
3
- /** Supported shells for completion generation. */
4
- export type Shell = 'bash' | 'fish' | 'nushell' | 'zsh'
3
+ import type { Shell } from './internal/command.js'
5
4
 
6
5
  /** A completion candidate with an optional description. */
7
6
  export type Candidate = {
package/src/Help.test.ts CHANGED
@@ -88,6 +88,17 @@ describe('formatCommand', () => {
88
88
  `)
89
89
  })
90
90
 
91
+ test('synopsis uses key name for non-union args and expanded values for enums', () => {
92
+ const result = Help.formatCommand('tool run', {
93
+ args: z.object({
94
+ port: z.number().describe('Port number'),
95
+ verbose: z.boolean().optional().describe('Verbose'),
96
+ mode: z.enum(['fast', 'slow']).describe('Mode'),
97
+ }),
98
+ })
99
+ expect(result).toContain('Usage: tool run <port> [verbose] <fast|slow>')
100
+ })
101
+
91
102
  test('shows count type in help for meta count', () => {
92
103
  const result = Help.formatCommand('tool run', {
93
104
  options: z.object({
package/src/Help.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod'
2
2
 
3
+ import { builtinCommands } from './internal/command.js'
4
+
3
5
  /** Formats help text for a router CLI or command group. */
4
6
  export function formatRoot(name: string, options: formatRoot.Options = {}): string {
5
7
  const { aliases, description, version, commands = [], root = false } = options
@@ -67,6 +69,8 @@ export declare namespace formatCommand {
67
69
  examples?: { command: string; description?: string }[] | undefined
68
70
  /** Plain text hint displayed after examples and before global options. */
69
71
  hint?: string | undefined
72
+ /** Hide global options section. */
73
+ hideGlobalOptions?: boolean | undefined
70
74
  /** Zod schema for named options/flags. */
71
75
  options?: z.ZodObject<any> | undefined
72
76
  /** Show root-level built-in commands and flags. */
@@ -195,7 +199,7 @@ export function formatCommand(name: string, options: formatCommand.Options = {})
195
199
  }
196
200
  }
197
201
 
198
- lines.push(...globalOptionsLines(root))
202
+ if (!options.hideGlobalOptions) lines.push(...globalOptionsLines(root))
199
203
 
200
204
  // Environment Variables
201
205
  if (env) {
@@ -223,8 +227,11 @@ export function formatCommand(name: string, options: formatCommand.Options = {})
223
227
  function buildSynopsis(name: string, args?: z.ZodObject<any>): string {
224
228
  if (!args) return name
225
229
  const parts = [name]
226
- for (const [key, schema] of Object.entries(args.shape))
227
- parts.push((schema as z.ZodType)._zod.optout === 'optional' ? `[${key}]` : `<${key}>`)
230
+ for (const [key, schema] of Object.entries(args.shape)) {
231
+ const type = resolveTypeName(schema)
232
+ const label = type.includes('|') ? type : key
233
+ parts.push((schema as z.ZodType)._zod.optout === 'optional' ? `[${label}]` : `<${label}>`)
234
+ }
228
235
  return parts.join(' ')
229
236
  }
230
237
 
@@ -330,15 +337,19 @@ function globalOptionsLines(root = false): string[] {
330
337
  const lines: string[] = []
331
338
 
332
339
  if (root) {
333
- const builtins = [
334
- { name: 'completions', desc: 'Generate shell completion script' },
335
- { name: 'mcp add', desc: 'Register as MCP server' },
336
- { name: 'skills add', desc: 'Sync skill files to agents' },
337
- ]
340
+ const builtins = builtinCommands.flatMap((b) => {
341
+ if (!b.subcommands) return [{ name: b.name, desc: b.description }]
342
+ if (b.subcommands.length === 1)
343
+ return [
344
+ { name: `${b.name} ${b.subcommands[0]!.name}`, desc: b.subcommands[0]!.description },
345
+ ]
346
+ const names = b.subcommands.map((s) => s.name).join(', ')
347
+ return [{ name: b.name, desc: `${b.description} (${names})` }]
348
+ })
338
349
  const maxCmd = Math.max(...builtins.map((b) => b.name.length))
339
350
  lines.push(
340
351
  '',
341
- 'Built-in Commands:',
352
+ 'Integrations:',
342
353
  ...builtins.map((b) => ` ${b.name}${' '.repeat(maxCmd - b.name.length)} ${b.desc}`),
343
354
  )
344
355
  }
package/src/Mcp.test.ts CHANGED
@@ -216,6 +216,149 @@ describe('Mcp', () => {
216
216
  ])
217
217
  })
218
218
 
219
+ test('middleware runs for tool calls', async () => {
220
+ const commands = new Map<string, any>()
221
+ commands.set('secret', {
222
+ description: 'Protected command',
223
+ run: () => ({ secret: 'data' }),
224
+ })
225
+ const middlewares = [
226
+ async (_c: any, next: () => Promise<void>) => {
227
+ _c.set('ran', true)
228
+ await next()
229
+ },
230
+ ]
231
+ const input = new PassThrough()
232
+ const output = new PassThrough()
233
+ const chunks: string[] = []
234
+ output.on('data', (chunk: Buffer) => chunks.push(chunk.toString()))
235
+
236
+ const done = Mcp.serve('test-cli', '1.0.0', commands, {
237
+ input,
238
+ output,
239
+ middlewares,
240
+ vars: z.object({ ran: z.boolean().default(false) }),
241
+ })
242
+
243
+ input.write(
244
+ `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: initParams })}\n`,
245
+ )
246
+ await new Promise((r) => setTimeout(r, 10))
247
+ input.write(
248
+ `${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'secret', arguments: {} } })}\n`,
249
+ )
250
+ await new Promise((r) => setTimeout(r, 20))
251
+ input.end()
252
+ await done
253
+
254
+ const responses = chunks.map((c) => JSON.parse(c.trim()))
255
+ const callRes = responses.find((r: any) => r.id === 2)
256
+ expect(callRes.result.content).toEqual([{ type: 'text', text: '{"secret":"data"}' }])
257
+ })
258
+
259
+ test('middleware error blocks tool call', async () => {
260
+ const commands = new Map<string, any>()
261
+ commands.set('secret', {
262
+ description: 'Protected',
263
+ run: () => ({ secret: true }),
264
+ })
265
+ const middlewares = [
266
+ (c: any) => {
267
+ c.error({ code: 'FORBIDDEN', message: 'not allowed' })
268
+ },
269
+ ]
270
+ const input = new PassThrough()
271
+ const output = new PassThrough()
272
+ const chunks: string[] = []
273
+ output.on('data', (chunk: Buffer) => chunks.push(chunk.toString()))
274
+
275
+ const done = Mcp.serve('test-cli', '1.0.0', commands, {
276
+ input,
277
+ output,
278
+ middlewares,
279
+ })
280
+
281
+ input.write(
282
+ `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: initParams })}\n`,
283
+ )
284
+ await new Promise((r) => setTimeout(r, 10))
285
+ input.write(
286
+ `${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'secret', arguments: {} } })}\n`,
287
+ )
288
+ await new Promise((r) => setTimeout(r, 20))
289
+ input.end()
290
+ await done
291
+
292
+ const responses = chunks.map((c) => JSON.parse(c.trim()))
293
+ const callRes = responses.find((r: any) => r.id === 2)
294
+ expect(callRes.result.isError).toBe(true)
295
+ expect(callRes.result.content[0].text).toBe('not allowed')
296
+ })
297
+
298
+ test('group middleware runs for nested tool calls', async () => {
299
+ const commands = new Map<string, any>()
300
+ const groupMiddleware = async (c: any, next: () => Promise<void>) => {
301
+ c.set('group', 'admin')
302
+ await next()
303
+ }
304
+ commands.set('admin', {
305
+ _group: true,
306
+ description: 'Admin commands',
307
+ middlewares: [groupMiddleware],
308
+ commands: new Map([
309
+ [
310
+ 'status',
311
+ {
312
+ description: 'Admin status',
313
+ run: (c: any) => ({ group: c.var.group }),
314
+ },
315
+ ],
316
+ ]),
317
+ })
318
+
319
+ const input = new PassThrough()
320
+ const output = new PassThrough()
321
+ const chunks: string[] = []
322
+ output.on('data', (chunk: Buffer) => chunks.push(chunk.toString()))
323
+
324
+ const done = Mcp.serve('test-cli', '1.0.0', commands, {
325
+ input,
326
+ output,
327
+ vars: z.object({ group: z.string().default('none') }),
328
+ })
329
+
330
+ input.write(
331
+ `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: initParams })}\n`,
332
+ )
333
+ await new Promise((r) => setTimeout(r, 10))
334
+ input.write(
335
+ `${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'admin_status', arguments: {} } })}\n`,
336
+ )
337
+ await new Promise((r) => setTimeout(r, 20))
338
+ input.end()
339
+ await done
340
+
341
+ const responses = chunks.map((c) => JSON.parse(c.trim()))
342
+ const callRes = responses.find((r: any) => r.id === 2)
343
+ expect(callRes.result.content).toEqual([{ type: 'text', text: '{"group":"admin"}' }])
344
+ })
345
+
346
+ test('env schema is parsed for tool calls', async () => {
347
+ const commands = new Map<string, any>()
348
+ commands.set('check-env', {
349
+ description: 'Check env',
350
+ env: z.object({ MY_VAR: z.string().default('default-val') }),
351
+ run: (c: any) => ({ val: c.env.MY_VAR }),
352
+ })
353
+
354
+ const [, res] = await mcpSession(commands, [
355
+ { id: 1, method: 'initialize', params: initParams },
356
+ { id: 2, method: 'tools/call', params: { name: 'check-env', arguments: {} } },
357
+ ])
358
+ const data = JSON.parse(res.result.content[0].text)
359
+ expect(data.val).toBe('default-val')
360
+ })
361
+
219
362
  test('streaming command sends progress notifications', async () => {
220
363
  const input = new PassThrough()
221
364
  const output = new PassThrough()
package/src/Mcp.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import type { Readable, Writable } from 'node:stream'
4
+ import type { z } from 'zod'
4
5
 
6
+ import * as Command from './internal/command.js'
7
+ import type { Handler as MiddlewareHandler } from './middleware.js'
5
8
  import * as Schema from './Schema.js'
6
9
 
7
10
  /** Starts a stdio MCP server that exposes commands as tools. */
@@ -25,12 +28,20 @@ export async function serve(
25
28
  {
26
29
  ...(tool.description ? { description: tool.description } : undefined),
27
30
  ...(hasInput ? { inputSchema: mergedShape } : undefined),
28
- },
31
+ ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : undefined),
32
+ } as never,
29
33
  async (...callArgs: any[]) => {
30
34
  // registerTool passes (args, extra) when inputSchema is set, (extra) when not
31
35
  const params = hasInput ? (callArgs[0] as Record<string, unknown>) : {}
32
36
  const extra = hasInput ? callArgs[1] : callArgs[0]
33
- return callTool(tool, params, extra)
37
+ return callTool(tool, params, {
38
+ extra,
39
+ name,
40
+ version,
41
+ middlewares: options.middlewares,
42
+ env: options.env,
43
+ vars: options.vars,
44
+ })
34
45
  },
35
46
  )
36
47
  }
@@ -44,10 +55,18 @@ export async function serve(
44
55
  export declare namespace serve {
45
56
  /** Options for the MCP server. */
46
57
  type Options = {
58
+ /** CLI-level env schema. */
59
+ env?: z.ZodObject<any> | undefined
47
60
  /** Override input stream. Defaults to `process.stdin`. */
48
61
  input?: Readable | undefined
62
+ /** Middleware handlers registered on the root CLI. */
63
+ middlewares?: MiddlewareHandler[] | undefined
49
64
  /** Override output stream. Defaults to `process.stdout`. */
50
65
  output?: Writable | undefined
66
+ /** Vars schema for middleware variables. */
67
+ vars?: z.ZodObject<any> | undefined
68
+ /** CLI version string. */
69
+ version?: string | undefined
51
70
  }
52
71
  }
53
72
 
@@ -55,83 +74,73 @@ export declare namespace serve {
55
74
  export async function callTool(
56
75
  tool: ToolEntry,
57
76
  params: Record<string, unknown>,
58
- extra?: {
59
- _meta?: { progressToken?: string | number }
60
- sendNotification?: (n: any) => Promise<void>
61
- },
62
- ): Promise<{ content: { type: 'text'; text: string }[]; isError?: boolean }> {
63
- try {
64
- const { args, options } = splitParams(params, tool.command)
65
- const parsedArgs = tool.command.args ? tool.command.args.parse(args) : {}
66
- const parsedOptions = tool.command.options ? tool.command.options.parse(options) : {}
67
- const parsedEnv = tool.command.env ? tool.command.env.parse(process.env) : {}
68
-
69
- const sentinel = Symbol.for('incur.sentinel')
70
- const okFn = (data: unknown): never => ({ [sentinel]: 'ok', data }) as never
71
- const errorFn = (opts: { code: string; message: string }): never =>
72
- ({ [sentinel]: 'error', ...opts }) as never
73
-
74
- const raw = tool.command.run({
75
- args: parsedArgs,
76
- env: parsedEnv,
77
- options: parsedOptions,
78
- ok: okFn,
79
- error: errorFn,
80
- })
81
-
77
+ options: {
78
+ extra?: {
79
+ _meta?: { progressToken?: string | number }
80
+ sendNotification?: (n: any) => Promise<void>
81
+ }
82
+ name?: string | undefined
83
+ version?: string | undefined
84
+ middlewares?: MiddlewareHandler[] | undefined
85
+ env?: z.ZodObject<any> | undefined
86
+ vars?: z.ZodObject<any> | undefined
87
+ } = {},
88
+ ): Promise<{ content: { type: 'text'; text: string }[]; structuredContent?: Record<string, unknown>; isError?: boolean }> {
89
+ const allMiddleware = [
90
+ ...(options.middlewares ?? []),
91
+ ...((tool.middlewares as MiddlewareHandler[] | undefined) ?? []),
92
+ ...((tool.command.middleware as MiddlewareHandler[] | undefined) ?? []),
93
+ ]
94
+
95
+ const result = await Command.execute(tool.command, {
96
+ agent: true,
97
+ argv: [],
98
+ env: options.env,
99
+ format: 'json',
100
+ formatExplicit: true,
101
+ inputOptions: params,
102
+ middlewares: allMiddleware,
103
+ name: options.name ?? tool.name,
104
+ parseMode: 'flat',
105
+ path: tool.name,
106
+ vars: options.vars,
107
+ version: options.version,
108
+ })
109
+
110
+ if ('stream' in result) {
82
111
  // Streaming: send progress notifications per chunk, then return buffered result
83
- if (isAsyncGenerator(raw)) {
84
- const chunks: unknown[] = []
85
- const progressToken = extra?._meta?.progressToken
86
- let i = 0
87
- for await (const chunk of raw) {
88
- if (typeof chunk === 'object' && chunk !== null && sentinel in chunk) {
89
- const tagged = chunk as any
90
- if (tagged[sentinel] === 'error')
91
- return {
92
- content: [{ type: 'text', text: tagged.message ?? 'Command failed' }],
93
- isError: true,
94
- }
95
- }
112
+ const chunks: unknown[] = []
113
+ const progressToken = options.extra?._meta?.progressToken
114
+ let i = 0
115
+ try {
116
+ for await (const chunk of result.stream) {
96
117
  chunks.push(chunk)
97
- if (progressToken !== undefined && extra?.sendNotification)
98
- await extra.sendNotification({
118
+ if (progressToken !== undefined && options.extra?.sendNotification)
119
+ await options.extra.sendNotification({
99
120
  method: 'notifications/progress' as const,
100
121
  params: { progressToken, progress: ++i, message: JSON.stringify(chunk) },
101
122
  })
102
123
  }
103
- return { content: [{ type: 'text', text: JSON.stringify(chunks) }] }
104
- }
105
-
106
- const awaited = await raw
107
-
108
- if (typeof awaited === 'object' && awaited !== null && sentinel in awaited) {
109
- const tagged = awaited as any
110
- if (tagged[sentinel] === 'error')
111
- return {
112
- content: [{ type: 'text', text: tagged.message ?? 'Command failed' }],
113
- isError: true,
114
- }
115
- return { content: [{ type: 'text', text: JSON.stringify(tagged.data ?? null) }] }
124
+ } catch (err) {
125
+ return {
126
+ content: [{ type: 'text', text: err instanceof Error ? err.message : String(err) }],
127
+ isError: true,
128
+ }
116
129
  }
130
+ return { content: [{ type: 'text', text: JSON.stringify(chunks) }] }
131
+ }
117
132
 
118
- return { content: [{ type: 'text', text: JSON.stringify(awaited ?? null) }] }
119
- } catch (err) {
133
+ if (!result.ok)
120
134
  return {
121
- content: [{ type: 'text', text: err instanceof Error ? err.message : String(err) }],
135
+ content: [{ type: 'text', text: result.error.message ?? 'Command failed' }],
122
136
  isError: true,
123
137
  }
124
- }
125
- }
126
138
 
127
- /** @internal Type guard for async generators. */
128
- function isAsyncGenerator(value: unknown): value is AsyncGenerator<unknown, unknown, unknown> {
129
- return (
130
- typeof value === 'object' &&
131
- value !== null &&
132
- Symbol.asyncIterator in value &&
133
- typeof (value as any).next === 'function'
134
- )
139
+ const data = result.data ?? null
140
+ return {
141
+ content: [{ type: 'text', text: JSON.stringify(data) }],
142
+ ...(data !== null && tool.outputSchema ? { structuredContent: data as Record<string, unknown> } : undefined),
143
+ }
135
144
  }
136
145
 
137
146
  /** @internal A resolved tool entry from the command tree. */
@@ -139,21 +148,34 @@ export type ToolEntry = {
139
148
  name: string
140
149
  description?: string | undefined
141
150
  inputSchema: { type: 'object'; properties: Record<string, unknown>; required?: string[] }
151
+ outputSchema?: Record<string, unknown> | undefined
142
152
  command: any
153
+ middlewares?: MiddlewareHandler[] | undefined
143
154
  }
144
155
 
145
156
  /** @internal Recursively collects leaf commands as tool entries. */
146
- export function collectTools(commands: Map<string, any>, prefix: string[]): ToolEntry[] {
157
+ export function collectTools(
158
+ commands: Map<string, any>,
159
+ prefix: string[],
160
+ parentMiddlewares: MiddlewareHandler[] = [],
161
+ ): ToolEntry[] {
147
162
  const result: ToolEntry[] = []
148
163
  for (const [name, entry] of commands) {
149
164
  const path = [...prefix, name]
150
- if ('_group' in entry && entry._group) result.push(...collectTools(entry.commands, path))
151
- else {
165
+ if ('_group' in entry && entry._group) {
166
+ const groupMw = [
167
+ ...parentMiddlewares,
168
+ ...((entry.middlewares as MiddlewareHandler[] | undefined) ?? []),
169
+ ]
170
+ result.push(...collectTools(entry.commands, path, groupMw))
171
+ } else {
152
172
  result.push({
153
173
  name: path.join('_'),
154
174
  description: entry.description,
155
175
  inputSchema: buildToolSchema(entry.args, entry.options),
176
+ ...(entry.output ? { outputSchema: Schema.toJsonSchema(entry.output) as Record<string, unknown> } : undefined),
156
177
  command: entry,
178
+ ...(parentMiddlewares.length > 0 ? { middlewares: parentMiddlewares } : undefined),
157
179
  })
158
180
  }
159
181
  }
@@ -179,17 +201,3 @@ function buildToolSchema(
179
201
  return { type: 'object', properties }
180
202
  }
181
203
 
182
- /** @internal Splits flat params into args vs options using schema shapes. */
183
- function splitParams(
184
- params: Record<string, unknown>,
185
- command: any,
186
- ): { args: Record<string, unknown>; options: Record<string, unknown> } {
187
- const argKeys = new Set(command.args ? Object.keys(command.args.shape) : [])
188
- const a: Record<string, unknown> = {}
189
- const o: Record<string, unknown> = {}
190
- for (const [key, value] of Object.entries(params)) {
191
- if (argKeys.has(key)) a[key] = value
192
- else o[key] = value
193
- }
194
- return { args: a, options: o }
195
- }
package/src/Skill.ts CHANGED
@@ -127,7 +127,11 @@ function renderGroup(
127
127
  ? `${descParts.join('. ')}. Run \`${title} --help\` for usage details.`
128
128
  : `Run \`${title} --help\` for usage details.`
129
129
 
130
- const slug = title.replace(/\s+/g, '-')
130
+ const slug = title
131
+ .toLowerCase()
132
+ .replace(/[^a-z0-9-]+/g, '-')
133
+ .replace(/-{2,}/g, '-')
134
+ .replace(/^-|-$/g, '')
131
135
  const fm = ['---', `name: ${slug}`]
132
136
  fm.push(`description: ${description}`)
133
137
  fm.push(`requires_bin: ${cli}`)
package/src/SyncSkills.ts CHANGED
@@ -150,7 +150,17 @@ function collectEntries(
150
150
  function resolvePackageRoot(): string {
151
151
  const bin = process.argv[1]
152
152
  if (!bin) return process.cwd()
153
- let dir = path.dirname(fsSync.realpathSync(bin))
153
+ let dir = path.dirname(
154
+ (() => {
155
+ try {
156
+ // resolve symlinks for normal bin scripts
157
+ return fsSync.realpathSync(bin)
158
+ } catch {
159
+ // Bun compiled binaries use a virtual `/$bunfs/` path for argv[1]
160
+ return process.execPath
161
+ }
162
+ })(),
163
+ )
154
164
  const root = path.parse(dir).root
155
165
  while (dir !== root) {
156
166
  try {
package/src/e2e.test.ts CHANGED
@@ -963,7 +963,7 @@ describe('help', () => {
963
963
  stream-throw Stream that throws
964
964
  validate-fail Fails validation
965
965
 
966
- Built-in Commands:
966
+ Integrations:
967
967
  completions Generate shell completion script
968
968
  mcp add Register as MCP server
969
969
  skills add Sync skill files to agents
@@ -1738,7 +1738,7 @@ describe('root command with subcommands', () => {
1738
1738
  info Show info
1739
1739
  version Show version
1740
1740
 
1741
- Built-in Commands:
1741
+ Integrations:
1742
1742
  completions Generate shell completion script
1743
1743
  mcp add Register as MCP server
1744
1744
  skills add Sync skill files to agents
@@ -2441,6 +2441,18 @@ describe('fetch api', () => {
2441
2441
  },
2442
2442
  "meta": {
2443
2443
  "command": "project create",
2444
+ "cta": {
2445
+ "commands": [
2446
+ {
2447
+ "command": "app project get p-new",
2448
+ "description": "View "MyProject"",
2449
+ },
2450
+ {
2451
+ "command": "app project list",
2452
+ },
2453
+ ],
2454
+ "description": "Suggested commands:",
2455
+ },
2444
2456
  "duration": "<stripped>",
2445
2457
  },
2446
2458
  "ok": true,
@@ -2524,21 +2536,22 @@ describe('fetch api', () => {
2524
2536
  const cli = createApp()
2525
2537
  expect(await fetchJson(cli, new Request('http://localhost/explode-clac')))
2526
2538
  .toMatchInlineSnapshot(`
2527
- {
2528
- "body": {
2529
- "error": {
2530
- "code": "QUOTA_EXCEEDED",
2531
- "message": "Rate limit exceeded",
2532
- },
2533
- "meta": {
2534
- "command": "explode-clac",
2535
- "duration": "<stripped>",
2539
+ {
2540
+ "body": {
2541
+ "error": {
2542
+ "code": "QUOTA_EXCEEDED",
2543
+ "message": "Rate limit exceeded",
2544
+ "retryable": true,
2545
+ },
2546
+ "meta": {
2547
+ "command": "explode-clac",
2548
+ "duration": "<stripped>",
2549
+ },
2550
+ "ok": false,
2536
2551
  },
2537
- "ok": false,
2538
- },
2539
- "status": 500,
2540
- }
2541
- `)
2552
+ "status": 500,
2553
+ }
2554
+ `)
2542
2555
  })
2543
2556
 
2544
2557
  test('validation error → 400', async () => {