goke 6.4.0 → 6.5.1

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 CHANGED
@@ -15,7 +15,7 @@
15
15
  - **Yet so powerful**. Enable features like default command, git-like subcommands, validation for required arguments and options, variadic arguments, dot-nested options, automated help message generation and so on.
16
16
  - **Space-separated subcommands**: Support multi-word commands like `mcp login`, `git remote add`.
17
17
  - **Schema-based type coercion**: Use Zod, Valibot, ArkType, or plain JSON Schema for automatic type coercion and TypeScript type inference. Description and default values are extracted from the schema automatically.
18
- - **Injected execution context**: Prefer `{ console, process }` in actions and middleware for portable output, simpler tests, and alternate runtimes like JustBash.
18
+ - **Injected execution context**: Prefer `{ fs, console, process }` in actions and middleware for portable storage, output, and runtime metadata across Node.js, tests, and JustBash.
19
19
  - **Type-safe middleware**: Register `.use()` callbacks that run before commands with full type inference from global options.
20
20
  - **Developer friendly**. Written in TypeScript.
21
21
 
@@ -25,6 +25,14 @@
25
25
  npm install goke
26
26
  ```
27
27
 
28
+ ## Install skill for AI agents
29
+
30
+ ```bash
31
+ npx -y skills add remorses/goke
32
+ ```
33
+
34
+ This installs the repository skill for AI coding agents. In this repo the shipped skill lives at `skills/goke/SKILL.md`.
35
+
28
36
  ## Usage
29
37
 
30
38
  ### Simple Parsing
@@ -44,8 +52,8 @@ cli.option(
44
52
  )
45
53
  cli.option('--name <name>', 'Provide your name')
46
54
 
47
- cli.command('lint [...files]', 'Lint files').action((files, options, { console }) => {
48
- console.log(files, options)
55
+ cli.command('lint [...files]', 'Lint files').action((files, options, { console, process }) => {
56
+ console.log(files, options, process.cwd)
49
57
  })
50
58
 
51
59
  cli
@@ -53,8 +61,8 @@ cli
53
61
  .option('--minify', 'Minify output')
54
62
  .example('build src/index.ts')
55
63
  .example('build src/index.ts --minify')
56
- .action(async (entry, options, { console }) => { // options is type safe! no need to type it
57
- console.log(entry, options)
64
+ .action(async (entry, options, { console, process }) => { // options is type safe! no need to type it
65
+ console.log(entry, options, process.env.NODE_ENV)
58
66
  })
59
67
 
60
68
  cli.example((bin) => `${bin} lint src/**/*.ts`)
@@ -133,8 +141,8 @@ cli
133
141
  .option('--channel <name>', 'Target channel: stable, beta, alpha')
134
142
  .option('--notes-file <path>', 'Markdown file used as release notes')
135
143
  .option('--dry-run', 'Preview every step without publishing')
136
- .action((version, options, { console }) => {
137
- console.log('release', version, options)
144
+ .action((version, options, { console, process }) => {
145
+ console.log('release', version, options, process.cwd)
138
146
  })
139
147
 
140
148
  cli
@@ -159,8 +167,8 @@ cli
159
167
  .option('--target <migration>', 'Apply up to a specific migration id')
160
168
  .option('--dry-run', 'Print plan only, do not execute SQL')
161
169
  .option('--verbose', 'Show each executed statement')
162
- .action((options, { console }) => {
163
- console.log('migrate', options)
170
+ .action((options, { console, process }) => {
171
+ console.log('migrate', options, process.stdin)
164
172
  })
165
173
 
166
174
  cli.help()
@@ -299,39 +307,39 @@ cli
299
307
  z.string().default('production').describe('Target environment'),
300
308
  )
301
309
  .option('--dry-run', 'Preview without deploying')
302
- .action((options, { console }) => {
303
- console.log(`Deploying to ${options.env}...`)
310
+ .action((options, { console, process }) => {
311
+ console.log(`Deploying to ${options.env} from ${process.cwd}...`)
304
312
  })
305
313
 
306
314
  // Subcommands
307
315
  cli
308
316
  .command('init', 'Initialize a new project')
309
317
  .option('--template <template>', 'Project template')
310
- .action((options, { console }) => {
311
- console.log('Initializing project...')
318
+ .action((options, { console, process }) => {
319
+ console.log('Initializing project in', process.cwd)
312
320
  })
313
321
 
314
- cli.command('login', 'Authenticate with the server').action((options, { console }) => {
315
- console.log('Opening browser for login...')
322
+ cli.command('login', 'Authenticate with the server').action((options, { console, process }) => {
323
+ console.log('Opening browser for login from', process.cwd)
316
324
  })
317
325
 
318
- cli.command('logout', 'Clear saved credentials').action((options, { console }) => {
319
- console.log('Logged out')
326
+ cli.command('logout', 'Clear saved credentials').action((options, { console, process }) => {
327
+ console.log('Logged out', process.env.USER)
320
328
  })
321
329
 
322
330
  cli
323
331
  .command('status', 'Show deployment status')
324
332
  .option('--json', 'Output as JSON')
325
- .action((options, { console }) => {
326
- console.log('Fetching status...')
333
+ .action((options, { console, process }) => {
334
+ console.log('Fetching status from', process.cwd)
327
335
  })
328
336
 
329
337
  cli
330
338
  .command('logs <deploymentId>', 'Stream logs for a deployment')
331
339
  .option('--follow', 'Follow log output')
332
340
  .option('--lines <n>', z.number().default(100).describe('Number of lines'))
333
- .action((deploymentId, options, { console }) => {
334
- console.log(`Streaming logs for ${deploymentId}...`)
341
+ .action((deploymentId, options, { console, process }) => {
342
+ console.log(`Streaming logs for ${deploymentId} from ${process.cwd}...`)
335
343
  })
336
344
 
337
345
  cli.help()
@@ -352,10 +360,88 @@ deploy --help # shows all commands
352
360
 
353
361
  Global options are defined on the CLI instance and apply to all commands. Use `.use()` to register middleware that runs before any command action — useful for reacting to global options like setting up logging, initializing state, or configuring services.
354
362
 
355
- Prefer the injected `{ console, process }` argument over global `console` and `process.exit`. It keeps commands easier to test and lets the same command code run inside alternate runtimes like JustBash.
363
+ Prefer the injected `{ fs, console, process }` argument over global `console`, `process.exit`, or direct `node:fs/promises` imports. It keeps commands easier to test and lets the same command code run inside alternate runtimes like JustBash.
364
+
365
+ `process.cwd`, `process.stdin`, and `process.env` come from the active runtime:
366
+
367
+ - In normal Node.js runs, `process.cwd` and `process.env` reflect the host process, while `process.stdin` defaults to an empty string unless you inject it yourself.
368
+ - In JustBash runs, those same fields are populated from the sandbox execution context.
356
369
 
357
370
  Middleware runs in registration order, after option parsing and validation, but before the matched command's `.action()` callback.
358
371
 
372
+ ### Filesystem Access
373
+
374
+ The injected `fs` object is the recommended way to read or write CLI state.
375
+
376
+ - In normal Node.js runs, `fs` defaults to `node:fs/promises`
377
+ - In JustBash runs, `goke` swaps in a compatible adapter over the JustBash virtual filesystem
378
+
379
+ This makes storage-style commands work in both environments without branching on runtime details.
380
+
381
+ ```ts
382
+ cli
383
+ .command('login', 'Save auth token')
384
+ .option('--token <token>', z.string().describe('Auth token'))
385
+ .action(async (options, { fs, console, process }) => {
386
+ await fs.mkdir('.mycli', { recursive: true })
387
+ await fs.writeFile('.mycli/auth.json', JSON.stringify({ token: options.token }), 'utf8')
388
+ console.log('saved credentials in', process.cwd)
389
+ })
390
+
391
+ cli
392
+ .command('whoami', 'Read saved auth token')
393
+ .action(async (options, { fs, console, process }) => {
394
+ const auth = await fs.readFile('.mycli/auth.json', 'utf8')
395
+ console.log(auth, process.env.USER)
396
+ })
397
+ ```
398
+
399
+ Prefer injected `fs` for CLI storage instead of importing `node:fs/promises` directly inside actions. That keeps the command portable to JustBash and easier to test.
400
+
401
+ ### Path handling
402
+
403
+ Use relative paths with injected `fs` for routine CLI storage paths. When a helper needs to resolve from the current directory, pass injected `process.cwd` into that helper and resolve from there.
404
+
405
+ ```ts
406
+ await fs.mkdir('.mycli', { recursive: true })
407
+ await fs.writeFile('.mycli/auth.json', json, 'utf8')
408
+ console.log('running from', process.cwd)
409
+ ```
410
+
411
+ Why this works:
412
+
413
+ - In normal Node.js runs, relative paths resolve against the host cwd.
414
+ - In JustBash runs, the same relative paths resolve against the sandbox cwd.
415
+ - `process.cwd` mirrors that runtime-specific cwd in both environments.
416
+
417
+ `goke` also exports the runtime types, so helper functions can use dependency injection without reaching for globals:
418
+
419
+ ```ts
420
+ import { goke } from 'goke'
421
+ import type { GokeFs, GokeProcess } from 'goke'
422
+
423
+ async function saveAuthToken(args: {
424
+ fs: GokeFs
425
+ process: GokeProcess
426
+ token: string
427
+ }) {
428
+ await args.fs.mkdir('.mycli', { recursive: true })
429
+ await args.fs.writeFile('.mycli/auth.json', JSON.stringify({
430
+ token,
431
+ cwd: args.process.cwd,
432
+ }), 'utf8')
433
+ }
434
+
435
+ const cli = goke('mycli')
436
+
437
+ cli
438
+ .command('login <token>', 'Save auth token')
439
+ .action(async (token, options, { fs, process, console }) => {
440
+ await saveAuthToken({ fs, process, token })
441
+ console.log('saved credentials')
442
+ })
443
+ ```
444
+
359
445
  ```ts
