incur 0.3.5 → 0.3.6
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 +207 -11
- 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 +5 -0
- package/dist/internal/helpers.d.ts.map +1 -0
- package/dist/internal/helpers.js +9 -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 +554 -3
- package/src/Cli.ts +266 -11
- package/src/Filter.ts +0 -17
- package/src/Help.test.ts +66 -0
- package/src/Help.ts +20 -13
- 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 +1 -1
- 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.ts +9 -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 } 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,13 +540,6 @@ 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)
|
|
506
544
|
if (!llms && !llmsFull && !schema && !help && !version) {
|
|
507
545
|
const isSkillsAdd =
|
|
@@ -759,6 +797,7 @@ async function serveImpl(
|
|
|
759
797
|
Help.formatCommand(name, {
|
|
760
798
|
alias: cmd.alias as Record<string, string> | undefined,
|
|
761
799
|
aliases: options.aliases,
|
|
800
|
+
configFlag,
|
|
762
801
|
description: cmd.description ?? options.description,
|
|
763
802
|
version: options.version,
|
|
764
803
|
args: cmd.args,
|
|
@@ -780,6 +819,7 @@ async function serveImpl(
|
|
|
780
819
|
writeln(
|
|
781
820
|
Help.formatRoot(name, {
|
|
782
821
|
aliases: options.aliases,
|
|
822
|
+
configFlag,
|
|
783
823
|
description: options.description,
|
|
784
824
|
version: options.version,
|
|
785
825
|
commands: collectHelpCommands(commands),
|
|
@@ -828,6 +868,7 @@ async function serveImpl(
|
|
|
828
868
|
Help.formatCommand(name, {
|
|
829
869
|
alias: cmd.alias as Record<string, string> | undefined,
|
|
830
870
|
aliases: options.aliases,
|
|
871
|
+
configFlag,
|
|
831
872
|
description: cmd.description ?? options.description,
|
|
832
873
|
version: options.version,
|
|
833
874
|
args: cmd.args,
|
|
@@ -845,6 +886,7 @@ async function serveImpl(
|
|
|
845
886
|
writeln(
|
|
846
887
|
Help.formatRoot(helpName, {
|
|
847
888
|
aliases: isRoot ? options.aliases : undefined,
|
|
889
|
+
configFlag,
|
|
848
890
|
description: helpDesc,
|
|
849
891
|
version: isRoot ? options.version : undefined,
|
|
850
892
|
commands: collectHelpCommands(helpCmds),
|
|
@@ -864,6 +906,7 @@ async function serveImpl(
|
|
|
864
906
|
Help.formatCommand(commandName, {
|
|
865
907
|
alias: cmd.alias as Record<string, string> | undefined,
|
|
866
908
|
aliases: isRootCmd ? options.aliases : undefined,
|
|
909
|
+
configFlag,
|
|
867
910
|
description: cmd.description,
|
|
868
911
|
version: isRootCmd ? options.version : undefined,
|
|
869
912
|
args: cmd.args,
|
|
@@ -886,6 +929,7 @@ async function serveImpl(
|
|
|
886
929
|
if ('help' in resolved) {
|
|
887
930
|
writeln(
|
|
888
931
|
Help.formatRoot(`${name} ${resolved.path}`, {
|
|
932
|
+
configFlag,
|
|
889
933
|
description: resolved.description,
|
|
890
934
|
commands: collectHelpCommands(resolved.commands),
|
|
891
935
|
}),
|
|
@@ -917,6 +961,7 @@ async function serveImpl(
|
|
|
917
961
|
if ('help' in resolved) {
|
|
918
962
|
writeln(
|
|
919
963
|
Help.formatRoot(`${name} ${resolved.path}`, {
|
|
964
|
+
configFlag,
|
|
920
965
|
description: resolved.description,
|
|
921
966
|
commands: collectHelpCommands(resolved.commands),
|
|
922
967
|
}),
|
|
@@ -1205,9 +1250,33 @@ async function serveImpl(
|
|
|
1205
1250
|
command.alias as Record<string, string> | undefined,
|
|
1206
1251
|
)
|
|
1207
1252
|
|
|
1253
|
+
let defaults: Record<string, unknown> | undefined
|
|
1254
|
+
if (configEnabled) {
|
|
1255
|
+
try {
|
|
1256
|
+
defaults = await loadCommandOptionDefaults(name, path, {
|
|
1257
|
+
configDisabled,
|
|
1258
|
+
configPath,
|
|
1259
|
+
files: options.config?.files,
|
|
1260
|
+
loader: options.config?.loader,
|
|
1261
|
+
})
|
|
1262
|
+
} catch (error) {
|
|
1263
|
+
write({
|
|
1264
|
+
ok: false,
|
|
1265
|
+
error: {
|
|
1266
|
+
code: error instanceof IncurError ? error.code : 'UNKNOWN',
|
|
1267
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1268
|
+
},
|
|
1269
|
+
meta: { command: path, duration: `${Math.round(performance.now() - start)}ms` },
|
|
1270
|
+
})
|
|
1271
|
+
exit(error instanceof IncurError ? (error.exitCode ?? 1) : 1)
|
|
1272
|
+
return
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1208
1276
|
const result = await Command.execute(command, {
|
|
1209
1277
|
agent: !human,
|
|
1210
1278
|
argv: rest,
|
|
1279
|
+
defaults,
|
|
1211
1280
|
env: options.envSchema,
|
|
1212
1281
|
envSource: options.env,
|
|
1213
1282
|
format,
|
|
@@ -1266,6 +1335,7 @@ async function serveImpl(
|
|
|
1266
1335
|
fieldErrors: result.error.fieldErrors,
|
|
1267
1336
|
}),
|
|
1268
1337
|
options.env,
|
|
1338
|
+
configFlag,
|
|
1269
1339
|
),
|
|
1270
1340
|
)
|
|
1271
1341
|
exit(1)
|
|
@@ -1636,6 +1706,7 @@ function formatHumanValidationError(
|
|
|
1636
1706
|
command: CommandDefinition<any, any, any>,
|
|
1637
1707
|
error: ValidationError,
|
|
1638
1708
|
envSource?: Record<string, string | undefined>,
|
|
1709
|
+
configFlag?: string,
|
|
1639
1710
|
): string {
|
|
1640
1711
|
const lines: string[] = []
|
|
1641
1712
|
for (const fe of error.fieldErrors) lines.push(`Error: missing required argument <${fe.path}>`)
|
|
@@ -1644,6 +1715,7 @@ function formatHumanValidationError(
|
|
|
1644
1715
|
lines.push(
|
|
1645
1716
|
Help.formatCommand(path === cli ? cli : `${cli} ${path}`, {
|
|
1646
1717
|
alias: command.alias as Record<string, string> | undefined,
|
|
1718
|
+
configFlag,
|
|
1647
1719
|
description: command.description,
|
|
1648
1720
|
args: command.args,
|
|
1649
1721
|
env: command.env,
|
|
@@ -1753,6 +1825,20 @@ declare namespace serveImpl {
|
|
|
1753
1825
|
type Options = serve.Options & {
|
|
1754
1826
|
/** Alternative binary names for this CLI. */
|
|
1755
1827
|
aliases?: string[] | undefined
|
|
1828
|
+
config?:
|
|
1829
|
+
| {
|
|
1830
|
+
flag?: string | undefined
|
|
1831
|
+
files?: string[] | undefined
|
|
1832
|
+
loader?:
|
|
1833
|
+
| ((
|
|
1834
|
+
path: string | undefined,
|
|
1835
|
+
) =>
|
|
1836
|
+
| Record<string, unknown>
|
|
1837
|
+
| undefined
|
|
1838
|
+
| Promise<Record<string, unknown> | undefined>)
|
|
1839
|
+
| undefined
|
|
1840
|
+
}
|
|
1841
|
+
| undefined
|
|
1756
1842
|
description?: string | undefined
|
|
1757
1843
|
/** CLI-level env schema. Parsed before middleware runs. */
|
|
1758
1844
|
envSchema?: z.ZodObject<any> | undefined
|
|
@@ -1787,7 +1873,7 @@ declare namespace serveImpl {
|
|
|
1787
1873
|
}
|
|
1788
1874
|
|
|
1789
1875
|
/** @internal Extracts built-in flags (--verbose, --format, --json, --llms, --help, --version) from argv. */
|
|
1790
|
-
function extractBuiltinFlags(argv: string[]) {
|
|
1876
|
+
function extractBuiltinFlags(argv: string[], options: extractBuiltinFlags.Options = {}) {
|
|
1791
1877
|
let verbose = false
|
|
1792
1878
|
let llms = false
|
|
1793
1879
|
let llmsFull = false
|
|
@@ -1797,12 +1883,18 @@ function extractBuiltinFlags(argv: string[]) {
|
|
|
1797
1883
|
let schema = false
|
|
1798
1884
|
let format: Formatter.Format = 'toon'
|
|
1799
1885
|
let formatExplicit = false
|
|
1886
|
+
let configPath: string | undefined
|
|
1887
|
+
let configDisabled = false
|
|
1800
1888
|
let filterOutput: string | undefined
|
|
1801
1889
|
let tokenLimit: number | undefined
|
|
1802
1890
|
let tokenOffset: number | undefined
|
|
1803
1891
|
let tokenCount = false
|
|
1804
1892
|
const rest: string[] = []
|
|
1805
1893
|
|
|
1894
|
+
const cfgFlag = options.configFlag ? `--${options.configFlag}` : undefined
|
|
1895
|
+
const cfgFlagEq = options.configFlag ? `--${options.configFlag}=` : undefined
|
|
1896
|
+
const noCfgFlag = options.configFlag ? `--no-${options.configFlag}` : undefined
|
|
1897
|
+
|
|
1806
1898
|
for (let i = 0; i < argv.length; i++) {
|
|
1807
1899
|
const token = argv[i]!
|
|
1808
1900
|
if (token === '--verbose') verbose = true
|
|
@@ -1819,6 +1911,22 @@ function extractBuiltinFlags(argv: string[]) {
|
|
|
1819
1911
|
format = argv[i + 1] as Formatter.Format
|
|
1820
1912
|
formatExplicit = true
|
|
1821
1913
|
i++
|
|
1914
|
+
} else if (cfgFlag && token === cfgFlag) {
|
|
1915
|
+
const value = argv[i + 1]
|
|
1916
|
+
if (value === undefined)
|
|
1917
|
+
throw new ParseError({ message: `Missing value for flag: ${cfgFlag}` })
|
|
1918
|
+
configPath = value
|
|
1919
|
+
configDisabled = false
|
|
1920
|
+
i++
|
|
1921
|
+
} else if (cfgFlagEq && token.startsWith(cfgFlagEq)) {
|
|
1922
|
+
const value = token.slice(cfgFlagEq.length)
|
|
1923
|
+
if (value.length === 0)
|
|
1924
|
+
throw new ParseError({ message: `Missing value for flag: ${cfgFlag}` })
|
|
1925
|
+
configPath = value
|
|
1926
|
+
configDisabled = false
|
|
1927
|
+
} else if (noCfgFlag && token === noCfgFlag) {
|
|
1928
|
+
configPath = undefined
|
|
1929
|
+
configDisabled = true
|
|
1822
1930
|
} else if (token === '--filter-output' && argv[i + 1]) {
|
|
1823
1931
|
filterOutput = argv[i + 1]!
|
|
1824
1932
|
i++
|
|
@@ -1836,6 +1944,8 @@ function extractBuiltinFlags(argv: string[]) {
|
|
|
1836
1944
|
verbose,
|
|
1837
1945
|
format,
|
|
1838
1946
|
formatExplicit,
|
|
1947
|
+
configPath,
|
|
1948
|
+
configDisabled,
|
|
1839
1949
|
filterOutput,
|
|
1840
1950
|
tokenLimit,
|
|
1841
1951
|
tokenOffset,
|
|
@@ -1850,6 +1960,145 @@ function extractBuiltinFlags(argv: string[]) {
|
|
|
1850
1960
|
}
|
|
1851
1961
|
}
|
|
1852
1962
|
|
|
1963
|
+
declare namespace extractBuiltinFlags {
|
|
1964
|
+
type Options = {
|
|
1965
|
+
configFlag?: string | undefined
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
/** @internal Loads config-backed option defaults for the active command. */
|
|
1970
|
+
async function loadCommandOptionDefaults(
|
|
1971
|
+
cli: string,
|
|
1972
|
+
path: string,
|
|
1973
|
+
options: loadCommandOptionDefaults.Options = {},
|
|
1974
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
1975
|
+
if (options.configDisabled) return undefined
|
|
1976
|
+
|
|
1977
|
+
const { loader } = options
|
|
1978
|
+
|
|
1979
|
+
// Resolve the target file path
|
|
1980
|
+
let targetPath: string | undefined
|
|
1981
|
+
if (options.configPath) {
|
|
1982
|
+
targetPath = resolveConfigPath(options.configPath)
|
|
1983
|
+
} else {
|
|
1984
|
+
const searchPaths = options.files ?? [`${cli}.json`]
|
|
1985
|
+
targetPath = await findFirstExisting(searchPaths)
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// Load and parse the config
|
|
1989
|
+
let parsed: Record<string, unknown>
|
|
1990
|
+
if (loader) {
|
|
1991
|
+
const result = await loader(targetPath)
|
|
1992
|
+
if (result === undefined) return undefined
|
|
1993
|
+
if (!isRecord(result))
|
|
1994
|
+
throw new ParseError({ message: 'Config loader must return a plain object or undefined' })
|
|
1995
|
+
parsed = result
|
|
1996
|
+
} else {
|
|
1997
|
+
if (!targetPath) return undefined
|
|
1998
|
+
const result = await readJsonConfig(targetPath, !!options.configPath)
|
|
1999
|
+
if (!result) return undefined
|
|
2000
|
+
parsed = result
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// Extract the command section from the config tree
|
|
2004
|
+
return extractCommandSection(parsed, cli, path)
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
declare namespace loadCommandOptionDefaults {
|
|
2008
|
+
type Options = {
|
|
2009
|
+
configDisabled?: boolean | undefined
|
|
2010
|
+
configPath?: string | undefined
|
|
2011
|
+
files?: string[] | undefined
|
|
2012
|
+
loader?:
|
|
2013
|
+
| ((
|
|
2014
|
+
path: string | undefined,
|
|
2015
|
+
) => Record<string, unknown> | undefined | Promise<Record<string, unknown> | undefined>)
|
|
2016
|
+
| undefined
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
/** @internal Resolves a config file path, expanding `~` to home dir. */
|
|
2021
|
+
function resolveConfigPath(filePath: string): string {
|
|
2022
|
+
if (filePath.startsWith('~/') || filePath === '~') {
|
|
2023
|
+
return path.join(os.homedir(), filePath.slice(1))
|
|
2024
|
+
}
|
|
2025
|
+
return path.resolve(process.cwd(), filePath)
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
/** @internal Returns the first readable file from a list of paths, or `undefined`. */
|
|
2029
|
+
async function findFirstExisting(paths: string[]): Promise<string | undefined> {
|
|
2030
|
+
for (const p of paths) {
|
|
2031
|
+
const resolved = resolveConfigPath(p)
|
|
2032
|
+
try {
|
|
2033
|
+
await fs.access(resolved, fs.constants.R_OK)
|
|
2034
|
+
return resolved
|
|
2035
|
+
} catch {}
|
|
2036
|
+
}
|
|
2037
|
+
return undefined
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
/** @internal Reads and parses a JSON config file. */
|
|
2041
|
+
async function readJsonConfig(
|
|
2042
|
+
targetPath: string,
|
|
2043
|
+
explicit: boolean,
|
|
2044
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
2045
|
+
let raw: string
|
|
2046
|
+
try {
|
|
2047
|
+
raw = await fs.readFile(targetPath, 'utf8')
|
|
2048
|
+
} catch (error) {
|
|
2049
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
2050
|
+
if (explicit) throw new ParseError({ message: `Config file not found: ${targetPath}` })
|
|
2051
|
+
return undefined
|
|
2052
|
+
}
|
|
2053
|
+
throw error
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
let parsed: unknown
|
|
2057
|
+
try {
|
|
2058
|
+
parsed = JSON.parse(raw)
|
|
2059
|
+
} catch (error) {
|
|
2060
|
+
throw new ParseError({
|
|
2061
|
+
message: `Invalid JSON config file: ${targetPath}`,
|
|
2062
|
+
cause: error instanceof Error ? error : undefined,
|
|
2063
|
+
})
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
if (!isRecord(parsed))
|
|
2067
|
+
throw new ParseError({
|
|
2068
|
+
message: `Invalid config file: expected a top-level object in ${targetPath}`,
|
|
2069
|
+
})
|
|
2070
|
+
return parsed
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/** @internal Walks the nested config tree to extract option defaults for a command path. */
|
|
2074
|
+
function extractCommandSection(
|
|
2075
|
+
parsed: Record<string, unknown>,
|
|
2076
|
+
cli: string,
|
|
2077
|
+
path: string,
|
|
2078
|
+
): Record<string, unknown> | undefined {
|
|
2079
|
+
const segments = path === cli ? [] : path.split(' ')
|
|
2080
|
+
let node: unknown = parsed
|
|
2081
|
+
for (const seg of segments) {
|
|
2082
|
+
if (!isRecord(node)) return undefined
|
|
2083
|
+
const commands = node.commands
|
|
2084
|
+
if (!isRecord(commands)) return undefined
|
|
2085
|
+
node = commands[seg]
|
|
2086
|
+
if (node === undefined) return undefined
|
|
2087
|
+
}
|
|
2088
|
+
if (!isRecord(node))
|
|
2089
|
+
throw new ParseError({
|
|
2090
|
+
message: `Invalid config section for '${path}': expected an object`,
|
|
2091
|
+
})
|
|
2092
|
+
|
|
2093
|
+
const options = node.options
|
|
2094
|
+
if (options === undefined) return undefined
|
|
2095
|
+
if (!isRecord(options))
|
|
2096
|
+
throw new ParseError({
|
|
2097
|
+
message: `Invalid config 'options' for '${path}': expected an object`,
|
|
2098
|
+
})
|
|
2099
|
+
return Object.keys(options).length > 0 ? options : undefined
|
|
2100
|
+
}
|
|
2101
|
+
|
|
1853
2102
|
/** @internal Collects immediate child commands/groups for help output. */
|
|
1854
2103
|
function collectHelpCommands(
|
|
1855
2104
|
commands: Map<string, CommandEntry>,
|
|
@@ -1953,7 +2202,13 @@ export const toCommands = new WeakMap<Cli, Map<string, CommandEntry>>()
|
|
|
1953
2202
|
const toMiddlewares = new WeakMap<Cli, MiddlewareHandler[]>()
|
|
1954
2203
|
|
|
1955
2204
|
/** @internal Maps root CLI instances to their command definitions. */
|
|
1956
|
-
const toRootDefinition = new WeakMap<Root, CommandDefinition<any, any, any>>()
|
|
2205
|
+
export const toRootDefinition = new WeakMap<Root, CommandDefinition<any, any, any>>()
|
|
2206
|
+
|
|
2207
|
+
/** @internal Maps CLI instances to their root options schema. */
|
|
2208
|
+
export const toRootOptions = new WeakMap<Cli, z.ZodObject<any>>()
|
|
2209
|
+
|
|
2210
|
+
/** @internal Maps CLI instances to whether config file loading is enabled. */
|
|
2211
|
+
export const toConfigEnabled = new WeakMap<Cli, boolean>()
|
|
1957
2212
|
|
|
1958
2213
|
/** @internal Maps CLI instances to their output policy. */
|
|
1959
2214
|
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,
|
package/src/Help.test.ts
CHANGED
|
@@ -141,6 +141,36 @@ describe('formatCommand', () => {
|
|
|
141
141
|
expect(result).toContain('[deprecated] Availability zone')
|
|
142
142
|
expect(result).not.toContain('[deprecated] Target region')
|
|
143
143
|
})
|
|
144
|
+
|
|
145
|
+
test('shows config global options when flag name is set', () => {
|
|
146
|
+
const result = Help.formatCommand('tool deploy', {
|
|
147
|
+
configFlag: 'config',
|
|
148
|
+
options: z.object({
|
|
149
|
+
env: z.enum(['staging', 'production']).describe('Target environment'),
|
|
150
|
+
}),
|
|
151
|
+
})
|
|
152
|
+
expect(result).toMatchInlineSnapshot(`
|
|
153
|
+
"tool deploy
|
|
154
|
+
|
|
155
|
+
Usage: tool deploy [options]
|
|
156
|
+
|
|
157
|
+
Options:
|
|
158
|
+
--env <staging|production> Target environment
|
|
159
|
+
|
|
160
|
+
Global Options:
|
|
161
|
+
--config <path> Load JSON option defaults from a file
|
|
162
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
163
|
+
--format <toon|json|yaml|md|jsonl> Output format
|
|
164
|
+
--help Show help
|
|
165
|
+
--llms, --llms-full Print LLM-readable manifest
|
|
166
|
+
--no-config Disable JSON option defaults for this run
|
|
167
|
+
--schema Show JSON Schema for command
|
|
168
|
+
--token-count Print token count of output (instead of output)
|
|
169
|
+
--token-limit <n> Limit output to n tokens
|
|
170
|
+
--token-offset <n> Skip first n tokens of output
|
|
171
|
+
--verbose Show full output envelope"
|
|
172
|
+
`)
|
|
173
|
+
})
|
|
144
174
|
})
|
|
145
175
|
|
|
146
176
|
describe('formatRoot', () => {
|
|
@@ -258,4 +288,40 @@ describe('formatRoot', () => {
|
|
|
258
288
|
--verbose Show full output envelope"
|
|
259
289
|
`)
|
|
260
290
|
})
|
|
291
|
+
|
|
292
|
+
test('formatRoot shows config global options when flag name is set', () => {
|
|
293
|
+
const result = Help.formatRoot('tool', {
|
|
294
|
+
configFlag: 'config',
|
|
295
|
+
root: true,
|
|
296
|
+
commands: [{ name: 'ping', description: 'Health check' }],
|
|
297
|
+
})
|
|
298
|
+
expect(result).toMatchInlineSnapshot(`
|
|
299
|
+
"tool
|
|
300
|
+
|
|
301
|
+
Usage: tool <command>
|
|
302
|
+
|
|
303
|
+
Commands:
|
|
304
|
+
ping Health check
|
|
305
|
+
|
|
306
|
+
Integrations:
|
|
307
|
+
completions Generate shell completion script
|
|
308
|
+
mcp add Register as MCP server
|
|
309
|
+
skills add Sync skill files to agents
|
|
310
|
+
|
|
311
|
+
Global Options:
|
|
312
|
+
--config <path> Load JSON option defaults from a file
|
|
313
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
314
|
+
--format <toon|json|yaml|md|jsonl> Output format
|
|
315
|
+
--help Show help
|
|
316
|
+
--llms, --llms-full Print LLM-readable manifest
|
|
317
|
+
--mcp Start as MCP stdio server
|
|
318
|
+
--no-config Disable JSON option defaults for this run
|
|
319
|
+
--schema Show JSON Schema for command
|
|
320
|
+
--token-count Print token count of output (instead of output)
|
|
321
|
+
--token-limit <n> Limit output to n tokens
|
|
322
|
+
--token-offset <n> Skip first n tokens of output
|
|
323
|
+
--verbose Show full output envelope
|
|
324
|
+
--version Show version"
|
|
325
|
+
`)
|
|
326
|
+
})
|
|
261
327
|
})
|
package/src/Help.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
3
|
import { builtinCommands } from './internal/command.js'
|
|
4
|
+
import { toKebab } from './internal/helpers.js'
|
|
4
5
|
|
|
5
6
|
/** Formats help text for a router CLI or command group. */
|
|
6
7
|
export function formatRoot(name: string, options: formatRoot.Options = {}): string {
|
|
7
|
-
const { aliases, description, version, commands = [], root = false } = options
|
|
8
|
+
const { aliases, configFlag, description, version, commands = [], root = false } = options
|
|
8
9
|
const lines: string[] = []
|
|
9
10
|
|
|
10
11
|
// Header
|
|
@@ -29,7 +30,7 @@ export function formatRoot(name: string, options: formatRoot.Options = {}): stri
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
lines.push(...globalOptionsLines(root))
|
|
33
|
+
lines.push(...globalOptionsLines(root, configFlag))
|
|
33
34
|
|
|
34
35
|
return lines.join('\n')
|
|
35
36
|
}
|
|
@@ -38,6 +39,8 @@ export declare namespace formatRoot {
|
|
|
38
39
|
type Options = {
|
|
39
40
|
/** Alternative binary names for this CLI. */
|
|
40
41
|
aliases?: string[] | undefined
|
|
42
|
+
/** Flag name for config file path (e.g. `'config'` renders `--config <path>`). */
|
|
43
|
+
configFlag?: string | undefined
|
|
41
44
|
/** Commands to list. */
|
|
42
45
|
commands?: { name: string; description?: string | undefined }[] | undefined
|
|
43
46
|
/** A short description of the CLI or group. */
|
|
@@ -57,6 +60,8 @@ export declare namespace formatCommand {
|
|
|
57
60
|
aliases?: string[] | undefined
|
|
58
61
|
/** Zod schema for positional arguments. */
|
|
59
62
|
args?: z.ZodObject<any> | undefined
|
|
63
|
+
/** Flag name for config file path (e.g. `'config'` renders `--config <path>`). */
|
|
64
|
+
configFlag?: string | undefined
|
|
60
65
|
/** Subcommands to list (for CLIs with both a root handler and subcommands). */
|
|
61
66
|
commands?: { name: string; description?: string | undefined }[] | undefined
|
|
62
67
|
/** A short description of what the command does. */
|
|
@@ -94,6 +99,7 @@ export function formatCommand(name: string, options: formatCommand.Options = {})
|
|
|
94
99
|
const {
|
|
95
100
|
alias,
|
|
96
101
|
aliases,
|
|
102
|
+
configFlag,
|
|
97
103
|
description,
|
|
98
104
|
version,
|
|
99
105
|
args,
|
|
@@ -199,7 +205,7 @@ export function formatCommand(name: string, options: formatCommand.Options = {})
|
|
|
199
205
|
}
|
|
200
206
|
}
|
|
201
207
|
|
|
202
|
-
if (!options.hideGlobalOptions) lines.push(...globalOptionsLines(root))
|
|
208
|
+
if (!options.hideGlobalOptions) lines.push(...globalOptionsLines(root, configFlag))
|
|
203
209
|
|
|
204
210
|
// Environment Variables
|
|
205
211
|
if (env) {
|
|
@@ -327,13 +333,8 @@ function extractDeprecated(schema: unknown): boolean | undefined {
|
|
|
327
333
|
return meta?.deprecated === true ? true : undefined
|
|
328
334
|
}
|
|
329
335
|
|
|
330
|
-
/** Converts a camelCase string to kebab-case. */
|
|
331
|
-
function toKebab(str: string): string {
|
|
332
|
-
return str.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
|
|
333
|
-
}
|
|
334
|
-
|
|
335
336
|
/** Renders the built-in commands and global options block. Root-only items are hidden for subcommands. */
|
|
336
|
-
function globalOptionsLines(root = false): string[] {
|
|
337
|
+
function globalOptionsLines(root = false, configFlag?: string): string[] {
|
|
337
338
|
const lines: string[] = []
|
|
338
339
|
|
|
339
340
|
if (root) {
|
|
@@ -355,6 +356,9 @@ function globalOptionsLines(root = false): string[] {
|
|
|
355
356
|
}
|
|
356
357
|
|
|
357
358
|
const flags = [
|
|
359
|
+
...(configFlag
|
|
360
|
+
? [{ flag: `--${configFlag} <path>`, desc: 'Load JSON option defaults from a file' }]
|
|
361
|
+
: []),
|
|
358
362
|
{
|
|
359
363
|
flag: '--filter-output <keys>',
|
|
360
364
|
desc: 'Filter output by key paths (e.g. foo,bar.baz,a[0,3])',
|
|
@@ -363,13 +367,16 @@ function globalOptionsLines(root = false): string[] {
|
|
|
363
367
|
{ flag: '--help', desc: 'Show help' },
|
|
364
368
|
{ flag: '--llms, --llms-full', desc: 'Print LLM-readable manifest' },
|
|
365
369
|
...(root ? [{ flag: '--mcp', desc: 'Start as MCP stdio server' }] : []),
|
|
370
|
+
...(configFlag
|
|
371
|
+
? [{ flag: `--no-${configFlag}`, desc: 'Disable JSON option defaults for this run' }]
|
|
372
|
+
: []),
|
|
366
373
|
{ flag: '--schema', desc: 'Show JSON Schema for command' },
|
|
367
374
|
{ flag: '--token-count', desc: 'Print token count of output (instead of output)' },
|
|
368
375
|
{ flag: '--token-limit <n>', desc: 'Limit output to n tokens' },
|
|
369
376
|
{ flag: '--token-offset <n>', desc: 'Skip first n tokens of output' },
|
|
370
377
|
{ flag: '--verbose', desc: 'Show full output envelope' },
|
|
371
378
|
...(root ? [{ flag: '--version', desc: 'Show version' }] : []),
|
|
372
|
-
]
|
|
379
|
+
].sort((a, b) => a.flag.localeCompare(b.flag))
|
|
373
380
|
const maxLen = Math.max(...flags.map((f) => f.flag.length))
|
|
374
381
|
lines.push(
|
|
375
382
|
'',
|
|
@@ -380,8 +387,8 @@ function globalOptionsLines(root = false): string[] {
|
|
|
380
387
|
return lines
|
|
381
388
|
}
|
|
382
389
|
|
|
383
|
-
/** Redacts a value, showing only the last
|
|
390
|
+
/** Redacts a value, showing only the last 4 characters. */
|
|
384
391
|
function redact(value: string): string {
|
|
385
|
-
if (value.length <=
|
|
386
|
-
return
|
|
392
|
+
if (value.length <= 4) return '****'
|
|
393
|
+
return `****${value.slice(-4)}`
|
|
387
394
|
}
|
package/src/Parser.test-d.ts
CHANGED
|
@@ -43,3 +43,25 @@ test('narrows both args and options together', () => {
|
|
|
43
43
|
expectTypeOf(result.args).toEqualTypeOf<{ repo: string }>()
|
|
44
44
|
expectTypeOf(result.options).toEqualTypeOf<{ limit: number }>()
|
|
45
45
|
})
|
|
46
|
+
|
|
47
|
+
test('defaults are typed from z.input of the options schema', () => {
|
|
48
|
+
const result = Parser.parse([], {
|
|
49
|
+
defaults: { limit: '5' },
|
|
50
|
+
options: z.object({ limit: z.coerce.number().default(30) }),
|
|
51
|
+
})
|
|
52
|
+
expectTypeOf(result.options).toEqualTypeOf<{ limit: number }>()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('defaults do not leak any', () => {
|
|
56
|
+
type Options = z.ZodObject<{
|
|
57
|
+
limit: z.ZodDefault<z.ZodNumber>
|
|
58
|
+
saveDev: z.ZodOptional<z.ZodBoolean>
|
|
59
|
+
}>
|
|
60
|
+
|
|
61
|
+
expectTypeOf<Parser.parse.Options<undefined, Options>>().toEqualTypeOf<{
|
|
62
|
+
args?: undefined
|
|
63
|
+
alias?: Record<string, string> | undefined
|
|
64
|
+
defaults?: Partial<z.input<Options>> | undefined
|
|
65
|
+
options?: Options
|
|
66
|
+
}>()
|
|
67
|
+
})
|