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/SyncSkills.ts CHANGED
@@ -2,10 +2,11 @@ import fsSync from 'node:fs'
2
2
  import fs from 'node:fs/promises'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
- import { parse as yamlParse } from 'yaml'
6
5
 
7
- import { formatExamples } from './Cli.js'
6
+ import { collectSkillCommands, parseSkillFrontmatter } from './Cli.js'
8
7
  import * as Agents from './internal/agents.js'
8
+ import * as Yaml from './internal/yaml.js'
9
+ import type * as Mcp from './Mcp.js'
9
10
  import * as Skill from './Skill.js'
10
11
 
11
12
  /** Generates skill files from a command map and installs them natively. */
@@ -17,9 +18,12 @@ export async function sync(
17
18
  const { depth = 1, description, global = true } = options
18
19
  const cwd = options.cwd ?? (global ? resolvePackageRoot() : process.cwd())
19
20
 
21
+ // Pre-load yaml for the sync call paths below (`Skill.split`, `parseFrontmatter`).
22
+ await Yaml.load()
23
+
20
24
  const groups = new Map<string, string>()
21
25
  if (description) groups.set(name, description)
22
- const entries = collectEntries(commands, [], groups, options.rootCommand)
26
+ const entries = collectSkillCommands(commands, [], groups, options.rootCommand)
23
27
  const files = Skill.split(name, entries, depth, groups)
24
28
 
25
29
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), `incur-skills-${name}-`))
@@ -31,7 +35,7 @@ export async function sync(
31
35
  : path.join(tmpDir, 'SKILL.md')
32
36
  await fs.mkdir(path.dirname(filePath), { recursive: true })
33
37
  await fs.writeFile(filePath, `${file.content}\n`)
34
- const meta = parseFrontmatter(file.content)
38
+ const meta = parseSkillFrontmatter(file.content)
35
39
  skills.push({ name: meta.name ?? (file.dir || name), description: meta.description })
36
40
  }
37
41
 
