incur 0.3.6 → 0.3.8

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/src/Cli.test.ts CHANGED
@@ -691,9 +691,9 @@ describe('serve', () => {
691
691
  "code: COMMAND_NOT_FOUND
692
692
  message: 'nonexistent' is not a command for 'test'.
693
693
  cta:
694
- description: "See available commands:"
695
- commands[1]{command}:
696
- test --help
694
+ description: "Suggested command:"
695
+ commands[1]{command,description}:
696
+ test --help,see all available commands
697
697
  "
698
698
  `)
699
699
  })
@@ -708,8 +708,8 @@ describe('serve', () => {
708
708
  expect(output).toMatchInlineSnapshot(`
709
709
  "Error: 'nonexistent' is not a command for 'test'.
710
710
 
711
- See available commands:
712
- test --help
711
+ Suggested command:
712
+ test --help # see all available commands
713
713
  "
714
714
  `)
715
715
  })
@@ -727,14 +727,100 @@ describe('serve', () => {
727
727
  meta:
728
728
  command: nonexistent
729
729
  cta:
730
- description: "See available commands:"
731
- commands[1]{command}:
732
- test --help
730
+ description: "Suggested command:"
731
+ commands[1]{command,description}:
732
+ test --help,see all available commands
733
733
  duration: <stripped>
734
734
  "
735
735
  `)
736
736
  })
737
737
 
