incur 0.3.5 → 0.3.7
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/README.md +61 -0
- package/dist/Cli.d.ts +15 -0
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +300 -25
- package/dist/Cli.js.map +1 -1
- package/dist/Filter.js +0 -18
- package/dist/Filter.js.map +1 -1
- package/dist/Help.d.ts +4 -0
- package/dist/Help.d.ts.map +1 -1
- package/dist/Help.js +17 -14
- package/dist/Help.js.map +1 -1
- package/dist/Parser.d.ts +2 -0
- package/dist/Parser.d.ts.map +1 -1
- package/dist/Parser.js +69 -37
- package/dist/Parser.js.map +1 -1
- package/dist/bin.d.ts +1 -0
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +17 -2
- package/dist/bin.js.map +1 -1
- package/dist/internal/command.d.ts +2 -0
- package/dist/internal/command.d.ts.map +1 -1
- package/dist/internal/command.js +1 -0
- package/dist/internal/command.js.map +1 -1
- package/dist/internal/configSchema.d.ts +8 -0
- package/dist/internal/configSchema.d.ts.map +1 -0
- package/dist/internal/configSchema.js +57 -0
- package/dist/internal/configSchema.js.map +1 -0
- package/dist/internal/helpers.d.ts +9 -0
- package/dist/internal/helpers.d.ts.map +1 -0
- package/dist/internal/helpers.js +39 -0
- package/dist/internal/helpers.js.map +1 -0
- package/examples/npm/.npmrc.json +21 -0
- package/examples/npm/config.schema.json +137 -0
- package/package.json +1 -1
- package/src/Cli.test-d.ts +39 -0
- package/src/Cli.test.ts +714 -25
- package/src/Cli.ts +353 -27
- package/src/Filter.ts +0 -17
- package/src/Help.test.ts +66 -0
- package/src/Help.ts +20 -13
- package/src/Openapi.test.ts +6 -1
- package/src/Parser.test-d.ts +22 -0
- package/src/Parser.test.ts +89 -0
- package/src/Parser.ts +86 -35
- package/src/bin.ts +21 -2
- package/src/e2e.test.ts +22 -19
- package/src/internal/command.ts +3 -0
- package/src/internal/configSchema.test.ts +193 -0
- package/src/internal/configSchema.ts +66 -0
- package/src/internal/helpers.test.ts +54 -0
- package/src/internal/helpers.ts +41 -0
package/src/Cli.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises'
|
|
2
|
+
import * as os from 'node:os'
|
|
3
|
+
import * as path from 'node:path'
|
|
1
4
|
import { estimateTokenCount, sliceByTokens } from 'tokenx'
|
|
2
5
|
import type { z } from 'zod'
|
|
3
6
|
|
|
4
7
|
import * as Completions from './Completions.js'
|
|
5
8
|
import type { FieldError } from './Errors.js'
|
|
6
|
-
import { IncurError, ValidationError } from './Errors.js'
|
|
9
|
+
import { IncurError, ParseError, ValidationError } from './Errors.js'
|
|
7
10
|
import * as Fetch from './Fetch.js'
|
|
8
11
|
import * as Filter from './Filter.js'
|
|
9
12
|
import * as Formatter from './Formatter.js'
|
|
10
13
|
import * as Help from './Help.js'
|
|
11
14
|
import { builtinCommands, type CommandMeta, type Shell, shells } from './internal/command.js'
|
|
12
15
|
import * as Command from './internal/command.js'
|
|
16
|
+
import { isRecord, suggest } from './internal/helpers.js'
|
|
13
17
|
import { detectRunner } from './internal/pm.js'
|
|
14
18
|
import type { OneOf } from './internal/types.js'
|
|
15
19
|
import * as Mcp from './Mcp.js'
|
|
@@ -274,6 +278,7 @@ export function create(
|
|
|
274
278
|
return serveImpl(name, commands, argv, {
|
|
275
279
|
...serveOptions,
|
|
276
280
|
aliases: def.aliases,
|
|
281
|
+
config: def.config,
|
|
277
282
|
description: def.description,
|
|
278
283
|
envSchema: def.env,
|
|
279
284
|
format: def.format,
|
|
@@ -295,6 +300,8 @@ export function create(
|
|
|
295
300
|
}
|
|
296
301
|
|
|
297
302
|
if (rootDef) toRootDefinition.set(cli as unknown as Root, rootDef)
|
|
303
|
+
if (def.options) toRootOptions.set(cli, def.options)
|
|
304
|
+
if (def.config !== undefined) toConfigEnabled.set(cli, true)
|
|
298
305
|
if (def.outputPolicy) toOutputPolicy.set(cli, def.outputPolicy)
|
|
299
306
|
toMiddlewares.set(cli, middlewares)
|
|
300
307
|
toCommands.set(cli, commands)
|
|
@@ -318,6 +325,24 @@ export declare namespace create {
|
|
|
318
325
|
aliases?: string[] | undefined
|
|
319
326
|
/** Zod schema for positional arguments. */
|
|
320
327
|
args?: args | undefined
|
|
328
|
+
/** Enable config-file defaults for command options. */
|
|
329
|
+
config?:
|
|
330
|
+
| {
|
|
331
|
+
/** Global flag name for specifying a config file path (e.g. `'config'` → `--config <path>`). Omit to auto-load only, with no CLI flag. */
|
|
332
|
+
flag?: string | undefined
|
|
333
|
+
/** Ordered list of file paths to search. First existing file wins. Supports `~` for home dir. Defaults to `['<cli>.json']` relative to cwd. */
|
|
334
|
+
files?: string[] | undefined
|
|
335
|
+
/** Custom config loader. Receives the resolved file path (or `undefined` if no file was found). Returns the parsed config tree, or `undefined` for no defaults. When omitted, the framework reads and parses JSON. */
|
|
336
|
+
loader?:
|
|
337
|
+
| ((
|
|
338
|
+
path: string | undefined,
|
|
339
|
+
) =>
|
|
340
|
+
| Record<string, unknown>
|
|
341
|
+
| undefined
|
|
342
|
+
| Promise<Record<string, unknown> | undefined>)
|
|
343
|
+
| undefined
|
|
344
|
+
}
|
|
345
|
+
| undefined
|
|
321
346
|
/** A short description of what the CLI does. */
|
|
322
347
|
description?: string | undefined
|
|
323
348
|
/** Zod schema for environment variables. Keys are the variable names (e.g. `NPM_TOKEN`). */
|
|
@@ -427,6 +452,24 @@ async function serveImpl(
|
|
|
427
452
|
) {
|
|
428
453
|
const stdout = options.stdout ?? ((s: string) => process.stdout.write(s))
|
|
429
454
|
const exit = options.exit ?? ((code: number) => process.exit(code))
|
|
455
|
+
const human = process.stdout.isTTY === true
|
|
456
|
+
const configEnabled = options.config !== undefined
|
|
457
|
+
const configFlag = options.config?.flag
|
|
458
|
+
|
|
459
|
+
function writeln(s: string) {
|
|
460
|
+
stdout(s.endsWith('\n') ? s : `${s}\n`)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
let builtinFlags: ReturnType<typeof extractBuiltinFlags>
|
|
464
|
+
try {
|
|
465
|
+
builtinFlags = extractBuiltinFlags(argv, { configFlag })
|
|
466
|
+
} catch (error) {
|
|
467
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
468
|
+
if (human) writeln(formatHumanError({ code: 'UNKNOWN', message }))
|
|
469
|
+
else writeln(Formatter.format({ code: 'UNKNOWN', message }, 'toon'))
|
|
470
|
+
exit(1)
|
|
471
|
+
return
|
|
472
|
+
}
|
|
430
473
|
|
|
431
474
|
const {
|
|
432
475
|
verbose,
|
|
@@ -442,8 +485,10 @@ async function serveImpl(
|
|
|
442
485
|
help,
|
|
443
486
|
version,
|
|
444
487
|
schema,
|
|
488
|
+
configPath,
|
|
489
|
+
configDisabled,
|
|
445
490
|
rest: filtered,
|
|
446
|
-
} =
|
|
491
|
+
} = builtinFlags
|
|
447
492
|
|
|
448
493
|
// --mcp: start as MCP stdio server
|
|
449
494
|
if (mcpFlag) {
|
|
@@ -495,14 +540,8 @@ async function serveImpl(
|
|
|
495
540
|
return
|
|
496
541
|
}
|
|
497
542
|
|
|
498
|
-
// Human mode: stdout is a TTY.
|
|
499
|
-
const human = process.stdout.isTTY === true
|
|
500
|
-
|
|
501
|
-
function writeln(s: string) {
|
|
502
|
-
stdout(s.endsWith('\n') ? s : `${s}\n`)
|
|
503
|
-
}
|
|
504
|
-
|
|
505
543
|
// Skills staleness check (skip for built-in commands)
|
|
544
|
+
let skillsCta: FormattedCtaBlock | undefined
|
|
506
545
|
if (!llms && !llmsFull && !schema && !help && !version) {
|
|
507
546
|
const isSkillsAdd =
|
|
508
547
|
filtered[0] === 'skills' || (filtered[0] === name && filtered[1] === 'skills')
|
|
@@ -515,9 +554,10 @@ async function serveImpl(
|
|
|
515
554
|
if (Skill.hash(entries) !== stored) {
|
|
516
555
|
const runner = detectRunner()
|
|
517
556
|
const spec = SyncMcp.detectPackageSpecifier(name)
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
557
|
+
skillsCta = {
|
|
558
|
+
description: 'Skills are out of date:',
|
|
559
|
+
commands: [{ command: `${runner} ${spec} skills add`, description: 'sync outdated skills' }],
|
|
560
|
+
}
|
|
521
561
|
}
|
|
522
562
|
}
|
|
523
563
|
}
|
|
@@ -608,7 +648,26 @@ async function serveImpl(
|
|
|
608
648
|
const skillsIdx =
|
|
609
649
|
filtered[0] === 'skills' ? 0 : filtered[0] === name && filtered[1] === 'skills' ? 1 : -1
|
|
610
650
|
if (skillsIdx !== -1 && filtered[skillsIdx] === 'skills') {
|
|
611
|
-
|
|
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: 'Next steps:', 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) {
|
|
612
671
|
const b = builtinCommands.find((c) => c.name === 'skills')!
|
|
613
672
|
writeln(formatBuiltinHelp(name, b))
|
|
614
673
|
return
|
|
@@ -680,7 +739,26 @@ async function serveImpl(
|
|
|
680
739
|
// mcp add: register CLI as MCP server via `npx add-mcp`
|
|
681
740
|
const mcpIdx = filtered[0] === 'mcp' ? 0 : filtered[0] === name && filtered[1] === 'mcp' ? 1 : -1
|
|
682
741
|
if (mcpIdx !== -1 && filtered[mcpIdx] === 'mcp') {
|
|
683
|
-
|
|
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: 'Next steps:', 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) {
|
|
684
762
|
const b = builtinCommands.find((c) => c.name === 'mcp')!
|
|
685
763
|
writeln(formatBuiltinHelp(name, b))
|
|
686
764
|
return
|
|
@@ -759,6 +837,7 @@ async function serveImpl(
|
|
|
759
837
|
Help.formatCommand(name, {
|
|
760
838
|
alias: cmd.alias as Record<string, string> | undefined,
|
|
761
839
|
aliases: options.aliases,
|
|
840
|
+
configFlag,
|
|
762
841
|
description: cmd.description ?? options.description,
|
|
763
842
|
version: options.version,
|
|
764
843
|
args: cmd.args,
|
|
@@ -780,6 +859,7 @@ async function serveImpl(
|
|
|
780
859
|
writeln(
|
|
781
860
|
Help.formatRoot(name, {
|
|
782
861
|
aliases: options.aliases,
|
|
862
|
+
configFlag,
|
|
783
863
|
description: options.description,
|
|
784
864
|
version: options.version,
|
|
785
865
|
commands: collectHelpCommands(commands),
|
|
@@ -828,6 +908,7 @@ async function serveImpl(
|
|
|
828
908
|
Help.formatCommand(name, {
|
|
829
909
|
alias: cmd.alias as Record<string, string> | undefined,
|
|
830
910
|
aliases: options.aliases,
|
|
911
|
+
configFlag,
|
|
831
912
|
description: cmd.description ?? options.description,
|
|
832
913
|
version: options.version,
|
|
833
914
|
args: cmd.args,
|
|
@@ -845,6 +926,7 @@ async function serveImpl(
|
|
|
845
926
|
writeln(
|
|
846
927
|
Help.formatRoot(helpName, {
|
|
847
928
|
aliases: isRoot ? options.aliases : undefined,
|
|
929
|
+
configFlag,
|
|
848
930
|
description: helpDesc,
|
|
849
931
|
version: isRoot ? options.version : undefined,
|
|
850
932
|
commands: collectHelpCommands(helpCmds),
|
|
@@ -864,6 +946,7 @@ async function serveImpl(
|
|
|
864
946
|
Help.formatCommand(commandName, {
|
|
865
947
|
alias: cmd.alias as Record<string, string> | undefined,
|
|
866
948
|
aliases: isRootCmd ? options.aliases : undefined,
|
|
949
|
+
configFlag,
|
|
867
950
|
description: cmd.description,
|
|
868
951
|
version: isRootCmd ? options.version : undefined,
|
|
869
952
|
args: cmd.args,
|
|
@@ -886,6 +969,7 @@ async function serveImpl(
|
|
|
886
969
|
if ('help' in resolved) {
|
|
887
970
|
writeln(
|
|
888
971
|
Help.formatRoot(`${name} ${resolved.path}`, {
|
|
972
|
+
configFlag,
|
|
889
973
|
description: resolved.description,
|
|
890
974
|
commands: collectHelpCommands(resolved.commands),
|
|
891
975
|
}),
|
|
@@ -894,7 +978,9 @@ async function serveImpl(
|
|
|
894
978
|
}
|
|
895
979
|
if ('error' in resolved) {
|
|
896
980
|
const parent = resolved.path ? `${name} ${resolved.path}` : name
|
|
897
|
-
|
|
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}`)
|
|
898
984
|
exit(1)
|
|
899
985
|
return
|
|
900
986
|
}
|
|
@@ -917,6 +1003,7 @@ async function serveImpl(
|
|
|
917
1003
|
if ('help' in resolved) {
|
|
918
1004
|
writeln(
|
|
919
1005
|
Help.formatRoot(`${name} ${resolved.path}`, {
|
|
1006
|
+
configFlag,
|
|
920
1007
|
description: resolved.description,
|
|
921
1008
|
commands: collectHelpCommands(resolved.commands),
|
|
922
1009
|
}),
|
|
@@ -977,6 +1064,21 @@ async function serveImpl(
|
|
|
977
1064
|
function write(output: Output) {
|
|
978
1065
|
if (filterPaths && output.ok && output.data != null)
|
|
979
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
|
+
}
|
|
980
1082
|
if (tokenCount) {
|
|
981
1083
|
const base = output.ok ? output.data : output.error
|
|
982
1084
|
const formatted = base != null ? Formatter.format(base, format) : ''
|
|
@@ -1027,14 +1129,24 @@ async function serveImpl(
|
|
|
1027
1129
|
if ('error' in effective) {
|
|
1028
1130
|
const helpCmd = effective.path ? `${name} ${effective.path} --help` : `${name} --help`
|
|
1029
1131
|
const parent = effective.path ? `${name} ${effective.path}` : name
|
|
1030
|
-
const
|
|
1031
|
-
const
|
|
1032
|
-
|
|
1033
|
-
|
|
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(' ')}` })
|
|
1034
1141
|
}
|
|
1142
|
+
ctaCommands.push({ command: helpCmd, description: 'see all available commands' })
|
|
1143
|
+
const cta: FormattedCtaBlock = { description: 'Next steps:', commands: ctaCommands }
|
|
1035
1144
|
if (human && !verbose) {
|
|
1036
1145
|
writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message }))
|
|
1037
|
-
|
|
1146
|
+
const mergedCta = skillsCta
|
|
1147
|
+
? { ...cta, commands: [...cta.commands, ...skillsCta.commands] }
|
|
1148
|
+
: cta
|
|
1149
|
+
writeln(formatHumanCta(mergedCta))
|
|
1038
1150
|
exit(1)
|
|
1039
1151
|
return
|
|
1040
1152
|
}
|
|
@@ -1205,9 +1317,33 @@ async function serveImpl(
|
|
|
1205
1317
|
command.alias as Record<string, string> | undefined,
|
|
1206
1318
|
)
|
|
1207
1319
|
|
|
1320
|
+
let defaults: Record<string, unknown> | undefined
|
|
1321
|
+
if (configEnabled) {
|
|
1322
|
+
try {
|
|
1323
|
+
defaults = await loadCommandOptionDefaults(name, path, {
|
|
1324
|
+
configDisabled,
|
|
1325
|
+
configPath,
|
|
1326
|
+
files: options.config?.files,
|
|
1327
|
+
loader: options.config?.loader,
|
|
1328
|
+
})
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
write({
|
|
1331
|
+
ok: false,
|
|
1332
|
+
error: {
|
|
1333
|
+
code: error instanceof IncurError ? error.code : 'UNKNOWN',
|
|
1334
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1335
|
+
},
|
|
1336
|
+
meta: { command: path, duration: `${Math.round(performance.now() - start)}ms` },
|
|
1337
|
+
})
|
|
1338
|
+
exit(error instanceof IncurError ? (error.exitCode ?? 1) : 1)
|
|
1339
|
+
return
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1208
1343
|
const result = await Command.execute(command, {
|
|
1209
1344
|
agent: !human,
|
|
1210
1345
|
argv: rest,
|
|
1346
|
+
defaults,
|
|
1211
1347
|
env: options.envSchema,
|
|
1212
1348
|
envSource: options.env,
|
|
1213
1349
|
format,
|
|
@@ -1266,6 +1402,7 @@ async function serveImpl(
|
|
|
1266
1402
|
fieldErrors: result.error.fieldErrors,
|
|
1267
1403
|
}),
|
|
1268
1404
|
options.env,
|
|
1405
|
+
configFlag,
|
|
1269
1406
|
),
|
|
1270
1407
|
)
|
|
1271
1408
|
exit(1)
|
|
@@ -1474,18 +1611,22 @@ async function fetchImpl(
|
|
|
1474
1611
|
|
|
1475
1612
|
const resolved = resolveCommand(commands, segments)
|
|
1476
1613
|
|
|
1477
|
-
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}'?` : ''
|
|
1478
1618
|
return jsonResponse(
|
|
1479
1619
|
{
|
|
1480
1620
|
ok: false,
|
|
1481
1621
|
error: {
|
|
1482
1622
|
code: 'COMMAND_NOT_FOUND',
|
|
1483
|
-
message: `'${resolved.error}' is not a command for '${
|
|
1623
|
+
message: `'${resolved.error}' is not a command for '${parent}'.${didYouMean}`,
|
|
1484
1624
|
},
|
|
1485
1625
|
meta: { command: resolved.error, duration: `${Math.round(performance.now() - start)}ms` },
|
|
1486
1626
|
},
|
|
1487
1627
|
404,
|
|
1488
1628
|
)
|
|
1629
|
+
}
|
|
1489
1630
|
|
|
1490
1631
|
if ('help' in resolved)
|
|
1491
1632
|
return jsonResponse(
|
|
@@ -1636,6 +1777,7 @@ function formatHumanValidationError(
|
|
|
1636
1777
|
command: CommandDefinition<any, any, any>,
|
|
1637
1778
|
error: ValidationError,
|
|
1638
1779
|
envSource?: Record<string, string | undefined>,
|
|
1780
|
+
configFlag?: string,
|
|
1639
1781
|
): string {
|
|
1640
1782
|
const lines: string[] = []
|
|
1641
1783
|
for (const fe of error.fieldErrors) lines.push(`Error: missing required argument <${fe.path}>`)
|
|
@@ -1644,6 +1786,7 @@ function formatHumanValidationError(
|
|
|
1644
1786
|
lines.push(
|
|
1645
1787
|
Help.formatCommand(path === cli ? cli : `${cli} ${path}`, {
|
|
1646
1788
|
alias: command.alias as Record<string, string> | undefined,
|
|
1789
|
+
configFlag,
|
|
1647
1790
|
description: command.description,
|
|
1648
1791
|
args: command.args,
|
|
1649
1792
|
env: command.env,
|
|
@@ -1682,10 +1825,10 @@ function resolveCommand(
|
|
|
1682
1825
|
description?: string | undefined
|
|
1683
1826
|
commands: Map<string, CommandEntry>
|
|
1684
1827
|
}
|
|
1685
|
-
| { error: string; path: string } {
|
|
1828
|
+
| { error: string; path: string; commands: Map<string, CommandEntry>; rest: string[] } {
|
|
1686
1829
|
const [first, ...rest] = tokens
|
|
1687
1830
|
|
|
1688
|
-
if (!first || !commands.has(first)) return { error: first ?? '(none)', path: '' }
|
|
1831
|
+
if (!first || !commands.has(first)) return { error: first ?? '(none)', path: '', commands, rest }
|
|
1689
1832
|
|
|
1690
1833
|
let entry = commands.get(first)!
|
|
1691
1834
|
const path = [first]
|
|
@@ -1719,7 +1862,7 @@ function resolveCommand(
|
|
|
1719
1862
|
|
|
1720
1863
|
const child = entry.commands.get(next)
|
|
1721
1864
|
if (!child) {
|
|
1722
|
-
return { error: next, path: path.join(' ') }
|
|
1865
|
+
return { error: next, path: path.join(' '), commands: entry.commands, rest: remaining.slice(1) }
|
|
1723
1866
|
}
|
|
1724
1867
|
|
|
1725
1868
|
path.push(next)
|
|
@@ -1753,6 +1896,20 @@ declare namespace serveImpl {
|
|
|
1753
1896
|
type Options = serve.Options & {
|
|
1754
1897
|
/** Alternative binary names for this CLI. */
|
|
1755
1898
|
aliases?: string[] | undefined
|
|
1899
|
+
config?:
|
|
1900
|
+
| {
|
|
1901
|
+
flag?: string | undefined
|
|
1902
|
+
files?: string[] | undefined
|
|
1903
|
+
loader?:
|
|
1904
|
+
| ((
|
|
1905
|
+
path: string | undefined,
|
|
1906
|
+
) =>
|
|
1907
|
+
| Record<string, unknown>
|
|
1908
|
+
| undefined
|
|
1909
|
+
| Promise<Record<string, unknown> | undefined>)
|
|
1910
|
+
| undefined
|
|
1911
|
+
}
|
|
1912
|
+
| undefined
|
|
1756
1913
|
description?: string | undefined
|
|
1757
1914
|
/** CLI-level env schema. Parsed before middleware runs. */
|
|
1758
1915
|
envSchema?: z.ZodObject<any> | undefined
|
|
@@ -1787,7 +1944,7 @@ declare namespace serveImpl {
|
|
|
1787
1944
|
}
|
|
1788
1945
|
|
|
1789
1946
|
/** @internal Extracts built-in flags (--verbose, --format, --json, --llms, --help, --version) from argv. */
|
|
1790
|
-
function extractBuiltinFlags(argv: string[]) {
|
|
1947
|
+
function extractBuiltinFlags(argv: string[], options: extractBuiltinFlags.Options = {}) {
|
|
1791
1948
|
let verbose = false
|
|
1792
1949
|
let llms = false
|
|
1793
1950
|
let llmsFull = false
|
|
@@ -1797,12 +1954,18 @@ function extractBuiltinFlags(argv: string[]) {
|
|
|
1797
1954
|
let schema = false
|
|
1798
1955
|
let format: Formatter.Format = 'toon'
|
|
1799
1956
|
let formatExplicit = false
|
|
1957
|
+
let configPath: string | undefined
|
|
1958
|
+
let configDisabled = false
|
|
1800
1959
|
let filterOutput: string | undefined
|
|
1801
1960
|
let tokenLimit: number | undefined
|
|
1802
1961
|
let tokenOffset: number | undefined
|
|
1803
1962
|
let tokenCount = false
|
|
1804
1963
|
const rest: string[] = []
|
|
1805
1964
|
|
|
1965
|
+
const cfgFlag = options.configFlag ? `--${options.configFlag}` : undefined
|
|
1966
|
+
const cfgFlagEq = options.configFlag ? `--${options.configFlag}=` : undefined
|
|
1967
|
+
const noCfgFlag = options.configFlag ? `--no-${options.configFlag}` : undefined
|
|
1968
|
+
|
|
1806
1969
|
for (let i = 0; i < argv.length; i++) {
|
|
1807
1970
|
const token = argv[i]!
|
|
1808
1971
|
if (token === '--verbose') verbose = true
|
|
@@ -1819,6 +1982,22 @@ function extractBuiltinFlags(argv: string[]) {
|
|
|
1819
1982
|
format = argv[i + 1] as Formatter.Format
|
|
1820
1983
|
formatExplicit = true
|
|
1821
1984
|
i++
|
|
1985
|
+
} else if (cfgFlag && token === cfgFlag) {
|
|
1986
|
+
const value = argv[i + 1]
|
|
1987
|
+
if (value === undefined)
|
|
1988
|
+
throw new ParseError({ message: `Missing value for flag: ${cfgFlag}` })
|
|
1989
|
+
configPath = value
|
|
1990
|
+
configDisabled = false
|
|
1991
|
+
i++
|
|
1992
|
+
} else if (cfgFlagEq && token.startsWith(cfgFlagEq)) {
|
|
1993
|
+
const value = token.slice(cfgFlagEq.length)
|
|
1994
|
+
if (value.length === 0)
|
|
1995
|
+
throw new ParseError({ message: `Missing value for flag: ${cfgFlag}` })
|
|
1996
|
+
configPath = value
|
|
1997
|
+
configDisabled = false
|
|
1998
|
+
} else if (noCfgFlag && token === noCfgFlag) {
|
|
1999
|
+
configPath = undefined
|
|
2000
|
+
configDisabled = true
|
|
1822
2001
|
} else if (token === '--filter-output' && argv[i + 1]) {
|
|
1823
2002
|
filterOutput = argv[i + 1]!
|
|
1824
2003
|
i++
|
|
@@ -1836,6 +2015,8 @@ function extractBuiltinFlags(argv: string[]) {
|
|
|
1836
2015
|
verbose,
|
|
1837
2016
|
format,
|
|
1838
2017
|
formatExplicit,
|
|
2018
|
+
configPath,
|
|
2019
|
+
configDisabled,
|
|
1839
2020
|
filterOutput,
|
|
1840
2021
|
tokenLimit,
|
|
1841
2022
|
tokenOffset,
|
|
@@ -1850,6 +2031,145 @@ function extractBuiltinFlags(argv: string[]) {
|
|
|
1850
2031
|
}
|
|
1851
2032
|
}
|
|
1852
2033
|
|
|
2034
|
+
declare namespace extractBuiltinFlags {
|
|
2035
|
+
type Options = {
|
|
2036
|
+
configFlag?: string | undefined
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
/** @internal Loads config-backed option defaults for the active command. */
|
|
2041
|
+
async function loadCommandOptionDefaults(
|
|
2042
|
+
cli: string,
|
|
2043
|
+
path: string,
|
|
2044
|
+
options: loadCommandOptionDefaults.Options = {},
|
|
2045
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
2046
|
+
if (options.configDisabled) return undefined
|
|
2047
|
+
|
|
2048
|
+
const { loader } = options
|
|
2049
|
+
|
|
2050
|
+
// Resolve the target file path
|
|
2051
|
+
let targetPath: string | undefined
|
|
2052
|
+
if (options.configPath) {
|
|
2053
|
+
targetPath = resolveConfigPath(options.configPath)
|
|
2054
|
+
} else {
|
|
2055
|
+
const searchPaths = options.files ?? [`${cli}.json`]
|
|
2056
|
+
targetPath = await findFirstExisting(searchPaths)
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// Load and parse the config
|
|
2060
|
+
let parsed: Record<string, unknown>
|
|
2061
|
+
if (loader) {
|
|
2062
|
+
const result = await loader(targetPath)
|
|
2063
|
+
if (result === undefined) return undefined
|
|
2064
|
+
if (!isRecord(result))
|
|
2065
|
+
throw new ParseError({ message: 'Config loader must return a plain object or undefined' })
|
|
2066
|
+
parsed = result
|
|
2067
|
+
} else {
|
|
2068
|
+
if (!targetPath) return undefined
|
|
2069
|
+
const result = await readJsonConfig(targetPath, !!options.configPath)
|
|
2070
|
+
if (!result) return undefined
|
|
2071
|
+
parsed = result
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
// Extract the command section from the config tree
|
|
2075
|
+
return extractCommandSection(parsed, cli, path)
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
declare namespace loadCommandOptionDefaults {
|
|
2079
|
+
type Options = {
|
|
2080
|
+
configDisabled?: boolean | undefined
|
|
2081
|
+
configPath?: string | undefined
|
|
2082
|
+
files?: string[] | undefined
|
|
2083
|
+
loader?:
|
|
2084
|
+
| ((
|
|
2085
|
+
path: string | undefined,
|
|
2086
|
+
) => Record<string, unknown> | undefined | Promise<Record<string, unknown> | undefined>)
|
|
2087
|
+
| undefined
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
/** @internal Resolves a config file path, expanding `~` to home dir. */
|
|
2092
|
+
function resolveConfigPath(filePath: string): string {
|
|
2093
|
+
if (filePath.startsWith('~/') || filePath === '~') {
|
|
2094
|
+
return path.join(os.homedir(), filePath.slice(1))
|
|
2095
|
+
}
|
|
2096
|
+
return path.resolve(process.cwd(), filePath)
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
/** @internal Returns the first readable file from a list of paths, or `undefined`. */
|
|
2100
|
+
async function findFirstExisting(paths: string[]): Promise<string | undefined> {
|
|
2101
|
+
for (const p of paths) {
|
|
2102
|
+
const resolved = resolveConfigPath(p)
|
|
2103
|
+
try {
|
|
2104
|
+
await fs.access(resolved, fs.constants.R_OK)
|
|
2105
|
+
return resolved
|
|
2106
|
+
} catch {}
|
|
2107
|
+
}
|
|
2108
|
+
return undefined
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
/** @internal Reads and parses a JSON config file. */
|
|
2112
|
+
async function readJsonConfig(
|
|
2113
|
+
targetPath: string,
|
|
2114
|
+
explicit: boolean,
|
|
2115
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
2116
|
+
let raw: string
|
|
2117
|
+
try {
|
|
2118
|
+
raw = await fs.readFile(targetPath, 'utf8')
|
|
2119
|
+
} catch (error) {
|
|
2120
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
2121
|
+
if (explicit) throw new ParseError({ message: `Config file not found: ${targetPath}` })
|
|
2122
|
+
return undefined
|
|
2123
|
+
}
|
|
2124
|
+
throw error
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
let parsed: unknown
|
|
2128
|
+
try {
|
|
2129
|
+
parsed = JSON.parse(raw)
|
|
2130
|
+
} catch (error) {
|
|
2131
|
+
throw new ParseError({
|
|
2132
|
+
message: `Invalid JSON config file: ${targetPath}`,
|
|
2133
|
+
cause: error instanceof Error ? error : undefined,
|
|
2134
|
+
})
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
if (!isRecord(parsed))
|
|
2138
|
+
throw new ParseError({
|
|
2139
|
+
message: `Invalid config file: expected a top-level object in ${targetPath}`,
|
|
2140
|
+
})
|
|
2141
|
+
return parsed
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
/** @internal Walks the nested config tree to extract option defaults for a command path. */
|
|
2145
|
+
function extractCommandSection(
|
|
2146
|
+
parsed: Record<string, unknown>,
|
|
2147
|
+
cli: string,
|
|
2148
|
+
path: string,
|
|
2149
|
+
): Record<string, unknown> | undefined {
|
|
2150
|
+
const segments = path === cli ? [] : path.split(' ')
|
|
2151
|
+
let node: unknown = parsed
|
|
2152
|
+
for (const seg of segments) {
|
|
2153
|
+
if (!isRecord(node)) return undefined
|
|
2154
|
+
const commands = node.commands
|
|
2155
|
+
if (!isRecord(commands)) return undefined
|
|
2156
|
+
node = commands[seg]
|
|
2157
|
+
if (node === undefined) return undefined
|
|
2158
|
+
}
|
|
2159
|
+
if (!isRecord(node))
|
|
2160
|
+
throw new ParseError({
|
|
2161
|
+
message: `Invalid config section for '${path}': expected an object`,
|
|
2162
|
+
})
|
|
2163
|
+
|
|
2164
|
+
const options = node.options
|
|
2165
|
+
if (options === undefined) return undefined
|
|
2166
|
+
if (!isRecord(options))
|
|
2167
|
+
throw new ParseError({
|
|
2168
|
+
message: `Invalid config 'options' for '${path}': expected an object`,
|
|
2169
|
+
})
|
|
2170
|
+
return Object.keys(options).length > 0 ? options : undefined
|
|
2171
|
+
}
|
|
2172
|
+
|
|
1853
2173
|
/** @internal Collects immediate child commands/groups for help output. */
|
|
1854
2174
|
function collectHelpCommands(
|
|
1855
2175
|
commands: Map<string, CommandEntry>,
|
|
@@ -1953,7 +2273,13 @@ export const toCommands = new WeakMap<Cli, Map<string, CommandEntry>>()
|
|
|
1953
2273
|
const toMiddlewares = new WeakMap<Cli, MiddlewareHandler[]>()
|
|
1954
2274
|
|
|
1955
2275
|
/** @internal Maps root CLI instances to their command definitions. */
|
|
1956
|
-
const toRootDefinition = new WeakMap<Root, CommandDefinition<any, any, any>>()
|
|
2276
|
+
export const toRootDefinition = new WeakMap<Root, CommandDefinition<any, any, any>>()
|
|
2277
|
+
|
|
2278
|
+
/** @internal Maps CLI instances to their root options schema. */
|
|
2279
|
+
export const toRootOptions = new WeakMap<Cli, z.ZodObject<any>>()
|
|
2280
|
+
|
|
2281
|
+
/** @internal Maps CLI instances to whether config file loading is enabled. */
|
|
2282
|
+
export const toConfigEnabled = new WeakMap<Cli, boolean>()
|
|
1957
2283
|
|
|
1958
2284
|
/** @internal Maps CLI instances to their output policy. */
|
|
1959
2285
|
const toOutputPolicy = new WeakMap<Cli, OutputPolicy>()
|
package/src/Filter.ts
CHANGED
|
@@ -80,23 +80,6 @@ export function apply(data: unknown, paths: FilterPath[]): unknown {
|
|
|
80
80
|
return result
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
function resolve(data: unknown, segments: Segment[], index: number): unknown {
|
|
84
|
-
if (index >= segments.length) return data
|
|
85
|
-
const segment = segments[index]!
|
|
86
|
-
|
|
87
|
-
if ('key' in segment) {
|
|
88
|
-
if (typeof data !== 'object' || data === null) return undefined
|
|
89
|
-
const val = (data as Record<string, unknown>)[segment.key]
|
|
90
|
-
return resolve(val, segments, index + 1)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// slice segment
|
|
94
|
-
if (!Array.isArray(data)) return undefined
|
|
95
|
-
const sliced = data.slice(segment.start, segment.end)
|
|
96
|
-
if (index + 1 >= segments.length) return sliced
|
|
97
|
-
return sliced.map((item) => resolve(item, segments, index + 1))
|
|
98
|
-
}
|
|
99
|
-
|
|
100
83
|
function merge(
|
|
101
84
|
target: Record<string, unknown>,
|
|
102
85
|
data: unknown,
|