@@ -42,7 +46,7 @@ export async function sync(
42
46
  for await (const match of fs.glob(globPattern, { cwd })) {
43
47
  try {
44
48
  const content = await fs.readFile(path.resolve(cwd, match), 'utf8')
45
- const meta = parseFrontmatter(content)
49
+ const meta = parseSkillFrontmatter(content)
46
50
  const skillName =
47
51
  pattern === '_root' ? (meta.name ?? name) : path.basename(path.dirname(match))
48
52
  const dest = path.join(tmpDir, skillName, 'SKILL.md')
@@ -68,7 +72,7 @@ export async function sync(
68
72
  }
69
73
 
70
74
  // Write skills hash + names for staleness detection
71
- const hashEntries = collectEntries(commands, [], undefined, options.rootCommand)
75
+ const hashEntries = collectSkillCommands(commands, [], new Map(), options.rootCommand)
72
76
  writeMeta(
73
77
  name,
74
78
  Skill.hash(hashEntries),
@@ -100,8 +104,10 @@ export declare namespace sync {
100
104
  | {
101
105
  description?: string | undefined
102
106
  args?: any
107
+ destructive?: boolean | undefined
103
108
  env?: any
104
109
  hint?: string | undefined
110
+ mcp?: { annotations?: Mcp.ToolAnnotations | undefined } | undefined
105
111
  options?: any
106
112
  output?: any
107
113
  examples?: any[] | undefined
@@ -137,16 +143,19 @@ export async function list(
137
143
  const { depth = 1, description } = options
138
144
  const cwd = options.cwd ?? process.cwd()
139
145
 
146
+ // Pre-load yaml for the sync call paths below (`Skill.split`, `parseFrontmatter`).
147
+ await Yaml.load()
148
+
140
149
  const groups = new Map<string, string>()
141
150
  if (description) groups.set(name, description)
142
- const entries = collectEntries(commands, [], groups, options.rootCommand)
151
+ const entries = collectSkillCommands(commands, [], groups, options.rootCommand)
143
152
  const files = Skill.split(name, entries, depth, groups)
144
153
 
145
154
  const skills: list.Skill[] = []
146
155
  const installed = readInstalledSkills(name, { cwd })
147
156
 
148
157
  for (const file of files) {
149
- const meta = parseFrontmatter(file.content)
158
+ const meta = parseSkillFrontmatter(file.content)
150
159
  const skillName = meta.name ?? (file.dir || name)
151
160
  skills.push({
152
161
  name: skillName,
@@ -162,7 +171,7 @@ export async function list(
162
171
  for await (const match of fs.glob(globPattern, { cwd })) {
163
172
  try {
164
173
  const content = await fs.readFile(path.resolve(cwd, match), 'utf8')
165
- const meta = parseFrontmatter(content)
174
+ const meta = parseSkillFrontmatter(content)
166
175
  const skillName =
167
176
  pattern === '_root' ? (meta.name ?? name) : path.basename(path.dirname(match))
168
177
  if (!skills.some((s) => s.name === skillName)) {
@@ -204,8 +213,10 @@ export declare namespace list {
204
213
  | {
205
214
  description?: string | undefined
206
215
  args?: any
216
+ destructive?: boolean | undefined
207
217
  env?: any
208
218
  hint?: string | undefined
219
+ mcp?: { annotations?: Mcp.ToolAnnotations | undefined } | undefined
209
220
  options?: any
210
221
  output?: any
211
222
  examples?: any[] | undefined
@@ -223,75 +234,6 @@ export declare namespace list {
223
234
  }
224
235
  }
225
236
 
226
- /** Recursively collects leaf commands as `Skill.CommandInfo`. */
227
- function collectEntries(
228
- commands: Map<string, any>,
229
- prefix: string[],
230
- groups: Map<string, string> = new Map(),
231
- rootCommand?:
232
- | {
233
- description?: string | undefined
234
- args?: any
235
- env?: any
236
- hint?: string | undefined
237
- options?: any
238
- output?: any
239
- examples?: any[] | undefined
240
- }
241
- | undefined,
242
- ): Skill.CommandInfo[] {
243
- const result: Skill.CommandInfo[] = []
244
- if (rootCommand) {
245
- const cmd: Skill.CommandInfo = {}
246
- if (rootCommand.description) cmd.description = rootCommand.description
247
- if (rootCommand.args) cmd.args = rootCommand.args
248
- if (rootCommand.env) cmd.env = rootCommand.env
249
- if (rootCommand.hint) cmd.hint = rootCommand.hint
250
- if (rootCommand.options) cmd.options = rootCommand.options
251
- if (rootCommand.output) cmd.output = rootCommand.output
252
- const examples = formatExamples(rootCommand.examples)
253
- if (examples) cmd.examples = examples
254
- result.push(cmd)
255
- }
256
- for (const [name, entry] of commands) {
257
- const entryPath = [...prefix, name]
258
- if ('_group' in entry && entry._group) {
259
- if (entry.description) groups.set(entryPath.join(' '), entry.description)
260
- result.push(...collectEntries(entry.commands, entryPath, groups))
261
- } else {
262
- const cmd: Skill.CommandInfo = { name: entryPath.join(' ') }
263
- if (entry.description) cmd.description = entry.description
264
- if (entry.args) cmd.args = entry.args
265
- if (entry.env) cmd.env = entry.env
266
- if (entry.hint) cmd.hint = entry.hint
267
- if (entry.options) cmd.options = entry.options
268
- if (entry.output) cmd.output = entry.output
269
- const examples = formatExamples(entry.examples)
270
- if (examples) {
271
- const cmdName = entryPath.join(' ')
272
- cmd.examples = examples.map((e) => ({
273
- ...e,
274
- command: e.command ? `${cmdName} ${e.command}` : cmdName,
275
- }))
276
- }
277
- result.push(cmd)
278
- }
279
- }
280
- return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
281
- }
282
-
283
- function parseFrontmatter(content: string): {
284
- description?: string | undefined
285
- name?: string | undefined
286
- } {
287
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
288
- if (!match) return {}
289
-
290
- const meta = yamlParse(match[1]!)
291
- if (!meta || typeof meta !== 'object') return {}
292
- return meta as { description?: string | undefined; name?: string | undefined }
293
- }
294
-
295
237
  /** Resolves the package root from the executing bin script (`process.argv[1]`). Walks up from the bin's directory looking for `package.json`. Falls back to `process.cwd()`. */
296
238
  function resolvePackageRoot(): string {
297
239
  const bin = process.argv[1]
@@ -70,6 +70,11 @@ cli.serve()
70
70
  expect(stdout).toContain('result: HELLO')
71
71
  })
72
72
 
73
+ test('runs command with --format yaml (lazy yaml import)', async () => {
74
+ const { stdout } = await exec(bin, ['ping', '--format', 'yaml'])
75
+ expect(stdout).toContain('pong: true')
76
+ })
77
+
73
78
  test('shows help', async () => {
74
79
  const { stdout } = await exec(bin, ['--help'])
75
80
  expect(stdout).toContain('test-cli')
package/src/e2e.test.ts CHANGED
@@ -984,7 +984,7 @@ describe('help', () => {
984
984
 
985
985
  Integrations:
986
986
  completions Generate shell completion script
987
- mcp add Register as MCP server
987
+ mcp Register as MCP server (add, doctor)
988
988
  skills Sync skill files to agents (add, list)
989
989
 
990
990
  Global Options:
@@ -1513,9 +1513,9 @@ describe('--llms', () => {
1513
1513
 
1514
1514
  | Command | Description |
1515
1515
  |---------|-------------|
1516
- | \`app auth auth login\` | Log in to the service |
1517
- | \`app auth auth logout\` | Log out of the service |
1518
- | \`app auth auth status\` | Show authentication status |
1516
+ | \`app auth login\` | Log in to the service |
1517
+ | \`app auth logout\` | Log out of the service |
1518
+ | \`app auth status\` | Show authentication status |
1519
1519
 
1520
1520
  Run \`app auth --llms-full\` for full manifest. Run \`app auth <command> --schema\` for argument details.
1521
1521
  "
@@ -1531,9 +1531,9 @@ describe('--llms', () => {
1531
1531
 
1532
1532
  | Command | Description |
1533
1533
  |---------|-------------|
1534
- | \`app project deploy project deploy create <env>\` | Create a deployment |
1535
- | \`app project deploy project deploy rollback <deployId>\` | Rollback a deployment |
1536
- | \`app project deploy project deploy status <deployId>\` | Check deployment status |
1534
+ | \`app project deploy create <env>\` | Create a deployment |
1535
+ | \`app project deploy rollback <deployId>\` | Rollback a deployment |
1536
+ | \`app project deploy status <deployId>\` | Check deployment status |
1537
1537
 
1538
1538
  Run \`app project deploy --llms-full\` for full manifest. Run \`app project deploy <command> --schema\` for argument details.
1539
1539
  "
@@ -1759,7 +1759,7 @@ describe('root command with subcommands', () => {
1759
1759
 
1760
1760
  Integrations:
1761
1761
  completions Generate shell completion script
1762
- mcp add Register as MCP server
1762
+ mcp Register as MCP server (add, doctor)
1763
1763
  skills Sync skill files to agents (add, list)
1764
1764
 
1765
1765
  Global Options:
@@ -2436,7 +2436,7 @@ describe('hosted OpenAPI CLI', () => {
2436
2436
 
2437
2437
  Integrations:
2438
2438
  completions Generate shell completion script
2439
- mcp add Register as MCP server
2439
+ mcp Register as MCP server (add, doctor)
2440
2440
  skills Sync skill files to agents (add, list)
2441
2441
 
2442
2442
  Global Options:
@@ -2517,7 +2517,7 @@ describe('hosted OpenAPI CLI', () => {
2517
2517
 
2518
2518
  Integrations:
2519
2519
  completions Generate shell completion script
2520
- mcp add Register as MCP server
2520
+ mcp Register as MCP server (add, doctor)
2521
2521
  skills Sync skill files to agents (add, list)
2522
2522
 
2523
2523
  Global Options:
@@ -2598,7 +2598,7 @@ describe('hosted OpenAPI CLI', () => {
2598
2598
  })
2599
2599
  })
2600
2600
 
2601
- async function fetchJson(cli: Cli.Cli<any, any, any>, req: Request) {
2601
+ async function fetchJson(cli: Cli.Cli<any, any, any, any>, req: Request) {
2602
2602
  const res = await cli.fetch(req)
2603
2603
  const body = await res.json()
2604
2604
  if (body.meta?.duration) body.meta.duration = '<stripped>'
@@ -2833,6 +2833,8 @@ describe('fetch api', () => {
2833
2833
  .trim()
2834
2834
  .split('\n')
2835
2835
  .map((l) => JSON.parse(l))
2836
+ expect(lines[2].meta.duration).toMatch(/^\d+ms$/)
2837
+ lines[2].meta.duration = '<stripped>'
2836
2838
  expect(lines).toMatchInlineSnapshot(`
2837
2839
  [
2838
2840
  {
@@ -2850,6 +2852,7 @@ describe('fetch api', () => {
2850
2852
  {
2851
2853
  "meta": {
2852
2854
  "command": "stream",
2855
+ "duration": "<stripped>",
2853
2856
  },
2854
2857
  "ok": true,
2855
2858
  "type": "done",
@@ -2947,7 +2950,7 @@ describe('fetch api', () => {
2947
2950
  clientInfo: { name: 'test-client', version: '1.0.0' },
2948
2951
  },
2949
2952
  })
2950
- const sessionId = res.headers.get('mcp-session-id')!
2953
+ const sessionId = res.headers.get('mcp-session-id') ?? undefined
2951
2954
  await mcpRequest(cli, { jsonrpc: '2.0', method: 'notifications/initialized' }, sessionId)
2952
2955
  return sessionId
2953
2956
  }
@@ -2965,6 +2968,7 @@ describe('fetch api', () => {
2965
2968
  },
2966
2969
  })
2967
2970
  expect(res.status).toBe(200)
2971
+ expect(res.headers.get('mcp-session-id')).toBeNull()
2968
2972
  const body = await res.json()
2969
2973
  expect({
2970
2974
  serverInfo: body.result.serverInfo,
@@ -2980,6 +2984,41 @@ describe('fetch api', () => {
2980
2984
  `)
2981
2985
  })
2982
2986
 
2987
+ test('tools/list works without session state', async () => {
2988
+ const cli = createApp()
2989
+ await mcpRequest(cli, {
2990
+ jsonrpc: '2.0',
2991
+ id: 1,
2992
+ method: 'initialize',
2993
+ params: {
2994
+ protocolVersion: '2025-03-26',
2995
+ capabilities: {},
2996
+ clientInfo: { name: 'test-client', version: '1.0.0' },
2997
+ },
2998
+ })
2999
+ const res = await mcpRequest(cli, {
3000
+ jsonrpc: '2.0',
3001
+ id: 2,
3002
+ method: 'tools/list',
3003
+ params: {},
3004
+ })
3005
+ expect(res.status).toBe(200)
3006
+ const body = await res.json()
3007
+ expect(body.result.tools.map((t: any) => t.name).sort()).toContain('ping')
3008
+ })
3009
+
3010
+ test('GET /mcp returns method not allowed in stateless mode', async () => {
3011
+ const cli = createApp()
3012
+ const res = await cli.fetch(
3013
+ new Request('http://localhost/mcp', {
3014
+ method: 'GET',
3015
+ headers: { accept: 'text/event-stream' },
3016
+ }),
3017
+ )
3018
+ expect(res.status).toBe(405)
3019
+ expect(res.headers.get('allow')).toBe('POST')
3020
+ })
3021
+
2983
3022
  test('tools/list → lists all registered tools', async () => {
2984
3023
  const cli = createApp()
2985
3024
  const sessionId = await initSession(cli)
@@ -3172,6 +3211,71 @@ describe('fetch api', () => {
3172
3211
  })
3173
3212
  })
3174
3213
 
3214
+ describe('globals', () => {
3215
+ test('global flags flow through middleware for nested commands', async () => {
3216
+ const { output } = await serve(createGlobalsApp(), [
3217
+ 'deploy',
3218
+ 'status',
3219
+ '--api-token',
3220
+ 'secret',
3221
+ '--profile',
3222
+ 'production',
3223
+ '--format',
3224
+ 'json',
3225
+ ])
3226
+ expect(json(output)).toEqual({
3227
+ command: 'deploy status',
3228
+ profile: 'production',
3229
+ token: 'secret',
3230
+ })
3231
+ })
3232
+
3233
+ test('informational commands do not require required globals', async () => {
3234
+ const cli = createGlobalsApp()
3235
+
3236
+ const help = await serve(cli, ['--help'])
3237
+ expect(help.exitCode).toBeUndefined()
3238
+ expect(help.output).toContain('Custom Global Options:')
3239
+ expect(help.output).toContain('--api-token')
3240
+
3241
+ const schema = await serve(cli, ['whoami', '--schema', '--format', 'json'])
3242
+ expect(schema.exitCode).toBeUndefined()
3243
+ expect(json(schema.output).globals.properties.apiToken).toBeDefined()
3244
+
3245
+ const llms = await serve(cli, ['--llms', '--format', 'json'])
3246
+ expect(llms.exitCode).toBeUndefined()
3247
+ expect(json(llms.output).globals.properties.apiToken).toBeDefined()
3248
+
3249
+ const version = await serve(cli, ['--version'])
3250
+ expect(version.exitCode).toBeUndefined()
3251
+ expect(version.output).toBe('1.0.0\n')
3252
+ })
3253
+
3254
+ test('fetch requests expose globals to middleware and command query params to handlers', async () => {
3255
+ const result = await fetchJson(
3256
+ createGlobalsApp(),
3257
+ new Request('http://localhost/search?apiToken=secret&profile=staging&limit=2'),
3258
+ )
3259
+ expect(result).toMatchInlineSnapshot(`
3260
+ {
3261
+ "body": {
3262
+ "data": {
3263
+ "limit": 2,
3264
+ "profile": "staging",
3265
+ "token": "secret",
3266
+ },
3267
+ "meta": {
3268
+ "command": "search",
3269
+ "duration": "<stripped>",
3270
+ },
3271
+ "ok": true,
3272
+ },
3273
+ "status": 200,
3274
+ }
3275
+ `)
3276
+ })
3277
+ })
3278
+
3175
3279
  describe('.well-known/skills', () => {
3176
3280
  async function fetchSkills(cli: Cli.Cli<any, any, any>, path: string) {
3177
3281
  const res = await cli.fetch(new Request(`http://localhost${path}`))
@@ -3358,6 +3462,56 @@ function createMiddlewareApp() {
3358
3462
  return { cli, order }
3359
3463
  }
3360
3464
 
3465
+ const globalsVars = z.object({
3466
+ apiToken: z.string().default(''),
3467
+ profile: z.string().default(''),
3468
+ })
3469
+
3470
+ function createGlobalsApp() {
3471
+ const deploy = Cli.create('deploy', {
3472
+ description: 'Deployment commands',
3473
+ vars: globalsVars,
3474
+ }).command('status', {
3475
+ description: 'Show deployment status',
3476
+ run(c) {
3477
+ return {
3478
+ command: 'deploy status',
3479
+ profile: c.var.profile,
3480
+ token: c.var.apiToken,
3481
+ }
3482
+ },
3483
+ })
3484
+
3485
+ return Cli.create('global-app', {
3486
+ version: '1.0.0',
3487
+ globals: z.object({
3488
+ apiToken: z.string().describe('API token'),
3489
+ profile: z.string().default('dev').describe('Profile name'),
3490
+ }),
3491
+ globalAlias: { apiToken: 't' },
3492
+ vars: globalsVars,
3493
+ })
3494
+ .use(async (c, next) => {
3495
+ c.set('apiToken', c.globals.apiToken)
3496
+ c.set('profile', c.globals.profile)
3497
+ await next()
3498
+ })
3499
+ .command('whoami', {
3500
+ description: 'Show active profile',
3501
+ run(c) {
3502
+ return { profile: c.var.profile, token: c.var.apiToken }
3503
+ },
3504
+ })
3505
+ .command('search', {
3506
+ description: 'Search resources',
3507
+ options: z.object({ limit: z.coerce.number().default(10) }),
3508
+ run(c) {
3509
+ return { limit: c.options.limit, profile: c.var.profile, token: c.var.apiToken }
3510
+ },
3511
+ })
3512
+ .command(deploy)
3513
+ }
3514
+
3361
3515
  function createApp() {
3362
3516
  const auth = Cli.create('auth', { description: 'Authentication commands' })
3363
3517
  .command('login', {
@@ -44,6 +44,7 @@ export async function execute(command: any, options: execute.Options): Promise<e
44
44
  version,
45
45
  envSource = process.env,
46
46
  env: envSchema,
47
+ globals = {},
47
48
  vars: varsSchema,
48
49
  middlewares = [],
49
50
  } = options
@@ -81,12 +82,12 @@ export async function execute(command: any, options: execute.Options): Promise<e
81
82
  // HTTP mode: positional args from URL path segments, options from body/query
82
83
  const parsed = Parser.parse(argv, { args: command.args })
83
84
  args = parsed.args
84
- parsedOptions = command.options ? command.options.parse(inputOptions) : {}
85
+ parsedOptions = command.options ? Parser.zodParse(command.options, inputOptions) : {}
85
86
  } else {
86
87
  // MCP mode: all params come from inputOptions, split into args vs options
87
88
  const split = splitParams(inputOptions, command)
88
- args = command.args ? command.args.parse(split.args) : {}
89
- parsedOptions = command.options ? command.options.parse(split.options) : {}
89
+ args = command.args ? Parser.zodParse(command.args, split.args) : {}
90
+ parsedOptions = command.options ? Parser.zodParse(command.options, split.options) : {}
90
91
  }
91
92
 
92
93
  // Parse env
@@ -128,7 +129,7 @@ export async function execute(command: any, options: execute.Options): Promise<e
128
129
  })
129
130
  async function* wrapped() {
130
131
  try {
131
- yield* raw as AsyncGenerator<unknown, unknown, unknown>
132
+ return yield* raw as AsyncGenerator<unknown, unknown, unknown>
132
133
  } finally {
133
134
  resolveStreamConsumed!()
134
135
  }
@@ -201,6 +202,7 @@ export async function execute(command: any, options: execute.Options): Promise<e
201
202
  error: errorFn,
202
203
  format: format as any,
203
204
  formatExplicit,
205
+ globals,
204
206
  name,
205
207
  set(key: string, value: unknown) {
206
208
  varsMap[key] = value
@@ -285,6 +287,8 @@ export declare namespace execute {
285
287
  format: string
286
288
  /** Whether the format was explicitly requested. */
287
289
  formatExplicit: boolean
290
+ /** Parsed global options. Defaults to `{}` when not provided. */
291
+ globals?: Record<string, unknown> | undefined
288
292
  /** Raw parsed options (from query params, JSON body, or MCP params). For CLI, pass `{}`. */
289
293
  inputOptions: Record<string, unknown>
290
294
  /** Middleware handlers (root + group + command, already collected). */
@@ -415,6 +419,10 @@ export const builtinCommands = [
415
419
  noGlobal: z.boolean().optional().describe('Install to project instead of globally'),
416
420
  }),
417
421
  }),
422
+ subcommand({
423
+ name: 'doctor',
424
+ description: 'Validate MCP server startup and tool listing',
425
+ }),
418
426
  ],
419
427
  },
420
428
  {
@@ -0,0 +1,58 @@
1
+ /** @internal A CTA block before command names are expanded. */
2
+ export type CtaBlock = {
3
+ commands: unknown[]
4
+ description?: string | undefined
5
+ }
6
+
7
+ /** @internal A formatted CTA block as it appears in output metadata. */
8
+ export type FormattedCtaBlock = {
9
+ /** Formatted command suggestions. */
10
+ commands: FormattedCta[]
11
+ /** Human-readable label for the CTA block. */
12
+ description: string
13
+ }
14
+
15
+ /** @internal A formatted CTA as it appears in output metadata. */
16
+ export type FormattedCta = {
17
+ /** The full command string with args and options folded in. */
18
+ command: string
19
+ /** A short description of what the command does. */
20
+ description?: string | undefined
21
+ }
22
+
23
+ type Cta =
24
+ | string
25
+ | {
26
+ args?: Record<string, unknown> | undefined
27
+ command: string
28
+ description?: string | undefined
29
+ options?: Record<string, unknown> | undefined
30
+ }
31
+
32
+ /** @internal Formats a CTA block into the output metadata shape. */
33
+ export function formatCtaBlock(
34
+ name: string,
35
+ block: CtaBlock | undefined,
36
+ ): FormattedCtaBlock | undefined {
37
+ if (!block || block.commands.length === 0) return undefined
38
+ return {
39
+ description:
40
+ block.description ??
41
+ (block.commands.length === 1 ? 'Suggested command:' : 'Suggested commands:'),
42
+ commands: block.commands.map((c) => formatCta(name, c as Cta)),
43
+ }
44
+ }
45
+
46
+ /** @internal Formats a CTA by prefixing the CLI name. */
47
+ function formatCta(name: string, cta: Cta): FormattedCta {
48
+ if (typeof cta === 'string') return { command: `${name} ${cta}` }
49
+ const prefix = cta.command === name || cta.command.startsWith(`${name} `) ? '' : `${name} `
50
+ let cmd = `${prefix}${cta.command}`
51
+ if (cta.args)
52
+ for (const [key, value] of Object.entries(cta.args))
53
+ cmd += value === true ? ` <${key}>` : ` ${value}`
54
+ if (cta.options)
55
+ for (const [key, value] of Object.entries(cta.options))
56
+ cmd += value === true ? ` --${key} <${key}>` : ` --${key} ${value}`
57
+ return { command: cmd, ...(cta.description ? { description: cta.description } : undefined) }
58
+ }
@@ -0,0 +1,16 @@
1
+ /** Serializes JSON with BigInt values represented as decimal strings. */
2
+ export function stringify(value: unknown, space?: number): string {
3
+ return JSON.stringify(
4
+ value,
5
+ (_key, value) => {
6
+ if (typeof value === 'bigint') return value.toString()
7
+ return value
8
+ },
9
+ space,
10
+ )
11
+ }
12
+
13
+ /** Converts a value to JSON-compatible data. */
14
+ export function normalize(value: unknown): unknown {
15
+ return JSON.parse(stringify(value))
16
+ }
@@ -0,0 +1,23 @@
1
+ import { createRequire } from 'node:module'
2
+ import type * as yaml from 'yaml'
3
+
4
+ /** @internal Cached `yaml` module, shared by `load()` and `loadSync()`. */
5
+ let cached: typeof yaml | undefined
6
+
7
+ /** Loads the `yaml` module on demand, so runs that never touch YAML don't pay its startup cost. */
8
+ export async function load(): Promise<typeof yaml> {
9
+ cached ??= await import('yaml')
10
+ return cached
11
+ }
12
+
13
+ /**
14
+ * Synchronous variant of `load()` for sync call paths (e.g. `Formatter.format`).
15
+ *
16
+ * Falls back to `require` when `load()` hasn't run yet. Async paths should call `load()`
17
+ * first so environments without synchronous module resolution (e.g. compiled binaries,
18
+ * bundled workers) never hit the fallback.
19
+ */
20
+ export function loadSync(): typeof yaml {
21
+ cached ??= createRequire(import.meta.url)('yaml') as typeof yaml
22
+ return cached
23
+ }