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/dist/Cli.d.ts +1 -1
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +95 -16
- package/dist/Cli.js.map +1 -1
- package/dist/Help.js +1 -1
- package/dist/Help.js.map +1 -1
- package/dist/internal/helpers.d.ts +4 -0
- package/dist/internal/helpers.d.ts.map +1 -1
- package/dist/internal/helpers.js +35 -0
- package/dist/internal/helpers.js.map +1 -1
- package/package.json +1 -1
- package/src/Cli.test.ts +163 -25
- package/src/Cli.ts +90 -19
- package/src/Help.ts +1 -1
- package/src/Openapi.test.ts +6 -1
- package/src/e2e.test.ts +26 -23
- package/src/internal/helpers.test.ts +62 -0
- package/src/internal/helpers.ts +36 -0
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: "
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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('
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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: '
|
|
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": "
|
|
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
|
-
|
|
557
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1076
|
-
const
|
|
1077
|
-
|
|
1078
|
-
|
|
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
|
-
|
|
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 '${
|
|
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
|
}
|
package/src/Openapi.test.ts
CHANGED
|
@@ -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: "
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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
|
|
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": "
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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('
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 () => {
|