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.
Files changed (49) hide show
  1. package/README.md +61 -0
  2. package/dist/Cli.d.ts +15 -0
  3. package/dist/Cli.d.ts.map +1 -1
  4. package/dist/Cli.js +207 -11
  5. package/dist/Cli.js.map +1 -1
  6. package/dist/Filter.js +0 -18
  7. package/dist/Filter.js.map +1 -1
  8. package/dist/Help.d.ts +4 -0
  9. package/dist/Help.d.ts.map +1 -1
  10. package/dist/Help.js +17 -14
  11. package/dist/Help.js.map +1 -1
  12. package/dist/Parser.d.ts +2 -0
  13. package/dist/Parser.d.ts.map +1 -1
  14. package/dist/Parser.js +69 -37
  15. package/dist/Parser.js.map +1 -1
  16. package/dist/bin.d.ts +1 -0
  17. package/dist/bin.d.ts.map +1 -1
  18. package/dist/bin.js +17 -2
  19. package/dist/bin.js.map +1 -1
  20. package/dist/internal/command.d.ts +2 -0
  21. package/dist/internal/command.d.ts.map +1 -1
  22. package/dist/internal/command.js +1 -0
  23. package/dist/internal/command.js.map +1 -1
  24. package/dist/internal/configSchema.d.ts +8 -0
  25. package/dist/internal/configSchema.d.ts.map +1 -0
  26. package/dist/internal/configSchema.js +57 -0
  27. package/dist/internal/configSchema.js.map +1 -0
  28. package/dist/internal/helpers.d.ts +5 -0
  29. package/dist/internal/helpers.d.ts.map +1 -0
  30. package/dist/internal/helpers.js +9 -0
  31. package/dist/internal/helpers.js.map +1 -0
  32. package/examples/npm/.npmrc.json +21 -0
  33. package/examples/npm/config.schema.json +137 -0
  34. package/package.json +1 -1
  35. package/src/Cli.test-d.ts +39 -0
  36. package/src/Cli.test.ts +554 -3
  37. package/src/Cli.ts +266 -11
  38. package/src/Filter.ts +0 -17
  39. package/src/Help.test.ts +66 -0
  40. package/src/Help.ts +20 -13
  41. package/src/Parser.test-d.ts +22 -0
  42. package/src/Parser.test.ts +89 -0
  43. package/src/Parser.ts +86 -35
  44. package/src/bin.ts +21 -2
  45. package/src/e2e.test.ts +1 -1
  46. package/src/internal/command.ts +3 -0
  47. package/src/internal/configSchema.test.ts +193 -0
  48. package/src/internal/configSchema.ts +66 -0
  49. 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
- } = extractBuiltinFlags(argv)
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 3 characters. */
390
+ /** Redacts a value, showing only the last 4 characters. */
384
391
  function redact(value: string): string {
385
- if (value.length <= 3) return '••••'
386
- return `••••${value.slice(-3)}`
392
+ if (value.length <= 4) return '****'
393
+ return `****${value.slice(-4)}`
387
394
  }
@@ -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
+ })