360
446
  import { goke } from 'goke'
361
447
  import { z } from 'zod'
@@ -365,25 +451,25 @@ const cli = goke('mycli')
365
451
  cli
366
452
  .option('--verbose', z.boolean().default(false).describe('Enable verbose logging'))
367
453
  .option('--api-url [url]', z.string().default('https://api.example.com').describe('API base URL'))
368
- .use((options, { console }) => {
454
+ .use((options, { console, process }) => {
369
455
  // options.verbose and options.apiUrl are fully typed here
370
456
  if (options.verbose) {
371
- console.log('verbose mode enabled')
457
+ console.log('verbose mode enabled in', process.cwd)
372
458
  }
373
459
  })
374
460
 
375
461
  cli
376
462
  .command('deploy <env>', 'Deploy to an environment')
377
463
  .option('--dry-run', 'Preview without deploying')
378
- .action((env, options, { console }) => {
464
+ .action((env, options, { console, process }) => {
379
465
  // options includes both command options (dryRun) and global options (verbose, apiUrl)
380
- console.log(`Deploying to ${env} via ${options.apiUrl}`)
466
+ console.log(`Deploying to ${env} via ${options.apiUrl} from ${process.cwd}`)
381
467
  })
382
468
 
383
469
  cli
384
470
  .command('status', 'Show deployment status')
385
- .action((options, { console }) => {
386
- console.log('Checking status...')
471
+ .action((options, { console, process }) => {
472
+ console.log('Checking status...', process.stdin)
387
473
  })
388
474
 
389
475
  cli.help()
@@ -398,13 +484,16 @@ cli
398
484
  .use((options, { process }) => {
399
485
  options.verbose // boolean — typed
400
486
  process.argv // string[] — typed
487
+ process.cwd // string — typed
488
+ process.env // Record<string, string> — typed
489
+ process.stdin // string — typed
401
490
  options.port // TypeScript error — not declared yet
402
491
  })
403
492
  .option('--port <port>', z.number().describe('Port'))
404
- .use((options, { console }) => {
493
+ .use((options, { console, process }) => {
405
494
  options.verbose // boolean — still visible
406
495
  options.port // number — now visible
407
- console.error('ready')
496
+ console.error('ready', process.cwd)
408
497
  })
409
498
  ```
410
499
 
@@ -413,10 +502,10 @@ Middleware supports async functions. If any middleware is async, the remaining m
413
502
  ```ts
414
503
  cli
415
504
  .option('--token <token>', z.string().describe('API token'))
416
- .use(async (options, { console }) => {
505
+ .use(async (options, { console, process }) => {
417
506
  const client = await connectToApi(options.token)
418
507
  globalState.client = client
419
- console.log('connected')
508
+ console.log('connected', process.env.NODE_ENV)
420
509
  })
421
510
  ```
422
511
 
@@ -432,8 +521,8 @@ const cli = goke()
432
521
  cli
433
522
  .command('rm <dir>', 'Remove a dir')
434
523
  .option('-r, --recursive', 'Remove recursively')
435
- .action((dir, options, { console }) => {
436
- console.log('remove ' + dir + (options.recursive ? ' recursively' : ''))
524
+ .action((dir, options, { console, process }) => {
525
+ console.log('remove ' + dir + (options.recursive ? ' recursively' : ''), process.cwd)
437
526
  })
438
527
 
439
528
  cli.help()
@@ -450,18 +539,18 @@ import { goke } from 'goke'
450
539
 
451
540
  const cli = goke('mycli')
452
541
 
453
- cli.command('mcp login <url>', 'Login to MCP server').action((url, options, { console }) => {
454
- console.log('Logging in to', url)
542
+ cli.command('mcp login <url>', 'Login to MCP server').action((url, options, { console, process }) => {
543
+ console.log('Logging in to', url, 'from', process.cwd)
455
544
  })
456
545
 
457
- cli.command('mcp logout', 'Logout from MCP server').action((options, { console }) => {
458
- console.log('Logged out')
546
+ cli.command('mcp logout', 'Logout from MCP server').action((options, { console, process }) => {
547
+ console.log('Logged out', process.env.USER)
459
548
  })
460
549
 
461
550
  cli
462
551
  .command('git remote add <name> <url>', 'Add a git remote')
463
- .action((name, url, options, { console }) => {
464
- console.log('Adding remote', name, url)
552
+ .action((name, url, options, { console, process }) => {
553
+ console.log('Adding remote', name, url, 'from', process.cwd)
465
554
  })
466
555
 
467
556
  cli.help()
@@ -485,9 +574,9 @@ cli
485
574
  .option('--workers <workers>', z.int().describe('Worker count'))
486
575
  .option('--tags <tag>', z.array(z.string()).describe('Tags (repeatable)'))
487
576
  .option('--verbose', 'Verbose output')
488
- .action((options, { console }) => {
577
+ .action((options, { console, process }) => {
489
578
  // options.port is number, options.host is string, etc.
490
- console.log(options)
579
+ console.log(options, process.env.NODE_ENV)
491
580
  })
492
581
 
493
582
  cli.parse()
@@ -516,9 +605,9 @@ cli
516
605
  .option('--old-port <port>', z.number().meta({ deprecated: true, description: 'Use --port instead' }))
517
606
  // Current option: visible in help
518
607
  .option('--port <port>', z.number().describe('Port number'))
519
- .action((options, { console }) => {
608
+ .action((options, { console, process }) => {
520
609
  const port = options.port ?? options.oldPort
521
- console.log('Starting on port', port)
610
+ console.log('Starting on port', port, 'from', process.cwd)
522
611
  })
523
612
 
524
613
  cli.help()
@@ -554,10 +643,10 @@ The last argument of a command can be variadic. To make an argument variadic you
554
643
  cli
555
644
  .command('build <entry> [...otherFiles]', 'Build your app')
556
645
  .option('--foo', 'Foo option')
557
- .action((entry, otherFiles, options, { console }) => {
646
+ .action((entry, otherFiles, options, { console, process }) => {
558
647
  console.log(entry)
559
648
  console.log(otherFiles)
560
- console.log(options)
649
+ console.log(options, process.stdin)
561
650
  })
562
651
  ```
563
652
 
@@ -612,8 +701,8 @@ cli
612
701
  .command('build', 'desc')
613
702
  .option('--env <env>', 'Set envs')
614
703
  .example('--env.API_SECRET xxx')
615
- .action((options, { console }) => {
616
- console.log(options)
704
+ .action((options, { console, process }) => {
705
+ console.log(options, process.env.API_SECRET)
617
706
  })
618
707
  ```
619
708
 
@@ -625,9 +714,9 @@ Register a command that will be used when no other command is matched.
625
714
  cli
626
715
  .command('[...files]', 'Build files')
627
716
  .option('--minimize', 'Minimize output')
628
- .action((files, options, { console }) => {
717
+ .action((files, options, { console, process }) => {
629
718
  console.log(files)
630
- console.log(options.minimize)
719
+ console.log(options.minimize, process.cwd)
631
720
  })
632
721
  ```
633
722
 
@@ -648,7 +737,7 @@ try {
648
737
 
649
738
  ### Testing with mocked console and exit
650
739
 
651
- Because goke derives its injected `{ console, process }` from the CLI's configured output streams and `exit` function, tests can override them directly and assert on the calls.
740
+ Because goke derives its injected `{ fs, console, process }` from the CLI's configured runtime dependencies, tests can override them directly and assert on the calls.
652
741
 
653
742
  ```ts
654
743
  import { describe, expect, test, vi } from 'vitest'
@@ -700,11 +789,11 @@ cli
700
789
  .command('serve <entry>', 'Start the app')
701
790
  .option('--port <port>', z.number().default(3000).describe('Port number'))
702
791
  .option('--watch', 'Watch files')
703
- .action((entry, options, { console }) => {
792
+ .action((entry, options, { console, process }) => {
704
793
  // entry: string
705
794
  // options.port: number
706
795
  // options.watch: boolean
707
- console.log(entry, options.port, options.watch)
796
+ console.log(entry, options.port, options.watch, process.cwd)
708
797
  })
709
798
  ```
710
799
 
@@ -732,8 +821,8 @@ const cli = goke('parent')
732
821
  cli
733
822
  .command('child commandwithspaces', 'Run nested command')
734
823
  .option('--name <name>', z.string().describe('Name'))
735
- .action((options, { console }) => {
736
- console.log(`hello ${options.name}`)
824
+ .action((options, { console, process }) => {
825
+ console.log(`hello ${options.name} from ${process.cwd}`)
737
826
  })
738
827
 
739
828
  const bash = new Bash({
@@ -743,7 +832,79 @@ const bash = new Bash({
743
832
  await bash.exec('parent child commandwithspaces --name Tommy')
744
833
  ```
745
834
 
746
- Prefer the injected `{ console, process }` helpers in command implementations so the same command code works cleanly both in the regular CLI runtime and through the JustBash bridge.
835
+ Prefer the injected `{ fs, console, process }` helpers in command implementations so the same command code works cleanly both in the regular CLI runtime and through the JustBash bridge. The injected `fs` defaults to Node `fs/promises`, and `process.cwd` / `process.env` / `process.stdin` reflect host values in Node but sandbox values inside `createJustBashCommand()`.
836
+
837
+ ### Test with real JustBash
838
+
839
+ When a command reads or writes files, test it through real `just-bash` using the existing app CLI. Do not define the CLI inside the test body.
840
+
841
+ ```ts
842
+ import { describe, expect, test } from 'vitest'
843
+ import { Bash, InMemoryFs } from 'just-bash'
844
+ import { cli } from '../src/cli'
845
+
846
+ describe('login command', () => {
847
+ test('writes auth state through the sandbox fs', async () => {
848
+ const virtualFs = new InMemoryFs()
849
+ await virtualFs.mkdir('/project', { recursive: true })
850
+
851
+ const bash = new Bash({
852
+ fs: virtualFs,
853
+ cwd: '/project',
854
+ customCommands: [await cli.createJustBashCommand()],
855
+ })
856
+
857
+ const result = await bash.exec('parent login --token Tommy')
858
+
859
+ expect(result.stdout).toBe('saved credentials\n')
860
+ expect(await virtualFs.readFile('/project/.mycli/auth.json', 'utf8')).toBe(
861
+ '{"token":"Tommy","cwd":"/project"}',
862
+ )
863
+ })
864
+ })
865
+ ```
866
+
867
+ This is the recommended compatibility test whenever a CLI touches storage: run the same CLI once in normal tests and once through real `just-bash`.
868
+
869
+ ### Exposing your CLI as a skill
870
+
871
+ If you build a CLI with goke, keep the skill minimal and point agents to the CLI help output. Put detailed usage in the CLI code and README, not in a duplicated skill file.
872
+
873
+ ````markdown
874
+ <!-- skills/acme/SKILL.md -->
875
+ ---
876
+ name: acme
877
+ description: >
878
+ acme is a deployment CLI. Always run `acme --help` before using it
879
+ to discover available commands, options, and usage examples.
880
+ ---
881
+
882
+ # acme
883
+
884
+ Always run `acme --help` before using this CLI.
885
+ For subcommand details: `acme <command> --help`
886
+ ````
887
+
888
+ ## Contributor Notes
889
+
890
+ ### Rules
891
+
892
+ 1. Use schema-based options for typed values.
893
+ 2. Do not repeat defaults in `.describe(...)` when using `.default()`.
894
+ 3. Do not manually type action callback arguments; let goke infer them.
895
+ 4. Prefer injected `{ fs, console, process }` over global `console`, `process.exit`, or direct `node:fs/promises` imports.
896
+ 5. Use implicit cwd with injected `fs` for CLI storage. When a helper needs current-cwd semantics, pass `process.cwd` from the injected context into that helper.
897
+ 6. Define the CLI in app code and import that same CLI in tests; do not construct a separate CLI inside compatibility tests.
898
+
899
+ ### Version
900
+
901
+ Import `package.json` and use its version field so the CLI stays in sync automatically:
902
+
903
+ ```ts
904
+ import pkg from './package.json' with { type: 'json' }
905
+
906
+ cli.version(pkg.version)
907
+ ```
747
908
 
748
909
  ## References
749
910
 
@@ -780,9 +941,9 @@ Add a global option. The second argument is either:
780
941
 
781
942
  #### cli.use(callback)
782
943
 
783
- - Type: `(callback: (options: Opts, { console, process }) => void | Promise<void>) => CLI`
944
+ - Type: `(callback: (options: Opts, { fs, console, process }) => void | Promise<void>) => CLI`
784
945
 
785
- Register a middleware function that runs before the matched command action. Middleware runs in registration order, after option parsing and validation. The callback receives the parsed global options, typed according to all `.option()` calls that precede the `.use()` in the chain, plus an injected `{ console, process }` helper object.
946
+ Register a middleware function that runs before the matched command action. Middleware runs in registration order, after option parsing and validation. The callback receives the parsed global options, typed according to all `.option()` calls that precede the `.use()` in the chain, plus an injected `{ fs, console, process }` helper object.
786
947
 
787
948
  #### cli.parse(argv?)
788
949
 
@@ -802,6 +963,26 @@ Register a middleware function that runs before the matched command action. Midd
802
963
 
803
964
  Print the help message to stdout.
804
965
 
966
+ #### cli.clone(options?)
967
+
968
+ - Type: `(options?: GokeOptions) => Goke`
969
+
970
+ Create a deep copy of the CLI instance with all commands, options, middleware, and event listeners. Override any `GokeOptions` (stdout, stderr, cwd, env, fs, argv, columns, exit) in the clone without affecting the original. Primarily useful in tests to run multiple isolated parses from the same CLI definition:
971
+
972
+ ```ts
973
+ const cli = goke('mycli')
974
+ cli.command('build', 'Build project').action((options, { console }) => {
975
+ console.log('building')
976
+ })
977
+ cli.help()
978
+
979
+ // In tests: override streams without touching the original CLI
980
+ const stdout = { write: vi.fn<(data: string) => void>() }
981
+ const isolated = cli.clone({ stdout })
982
+ isolated.parse(['node', 'mycli', 'build'])
983
+ expect(stdout.write).toHaveBeenCalledWith('building\n')
984
+ ```
985
+
805
986
  #### cli.helpText()
806
987
 
807
988
  - Type: `() => string`
@@ -836,7 +1017,7 @@ Basically the same as `cli.option` but this adds the option to specific command.
836
1017
 
837
1018
  - Type: `(callback: ActionCallback) => Command`
838
1019
 
839
- Command callbacks receive positional args first, then parsed options, then an injected `{ console, process }` object. Prefer those injected helpers over global `console` and `process.exit` so commands stay easier to test and can run inside alternate runtimes like JustBash.
1020
+ Command callbacks receive positional args first, then parsed options, then an injected `{ fs, console, process }` object. Prefer those injected helpers over global `console`, `process.exit`, and direct `node:fs/promises` imports so commands stay easier to test and can run inside alternate runtimes like JustBash.
840
1021
 
841
1022
  #### command.alias(name)
842
1023
 
@@ -846,6 +1027,21 @@ Command callbacks receive positional args first, then parsed options, then an in
846
1027
 
847
1028
  - Type: `() => Command`
848
1029
 
1030
+ #### command.hidden()
1031
+
1032
+ - Type: `() => Command`
1033
+
1034
+ Hide a command from the help output listing. The command still matches and runs when invoked directly — only the help display is suppressed. Useful for internal, deprecated, or experimental commands you don't want to advertise.
1035
+
1036
+ ```ts
1037
+ cli
1038
+ .command('internal-reset', 'Reset internal state')
1039
+ .hidden()
1040
+ .action((options, { console }) => {
1041
+ console.log('reset done')
1042
+ })
1043
+ ```
1044
+
849
1045
  #### command.example(example)
850
1046
 
851
1047
  - Type: `(example: CommandExample) => Command`
@@ -854,6 +1050,12 @@ Command callbacks receive positional args first, then parsed options, then an in
854
1050
 
855
1051
  - Type: `(text: string) => Command`
856
1052
 
1053
+ #### command.helpText()
1054
+
1055
+ - Type: `() => string`
1056
+
1057
+ Return the formatted help string for this specific command without printing it. Useful for tests or embedding help text programmatically.
1058
+
857
1059
  ### Events
858
1060
 
859
1061
  Listen to commands:
@@ -2,6 +2,9 @@ import { describe, test, expect } from 'vitest';
2
2
  import goke, { createConsole } from '../index.js';
3
3
  import { coerceBySchema } from '../coerce.js';
4
4
  import { z } from 'zod';
5
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
5
8
  const ANSI_RE = /\x1B\[[0-9;]*m/g;
6
9
  const stripAnsi = (text) => text.replace(ANSI_RE, '');
7
10
  /**
@@ -129,6 +132,83 @@ describe('error formatting', () => {
129
132
  expect(text).toMatch(/at /);
130
133
  });
131
134
  });
135
+ describe('injected fs', () => {
136
+ test('command actions can use the default node fs for cli storage', async () => {
137
+ const stdout = createTestOutputStream();
138
+ const cli = gokeTestable('mycli', { stdout });
139
+ const originalCwd = process.cwd();
140
+ const tempDir = await mkdtemp(join(tmpdir(), 'goke-fs-'));
141
+ try {
142
+ process.chdir(tempDir);
143
+ cli
144
+ .command('login', 'Persist login state')
145
+ .option('--token <token>', z.string().describe('Token'))
146
+ .action(async (options, { fs, console }) => {
147
+ await fs.mkdir('.mycli', { recursive: true });
148
+ await fs.writeFile('.mycli/auth.json', JSON.stringify({ token: options.token }), 'utf8');
149
+ console.log('saved credentials');
150
+ });
151
+ cli.parse(['node', 'bin', 'login', '--token', 'abc123'], { run: false });
152
+ await cli.runMatchedCommand();
153
+ expect(stdout.text).toBe('saved credentials\n');
154
+ expect(await readFile(join(tempDir, '.mycli/auth.json'), 'utf8')).toBe('{"token":"abc123"}');
155
+ }
156
+ finally {
157
+ process.chdir(originalCwd);
158
+ await rm(tempDir, { recursive: true, force: true });
159
+ }
160
+ });
161
+ });
162
+ describe('injected process context', () => {
163
+ test('command actions receive host cwd, env, and stdin defaults', async () => {
164
+ const stdout = createTestOutputStream();
165
+ const cli = gokeTestable('mycli', { stdout });
166
+ const originalCwd = process.cwd();
167
+ const originalEnv = process.env.GOKE_TEST_TOKEN;
168
+ const tempDir = await mkdtemp(join(tmpdir(), 'goke-process-'));
169
+ try {
170
+ process.chdir(tempDir);
171
+ process.env.GOKE_TEST_TOKEN = 'abc123';
172
+ cli
173
+ .command('context', 'Inspect process context')
174
+ .action((options, { console, process }) => {
175
+ console.log(JSON.stringify({
176
+ cwd: process.cwd,
177
+ stdin: process.stdin,
178
+ token: process.env.GOKE_TEST_TOKEN,
179
+ }));
180
+ });
181
+ cli.parse(['node', 'bin', 'context'], { run: false });
182
+ await cli.runMatchedCommand();
183
+ expect(stdout.text).toBe(`${JSON.stringify({ cwd: process.cwd(), stdin: '', token: 'abc123' })}\n`);
184
+ }
185
+ finally {
186
+ process.chdir(originalCwd);
187
+ if (originalEnv === undefined) {
188
+ delete process.env.GOKE_TEST_TOKEN;
189
+ }
190
+ else {
191
+ process.env.GOKE_TEST_TOKEN = originalEnv;
192
+ }
193
+ await rm(tempDir, { recursive: true, force: true });
194
+ }
195
+ });
196
+ test('custom injected env stays mutable inside command actions', async () => {
197
+ const stdout = createTestOutputStream();
198
+ const env = { TOKEN: 'before' };
199
+ const cli = gokeTestable('mycli', { env, stdout });
200
+ cli
201
+ .command('context', 'Mutate process env')
202
+ .action((options, { console, process }) => {
203
+ process.env.TOKEN = 'after';
204
+ console.log(process.env.TOKEN);
205
+ });
206
+ cli.parse(['node', 'bin', 'context'], { run: false });
207
+ await cli.runMatchedCommand();
208
+ expect(stdout.text).toBe('after\n');
209
+ expect(env.TOKEN).toBe('after');
210
+ });
211
+ });
132
212
  test('double dashes', () => {
133
213
  const cli = goke();
134
214
  const { args, options } = cli.parse([