738
+ test('suggests similar command for typos', async () => {
739
+ const cli = Cli.create('test')
740
+ cli.command('deploy', { run: () => ({}) })
741
+ cli.command('status', { run: () => ({}) })
742
+
743
+ const { output, exitCode } = await serve(cli, ['deplyo'])
744
+ expect(exitCode).toBe(1)
745
+ expect(output).toMatchInlineSnapshot(`
746
+ "code: COMMAND_NOT_FOUND
747
+ message: 'deplyo' is not a command for 'test'. Did you mean 'deploy'?
748
+ cta:
749
+ description: "Suggested commands:"
750
+ commands[2]:
751
+ - command: test deploy
752
+ - command: test --help
753
+ description: see all available commands
754
+ "
755
+ `)
756
+ })
757
+
758
+ test('suggests similar command for typos in TTY', async () => {
759
+ ;(process.stdout as any).isTTY = true
760
+ const cli = Cli.create('test')
761
+ cli.command('deploy', { run: () => ({}) })
762
+
763
+ const { output, exitCode } = await serve(cli, ['deplyo'])
764
+ ;(process.stdout as any).isTTY = false
765
+ expect(exitCode).toBe(1)
766
+ expect(output).toMatchInlineSnapshot(`
767
+ "Error: 'deplyo' is not a command for 'test'. Did you mean 'deploy'?
768
+
769
+ Suggested commands:
770
+ test deploy
771
+ test --help # see all available commands
772
+ "
773
+ `)
774
+ })
775
+
776
+ test('suggests builtin commands for typos', async () => {
777
+ const cli = Cli.create('test')
778
+ cli.command('ping', { run: () => ({}) })
779
+
780
+ const { output, exitCode } = await serve(cli, ['mpc'])
781
+ expect(exitCode).toBe(1)
782
+ expect(output).toContain("Did you mean 'mcp'?")
783
+ expect(output).toContain('test mcp')
784
+ })
785
+
786
+ test('preserves flags in suggestion CTA', async () => {
787
+ const cli = Cli.create('test')
788
+ cli.command('deploy', { run: () => ({}) })
789
+
790
+ const { output } = await serve(cli, ['deplyo', '--verbose'])
791
+ expect(output).toContain('test deploy --verbose')
792
+ })
793
+
794
+ test('no suggestion when input is too far from any command', async () => {
795
+ const cli = Cli.create('test')
796
+ cli.command('deploy', { run: () => ({}) })
797
+
798
+ const { output } = await serve(cli, ['xyz'])
799
+ expect(output).not.toContain('Did you mean')
800
+ })
801
+
802
+ test('suggests similar subcommand for typos', async () => {
803
+ const cli = Cli.create('test')
804
+ const pr = Cli.create('pr')
805
+ .command('list', { run: () => ({}) })
806
+ .command('create', { run: () => ({}) })
807
+ cli.command(pr)
808
+
809
+ const { output, exitCode } = await serve(cli, ['pr', 'craete'])
810
+ expect(exitCode).toBe(1)
811
+ expect(output).toMatchInlineSnapshot(`
812
+ "code: COMMAND_NOT_FOUND
813
+ message: 'craete' is not a command for 'test pr'. Did you mean 'create'?
814
+ cta:
815
+ description: "Suggested commands:"
816
+ commands[2]:
817
+ - command: test pr create
818
+ - command: test pr --help
819
+ description: see all available commands
820
+ "
821
+ `)
822
+ })
823
+
738
824
  test('wraps handler errors in error output', async () => {
739
825
  const cli = Cli.create('test')
740
826
  cli.command('fail', {
@@ -1289,6 +1375,14 @@ describe('--schema', () => {
1289
1375
  expect(exitCode).toBe(1)
1290
1376
  })
1291
1377
 
1378
+ test('on unknown command suggests similar', async () => {
1379
+ const cli = Cli.create('test')
1380
+ cli.command('greet', { run: () => ({}) })
1381
+ const { output, exitCode } = await serve(cli, ['grete', '--schema'])
1382
+ expect(output).toContain("Did you mean 'greet'?")
1383
+ expect(exitCode).toBe(1)
1384
+ })
1385
+
1292
1386
  test('on group shows available commands', async () => {
1293
1387
  const cli = Cli.create('test')
1294
1388
  const pr = Cli.create('pr', { description: 'PR management' }).command('list', {
@@ -1422,9 +1516,9 @@ describe('subcommands', () => {
1422
1516
  "code: COMMAND_NOT_FOUND
1423
1517
  message: 'unknown' is not a command for 'test pr'.
1424
1518
  cta:
1425
- description: "See available commands:"
1426
- commands[1]{command}:
1427
- test pr --help
1519
+ description: "Suggested command:"
1520
+ commands[1]{command,description}:
1521
+ test pr --help,see all available commands
1428
1522
  "
1429
1523
  `)
1430
1524
  })
@@ -1443,8 +1537,8 @@ describe('subcommands', () => {
1443
1537
  expect(output).toMatchInlineSnapshot(`
1444
1538
  "Error: 'unknown' is not a command for 'test pr'.
1445
1539
 
1446
- See available commands:
1447
- test pr --help
1540
+ Suggested command:
1541
+ test pr --help # see all available commands
1448
1542
  "
1449
1543
  `)
1450
1544
  })
@@ -1746,7 +1840,7 @@ describe('cta', () => {
1746
1840
  const { output } = await serve(cli, ['pr', 'create', 'my-pr', '--verbose', '--format', 'json'])
1747
1841
  const parsed = JSON.parse(output)
1748
1842
  expect(parsed.meta.cta).toEqual({
1749
- description: 'Suggested commands:',
1843
+ description: 'Suggested command:',
1750
1844
  commands: [{ command: 'test pr get 42', description: 'View the PR' }],
1751
1845
  })
1752
1846
  })
@@ -2449,6 +2543,25 @@ describe('built-in commands', () => {
2449
2543
  expect(output).toContain('add')
2450
2544
  })
2451
2545
 
2546
+ test('skills typo suggests add', async () => {
2547
+ const cli = Cli.create('test')
2548
+ cli.command('ping', { run: () => ({}) })
2549
+ const { output, exitCode } = await serve(cli, ['skills', 'addd'])
2550
+ expect(exitCode).toBe(1)
2551
+ expect(output).toContain("Did you mean 'add'?")
2552
+ expect(output).toContain('test skills add')
2553
+ expect(output).toContain('test skills --help')
2554
+ })
2555
+
2556
+ test('mcp typo suggests add', async () => {
2557
+ const cli = Cli.create('test')
2558
+ cli.command('ping', { run: () => ({}) })
2559
+ const { output, exitCode } = await serve(cli, ['mcp', 'addd'])
2560
+ expect(exitCode).toBe(1)
2561
+ expect(output).toContain("Did you mean 'add'?")
2562
+ expect(output).toContain('test mcp add')
2563
+ })
2564
+
2452
2565
  test('skills add --help shows options', async () => {
2453
2566
  const cli = Cli.create('test')
2454
2567
  cli.command('ping', { run: () => ({ pong: true }) })
@@ -2469,15 +2582,32 @@ describe('skills staleness', () => {
2469
2582
 
2470
2583
  afterEach(() => {
2471
2584
  stderrSpy.mockRestore()
2585
+ __mockSkillsHash = undefined
2472
2586
  })
2473
2587
 
2474
- test('warns on stderr when skills are stale', async () => {
2588
+ test('includes skills CTA when stale', async () => {
2475
2589
  __mockSkillsHash = '0000000000000000'
2476
2590
  const cli = Cli.create('test')
2477
2591
  cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2478
2592
 
2479
- await serve(cli, ['ping'])
2480
- expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("Skills are out of date. Run '"))
2593
+ const { output } = await serve(cli, ['ping'])
2594
+ expect(output).toContain('Skills are out of date:')
2595
+ expect(output).toContain('skills add')
2596
+ })
2597
+
2598
+ test('merges skills CTA with command CTA', async () => {
2599
+ __mockSkillsHash = '0000000000000000'
2600
+ ;(process.stdout as any).isTTY = true
2601
+ const cli = Cli.create('test')
2602
+ cli.command('ping', {
2603
+ description: 'Health check',
2604
+ run: (c) => c.ok({ pong: true }, { cta: { commands: ['status'] } }),
2605
+ })
2606
+
2607
+ const { output } = await serve(cli, ['ping'])
2608
+ ;(process.stdout as any).isTTY = false
2609
+ expect(output).toContain('status')
2610
+ expect(output).toContain('skills add')
2481
2611
  })
2482
2612
 
2483
2613
  test('does not warn when hash matches', async () => {
@@ -2486,8 +2616,8 @@ describe('skills staleness', () => {
2486
2616
  const cli = Cli.create('test')
2487
2617
  cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2488
2618
 
2489
- await serve(cli, ['ping'])
2490
- expect(stderrSpy).not.toHaveBeenCalled()
2619
+ const { output } = await serve(cli, ['ping'])
2620
+ expect(output).not.toContain('Skills are out of date')
2491
2621
  })
2492
2622
 
2493
2623
  test('does not warn when no hash stored', async () => {
@@ -2495,8 +2625,8 @@ describe('skills staleness', () => {
2495
2625
  const cli = Cli.create('test')
2496
2626
  cli.command('ping', { run: () => ({ pong: true }) })
2497
2627
 
2498
- await serve(cli, ['ping'])
2499
- expect(stderrSpy).not.toHaveBeenCalled()
2628
+ const { output } = await serve(cli, ['ping'])
2629
+ expect(output).not.toContain('Skills are out of date')
2500
2630
  })
2501
2631
 
2502
2632
  test('does not warn for skills add', async () => {
@@ -2513,8 +2643,8 @@ describe('skills staleness', () => {
2513
2643
  const cli = Cli.create('test')
2514
2644
  cli.command('ping', { run: () => ({ pong: true }) })
2515
2645
 
2516
- await serve(cli, ['--help'])
2517
- expect(stderrSpy).not.toHaveBeenCalled()
2646
+ const { output } = await serve(cli, ['--help'])
2647
+ expect(output).not.toContain('Skills are out of date')
2518
2648
  })
2519
2649
  })
2520
2650
 
@@ -3708,6 +3838,14 @@ describe('fetch', () => {
3708
3838
  `)
3709
3839
  })
3710
3840
 
3841
+ test('GET /helath → 404 with suggestion', async () => {
3842
+ const cli = Cli.create('test')
3843
+ cli.command('health', { run: () => ({}) })
3844
+ const res = await fetchJson(cli, new Request('http://localhost/helath'))
3845
+ expect(res.status).toBe(404)
3846
+ expect(res.body.error.message).toContain("Did you mean 'health'?")
3847
+ })
3848
+
3711
3849
  test('GET / with root command → 200', async () => {
3712
3850
  const cli = Cli.create('test', { run: () => ({ root: true }) })
3713
3851
  expect(await fetchJson(cli, new Request('http://localhost/'))).toMatchInlineSnapshot(`
@@ -4057,7 +4195,7 @@ describe('fetch', () => {
4057
4195
  test('cta block is propagated', async () => {
4058
4196
  const cli = Cli.create('test')
4059
4197
  cli.command('done', {
4060
- run: (c) => c.ok({ id: 1 }, { cta: { commands: ['list'], description: 'Next steps:' } }),
4198
+ run: (c) => c.ok({ id: 1 }, { cta: { commands: ['list'], description: 'Suggested commands:' } }),
4061
4199
  })
4062
4200
  const { body } = await fetchJson(cli, new Request('http://localhost/done'))
4063
4201
  expect(body.ok).toBe(true)
@@ -4068,7 +4206,7 @@ describe('fetch', () => {
4068
4206
  "command": "test list",
4069
4207
  },
4070
4208
  ],
4071
- "description": "Next steps:",
4209
+ "description": "Suggested commands:",
4072
4210
  }
4073
4211
  `)
4074
4212
  })
package/src/Cli.ts CHANGED
@@ -13,7 +13,7 @@ import * as Formatter from './Formatter.js'
13
13
  import * as Help from './Help.js'
14
14
  import { builtinCommands, type CommandMeta, type Shell, shells } from './internal/command.js'
15
15
  import * as Command from './internal/command.js'
16
- import { isRecord } from './internal/helpers.js'
16
+ import { isRecord, suggest } from './internal/helpers.js'
17
17
  import { detectRunner } from './internal/pm.js'
18
18
  import type { OneOf } from './internal/types.js'
19
19
  import * as Mcp from './Mcp.js'
@@ -541,6 +541,7 @@ async function serveImpl(
541
541
  }
542
542
 
543
543
  // Skills staleness check (skip for built-in commands)
544
+ let skillsCta: FormattedCtaBlock | undefined
544
545
  if (!llms && !llmsFull && !schema && !help && !version) {
545
546
  const isSkillsAdd =
546
547
  filtered[0] === 'skills' || (filtered[0] === name && filtered[1] === 'skills')
@@ -553,9 +554,10 @@ async function serveImpl(
553
554
  if (Skill.hash(entries) !== stored) {
554
555
  const runner = detectRunner()
555
556
  const spec = SyncMcp.detectPackageSpecifier(name)
556
- process.stderr.write(
557
- `⚠ Skills are out of date. Run '${runner} ${spec} skills add' to update.\n\n`,
558
- )
557
+ skillsCta = {
558
+ description: 'Skills are out of date:',
559
+ commands: [{ command: `${runner} ${spec} skills add`, description: 'sync outdated skills' }],
560
+ }
559
561
  }
560
562
  }
561
563
  }
@@ -646,7 +648,26 @@ async function serveImpl(
646
648
  const skillsIdx =
647
649
  filtered[0] === 'skills' ? 0 : filtered[0] === name && filtered[1] === 'skills' ? 1 : -1
648
650
  if (skillsIdx !== -1 && filtered[skillsIdx] === 'skills') {
649
- if (filtered[skillsIdx + 1] !== 'add') {
651
+ const skillsSub = filtered[skillsIdx + 1]
652
+ if (skillsSub && skillsSub !== 'add') {
653
+ const suggestion = suggest(skillsSub, ['add'])
654
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : ''
655
+ const message = `'${skillsSub}' is not a command for '${name} skills'.${didYouMean}`
656
+ const ctaCommands: FormattedCta[] = []
657
+ if (suggestion) {
658
+ const corrected = argv.map((t) => (t === skillsSub ? suggestion : t))
659
+ ctaCommands.push({ command: `${name} ${corrected.join(' ')}` })
660
+ }
661
+ ctaCommands.push({ command: `${name} skills --help`, description: 'see all available commands' })
662
+ const cta: FormattedCtaBlock = { description: ctaCommands.length === 1 ? 'Suggested command:' : 'Suggested commands:', commands: ctaCommands }
663
+ if (human) {
664
+ writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message }))
665
+ writeln(formatHumanCta(cta))
666
+ } else writeln(Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, 'toon'))
667
+ exit(1)
668
+ return
669
+ }
670
+ if (!skillsSub) {
650
671
  const b = builtinCommands.find((c) => c.name === 'skills')!
651
672
  writeln(formatBuiltinHelp(name, b))
652
673
  return
@@ -718,7 +739,26 @@ async function serveImpl(
718
739
  // mcp add: register CLI as MCP server via `npx add-mcp`
719
740
  const mcpIdx = filtered[0] === 'mcp' ? 0 : filtered[0] === name && filtered[1] === 'mcp' ? 1 : -1
720
741
  if (mcpIdx !== -1 && filtered[mcpIdx] === 'mcp') {
721
- if (filtered[mcpIdx + 1] !== 'add') {
742
+ const mcpSub = filtered[mcpIdx + 1]
743
+ if (mcpSub && mcpSub !== 'add') {
744
+ const suggestion = suggest(mcpSub, ['add'])
745
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : ''
746
+ const message = `'${mcpSub}' is not a command for '${name} mcp'.${didYouMean}`
747
+ const ctaCommands: FormattedCta[] = []
748
+ if (suggestion) {
749
+ const corrected = argv.map((t) => (t === mcpSub ? suggestion : t))
750
+ ctaCommands.push({ command: `${name} ${corrected.join(' ')}` })
751
+ }
752
+ ctaCommands.push({ command: `${name} mcp --help`, description: 'see all available commands' })
753
+ const cta: FormattedCtaBlock = { description: ctaCommands.length === 1 ? 'Suggested command:' : 'Suggested commands:', commands: ctaCommands }
754
+ if (human) {
755
+ writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message }))
756
+ writeln(formatHumanCta(cta))
757
+ } else writeln(Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, 'toon'))
758
+ exit(1)
759
+ return
760
+ }
761
+ if (!mcpSub) {
722
762
  const b = builtinCommands.find((c) => c.name === 'mcp')!
723
763
  writeln(formatBuiltinHelp(name, b))
724
764
  return
@@ -938,7 +978,9 @@ async function serveImpl(
938
978
  }
939
979
  if ('error' in resolved) {
940
980
  const parent = resolved.path ? `${name} ${resolved.path}` : name
941
- writeln(`Error: '${resolved.error}' is not a command for '${parent}'.`)
981
+ const suggestion = suggest(resolved.error, resolved.commands.keys())
982
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : ''
983
+ writeln(`Error: '${resolved.error}' is not a command for '${parent}'.${didYouMean}`)
942
984
  exit(1)
943
985
  return
944
986
  }
@@ -1022,6 +1064,21 @@ async function serveImpl(
1022
1064
  function write(output: Output) {
1023
1065
  if (filterPaths && output.ok && output.data != null)
1024
1066
  output = { ...output, data: Filter.apply(output.data, filterPaths) }
1067
+ if (skillsCta) {
1068
+ const existing = output.meta.cta
1069
+ output = {
1070
+ ...output,
1071
+ meta: {
1072
+ ...output.meta,
1073
+ cta: existing
1074
+ ? {
1075
+ description: existing.description,
1076
+ commands: [...existing.commands, ...skillsCta.commands],
1077
+ }
1078
+ : skillsCta,
1079
+ },
1080
+ }
1081
+ }
1025
1082
  if (tokenCount) {
1026
1083
  const base = output.ok ? output.data : output.error
1027
1084
  const formatted = base != null ? Formatter.format(base, format) : ''
@@ -1072,14 +1129,24 @@ async function serveImpl(
1072
1129
  if ('error' in effective) {
1073
1130
  const helpCmd = effective.path ? `${name} ${effective.path} --help` : `${name} --help`
1074
1131
  const parent = effective.path ? `${name} ${effective.path}` : name
1075
- const message = `'${effective.error}' is not a command for '${parent}'.`
1076
- const cta: FormattedCtaBlock = {
1077
- description: 'See available commands:',
1078
- commands: [{ command: helpCmd }],
1132
+ const candidates = 'commands' in effective ? [...effective.commands.keys()] : []
1133
+ if (!effective.path) for (const b of builtinCommands) candidates.push(b.name)
1134
+ const suggestion = suggest(effective.error, candidates)
1135
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : ''
1136
+ const message = `'${effective.error}' is not a command for '${parent}'.${didYouMean}`
1137
+ const ctaCommands: FormattedCta[] = []
1138
+ if (suggestion) {
1139
+ const corrected = argv.map((t) => (t === effective.error ? suggestion : t))
1140
+ ctaCommands.push({ command: `${name} ${corrected.join(' ')}` })
1079
1141
  }
1142
+ ctaCommands.push({ command: helpCmd, description: 'see all available commands' })
1143
+ const cta: FormattedCtaBlock = { description: ctaCommands.length === 1 ? 'Suggested command:' : 'Suggested commands:', commands: ctaCommands }
1080
1144
  if (human && !verbose) {
1081
1145
  writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message }))
1082
- writeln(formatHumanCta(cta))
1146
+ const mergedCta = skillsCta
1147
+ ? { ...cta, commands: [...cta.commands, ...skillsCta.commands] }
1148
+ : cta
1149
+ writeln(formatHumanCta(mergedCta))
1083
1150
  exit(1)
1084
1151
  return
1085
1152
  }
@@ -1544,18 +1611,22 @@ async function fetchImpl(
1544
1611
 
1545
1612
  const resolved = resolveCommand(commands, segments)
1546
1613
 
1547
- if ('error' in resolved)
1614
+ if ('error' in resolved) {
1615
+ const parent = resolved.path ? `${name} ${resolved.path}` : name
1616
+ const suggestion = suggest(resolved.error, resolved.commands.keys())
1617
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : ''
1548
1618
  return jsonResponse(
1549
1619
  {
1550
1620
  ok: false,
1551
1621
  error: {
1552
1622
  code: 'COMMAND_NOT_FOUND',
1553
- message: `'${resolved.error}' is not a command for '${resolved.path ? `${name} ${resolved.path}` : name}'.`,
1623
+ message: `'${resolved.error}' is not a command for '${parent}'.${didYouMean}`,
1554
1624
  },
1555
1625
  meta: { command: resolved.error, duration: `${Math.round(performance.now() - start)}ms` },
1556
1626
  },
1557
1627
  404,
1558
1628
  )
1629
+ }
1559
1630
 
1560
1631
  if ('help' in resolved)
1561
1632
  return jsonResponse(
@@ -1754,10 +1825,10 @@ function resolveCommand(
1754
1825
  description?: string | undefined
1755
1826
  commands: Map<string, CommandEntry>
1756
1827
  }
1757
- | { error: string; path: string } {
1828
+ | { error: string; path: string; commands: Map<string, CommandEntry>; rest: string[] } {
1758
1829
  const [first, ...rest] = tokens
1759
1830
 
1760
- if (!first || !commands.has(first)) return { error: first ?? '(none)', path: '' }
1831
+ if (!first || !commands.has(first)) return { error: first ?? '(none)', path: '', commands, rest }
1761
1832
 
1762
1833
  let entry = commands.get(first)!
1763
1834
  const path = [first]
@@ -1791,7 +1862,7 @@ function resolveCommand(
1791
1862
 
1792
1863
  const child = entry.commands.get(next)
1793
1864
  if (!child) {
1794
- return { error: next, path: path.join(' ') }
1865
+ return { error: next, path: path.join(' '), commands: entry.commands, rest: remaining.slice(1) }
1795
1866
  }
1796
1867
 
1797
1868
  path.push(next)
@@ -2237,7 +2308,7 @@ type ErrorResult = {
2237
2308
  type CtaBlock<commands extends CommandsMap = Commands> = {
2238
2309
  /** Commands to suggest. */
2239
2310
  commands: Cta<commands>[]
2240
- /** Human-readable label. Defaults to `"Suggested commands:"`. */
2311
+ /** Human-readable label. Defaults to `"Suggested command:"` or `"Suggested commands:"` based on count. */
2241
2312
  description?: string | undefined
2242
2313
  }
2243
2314
 
@@ -2481,7 +2552,7 @@ async function handleStreaming(
2481
2552
  function formatCtaBlock(name: string, block: CtaBlock | undefined): FormattedCtaBlock | undefined {
2482
2553
  if (!block || block.commands.length === 0) return undefined
2483
2554
  return {
2484
- description: block.description ?? 'Suggested commands:',
2555
+ description: block.description ?? (block.commands.length === 1 ? 'Suggested command:' : 'Suggested commands:'),
2485
2556
  commands: block.commands.map((c) => formatCta(name, c)),
2486
2557
  }
2487
2558
  }
package/src/Help.ts CHANGED
@@ -389,6 +389,6 @@ function globalOptionsLines(root = false, configFlag?: string): string[] {
389
389
 
390
390
  /** Redacts a value, showing only the last 4 characters. */
391
391
  function redact(value: string): string {
392
- if (value.length <= 4) return '****'
392
+ if (value.length <= 4) return `****${value.slice(-1)}`
393
393
  return `****${value.slice(-4)}`
394
394
  }
@@ -1,4 +1,9 @@
1
- import { describe, expect, test } from 'vitest'
1
+ import { describe, expect, test, vi } from 'vitest'
2
+
3
+ vi.mock('./SyncSkills.js', async (importOriginal) => {
4
+ const actual = await importOriginal<typeof import('./SyncSkills.js')>()
5
+ return { ...actual, readHash: () => undefined }
6
+ })
2
7
 
3
8
  import { app as prefixedApp } from '../test/fixtures/hono-api-prefixed.js'
4
9
  import { app } from '../test/fixtures/hono-api.js'
package/src/e2e.test.ts CHANGED
@@ -70,9 +70,9 @@ describe('routing', () => {
70
70
  "code: COMMAND_NOT_FOUND
71
71
  message: 'nonexistent' is not a command for 'app'.
72
72
  cta:
73
- description: "See available commands:"
74
- commands[1]{command}:
75
- app --help
73
+ description: "Suggested command:"
74
+ commands[1]{command,description}:
75
+ app --help,see all available commands
76
76
  "
77
77
  `)
78
78
  })
@@ -85,8 +85,8 @@ describe('routing', () => {
85
85
  expect(output).toMatchInlineSnapshot(`
86
86
  "Error: 'nonexistent' is not a command for 'app'.
87
87
 
88
- See available commands:
89
- app --help
88
+ Suggested command:
89
+ app --help # see all available commands
90
90
  "
91
91
  `)
92
92
  })
@@ -98,9 +98,9 @@ describe('routing', () => {
98
98
  "code: COMMAND_NOT_FOUND
99
99
  message: 'whoami' is not a command for 'app auth'.
100
100
  cta:
101
- description: "See available commands:"
102
- commands[1]{command}:
103
- app auth --help
101
+ description: "Suggested command:"
102
+ commands[1]{command,description}:
103
+ app auth --help,see all available commands
104
104
  "
105
105
  `)
106
106
  })
@@ -112,9 +112,9 @@ describe('routing', () => {
112
112
  "code: COMMAND_NOT_FOUND
113
113
  message: 'nope' is not a command for 'app project deploy'.
114
114
  cta:
115
- description: "See available commands:"
116
- commands[1]{command}:
117
- app project deploy --help
115
+ description: "Suggested command:"
116
+ commands[1]{command,description}:
117
+ app project deploy --help,see all available commands
118
118
  "
119
119
  `)
120
120
  })
@@ -595,7 +595,7 @@ describe('error handling', () => {
595
595
  "command": "app auth login",
596
596
  },
597
597
  ],
598
- "description": "Suggested commands:",
598
+ "description": "Suggested command:",
599
599
  },
600
600
  "duration": "<stripped>",
601
601
  },
@@ -650,9 +650,10 @@ describe('error handling', () => {
650
650
  "commands": [
651
651
  {
652
652
  "command": "app --help",
653
+ "description": "see all available commands",
653
654
  },
654
655
  ],
655
- "description": "See available commands:",
656
+ "description": "Suggested command:",
656
657
  },
657
658
  "duration": "<stripped>",
658
659
  },
@@ -722,7 +723,7 @@ describe('cta', () => {
722
723
  "command": "app auth login",
723
724
  },
724
725
  ],
725
- "description": "Suggested commands:",
726
+ "description": "Suggested command:",
726
727
  }
727
728
  `)
728
729
  })
@@ -868,7 +869,7 @@ describe('streaming', () => {
868
869
  "command": "app ping",
869
870
  },
870
871
  ],
871
- "description": "Suggested commands:",
872
+ "description": "Suggested command:",
872
873
  }
873
874
  `)
874
875
  })
@@ -877,7 +878,7 @@ describe('streaming', () => {
877
878
  const { output } = await serve(createApp(), ['stream-ok'])
878
879
  expect(output).toContain('n: 1')
879
880
  expect(output).toContain('n: 2')
880
- expect(output).toContain('Suggested commands:')
881
+ expect(output).toContain('Suggested command:')
881
882
  expect(output).toContain('app ping')
882
883
  })
883
884
 
@@ -1786,7 +1787,7 @@ describe('edge cases', () => {
1786
1787
  "description": "View "Alpha"",
1787
1788
  },
1788
1789
  ],
1789
- "description": "Suggested commands:",
1790
+ "description": "Suggested command:",
1790
1791
  },
1791
1792
  "items": [
1792
1793
  {
@@ -1974,13 +1975,15 @@ describe('skills staleness', () => {
1974
1975
 
1975
1976
  afterEach(() => {
1976
1977
  stderrSpy.mockRestore()
1978
+ __mockSkillsHash = undefined
1977
1979
  })
1978
1980
 
1979
- test('warns when running a command with stale skills', async () => {
1981
+ test('includes skills CTA when stale', async () => {
1980
1982
  __mockSkillsHash = '0000000000000000'
1981
1983
  const { output } = await serve(createApp(), ['ping'])
1982
1984
  expect(output).toContain('pong: true')
1983
- expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Skills are out of date.'))
1985
+ expect(output).toContain('Skills are out of date:')
1986
+ expect(output).toContain('skills add')
1984
1987
  })
1985
1988
 
1986
1989
  test('no warning when skills hash matches', async () => {
@@ -1991,20 +1994,20 @@ describe('skills staleness', () => {
1991
1994
 
1992
1995
  const { output } = await serve(cli, ['ping'])
1993
1996
  expect(output).toContain('pong: true')
1994
- expect(stderrSpy).not.toHaveBeenCalled()
1997
+ expect(output).not.toContain('Skills are out of date')
1995
1998
  })
1996
1999
 
1997
2000
  test('no warning on first use (no hash stored)', async () => {
1998
2001
  __mockSkillsHash = undefined
1999
2002
  const { output } = await serve(createApp(), ['ping'])
2000
2003
  expect(output).toContain('pong: true')
2001
- expect(stderrSpy).not.toHaveBeenCalled()
2004
+ expect(output).not.toContain('Skills are out of date')
2002
2005
  })
2003
2006
 
2004
2007
  test('no warning for --llms', async () => {
2005
2008
  __mockSkillsHash = '0000000000000000'
2006
- await serve(createApp(), ['--llms'])
2007
- expect(stderrSpy).not.toHaveBeenCalled()
2009
+ const { output } = await serve(createApp(), ['--llms'])
2010
+ expect(output).not.toContain('Skills are out of date')
2008
2011
  })
2009
2012
 
2010
2013
  test('no warning for --mcp', async () => {