goke 6.5.0 → 6.5.2

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
@@ -2,22 +2,52 @@
2
2
  <br/>
3
3
  <br/>
4
4
  <h3>goke</h3>
5
- <p>simple, type safe, elegant command line framework. CAC replacement</p>
5
+ <p>Build CLIs like you'd build an API. Type-safe, chainable, zero dependencies.</p>
6
6
  <br/>
7
7
  <br/>
8
8
  </div>
9
9
 
10
+ goke is a TypeScript CLI framework with a [Hono](https://hono.dev)-like API. You chain `.use()` for middleware and `.command()` for routes — the same mental model as a REST API, applied to the terminal.
11
+
12
+ ```ts
13
+ import { goke } from 'goke'
14
+ import { z } from 'zod'
15
+
16
+ const cli = goke('deploy')
17
+
18
+ // middleware — runs before every command
19
+ cli
20
+ .option('--env <env>', z.enum(['staging', 'production']).default('staging').describe('Target environment'))
21
+ .use((options, { console }) => {
22
+ console.log(`Environment: ${options.env}`)
23
+ })
24
+
25
+ // commands — like route handlers
26
+ cli
27
+ .command('up', 'Deploy the app')
28
+ .option('--dry-run', 'Preview without deploying')
29
+ .action((options, { console, process }) => {
30
+ console.log(`Deploying from ${process.cwd}`)
31
+ })
32
+
33
+ cli
34
+ .command('logs <deploymentId>', 'Stream logs')
35
+ .option('--lines <n>', z.number().default(100).describe('Lines to tail'))
36
+ .action((id, options) => streamLogs(id, options.lines))
37
+
38
+ cli.help()
39
+ cli.parse()
40
+ ```
10
41
 
11
42
  ## Features
12
43
 
13
- - **Super light-weight**: No dependency, just a single file.
14
- - **Easy to learn**. There are only 5 APIs you need to learn for building simple CLIs: `cli.option` `cli.use` `cli.version` `cli.help` `cli.parse`.
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
- - **Space-separated subcommands**: Support multi-word commands like `mcp login`, `git remote add`.
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 `{ fs, console, process }` in actions and middleware for portable storage, output, and runtime metadata across Node.js, tests, and JustBash.
19
- - **Type-safe middleware**: Register `.use()` callbacks that run before commands with full type inference from global options.
20
- - **Developer friendly**. Written in TypeScript.
44
+ - **Hono-like chaining** — `.use()` for middleware, `.command()` for handlers. Build a CLI the same way you'd design a REST API.
45
+ - **Zod type safety** — pass a Zod schema to `.option()` and get automatic coercion, TypeScript inference, and help text for free. Works with Valibot, ArkType, or any Standard Schema library.
46
+ - **MCP server in 2 lines** expose your entire CLI as an MCP server with `createMcpAction({ cli })`. Every command becomes a tool, ready for Claude Desktop, Cursor, VS Code, and [any MCP client](https://github.com/supermemoryai/install-mcp#supported-clients).
47
+ - **JustBash support** `cli.createJustBashCommand()` exposes your CLI as a sandboxed JustBash command. Same action code, no changes needed.
48
+ - **Space-separated subcommands** `git remote add`, `mcp login`, `db migrate` multi-word commands work out of the box.
49
+ - **Injected `{ fs, console, process }`** commands receive a portable runtime context. Swap it in tests, or let JustBash replace it with a sandbox. No global side effects.
50
+ - **Zero dependencies** single file, no runtime deps.
21
51
 
22
52
  ## Install
23
53
 
@@ -25,6 +55,14 @@
25
55
  npm install goke
26
56
  ```
27
57
 
58
+ ## Install skill for AI agents
59
+
60
+ ```bash
61
+ npx -y skills add remorses/goke
62
+ ```
63
+
64
+ This installs the repository skill for AI coding agents. In this repo the shipped skill lives at `skills/goke/SKILL.md`.
65
+
28
66
  ## Usage
29
67
 
30
68
  ### Simple Parsing
@@ -390,6 +428,22 @@ cli
390
428
 
391
429
  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.
392
430
 
431
+ ### Path handling
432
+
433
+ 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.
434
+
435
+ ```ts
436
+ await fs.mkdir('.mycli', { recursive: true })
437
+ await fs.writeFile('.mycli/auth.json', json, 'utf8')
438
+ console.log('running from', process.cwd)
439
+ ```
440
+
441
+ Why this works:
442
+
443
+ - In normal Node.js runs, relative paths resolve against the host cwd.
444
+ - In JustBash runs, the same relative paths resolve against the sandbox cwd.
445
+ - `process.cwd` mirrors that runtime-specific cwd in both environments.
446
+
393
447
  `goke` also exports the runtime types, so helper functions can use dependency injection without reaching for globals:
394
448
 
395
449
  ```ts
@@ -810,6 +864,78 @@ await bash.exec('parent child commandwithspaces --name Tommy')
810
864
 
811
865
  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()`.
812
866
 
867
+ ### Test with real JustBash
868
+
869
+ 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.
870
+
871
+ ```ts
872
+ import { describe, expect, test } from 'vitest'
873
+ import { Bash, InMemoryFs } from 'just-bash'
874
+ import { cli } from '../src/cli'
875
+
876
+ describe('login command', () => {
877
+ test('writes auth state through the sandbox fs', async () => {
878
+ const virtualFs = new InMemoryFs()
879
+ await virtualFs.mkdir('/project', { recursive: true })
880
+
881
+ const bash = new Bash({
882
+ fs: virtualFs,
883
+ cwd: '/project',
884
+ customCommands: [await cli.createJustBashCommand()],
885
+ })
886
+
887
+ const result = await bash.exec('parent login --token Tommy')
888
+
889
+ expect(result.stdout).toBe('saved credentials\n')
890
+ expect(await virtualFs.readFile('/project/.mycli/auth.json', 'utf8')).toBe(
891
+ '{"token":"Tommy","cwd":"/project"}',
892
+ )
893
+ })
894
+ })
895
+ ```
896
+
897
+ 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`.
898
+
899
+ ### Exposing your CLI as a skill
900
+
901
+ 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.
902
+
903
+ ````markdown
904
+ <!-- skills/acme/SKILL.md -->
905
+ ---
906
+ name: acme
907
+ description: >
908
+ acme is a deployment CLI. Always run `acme --help` before using it
909
+ to discover available commands, options, and usage examples.
910
+ ---
911
+
912
+ # acme
913
+
914
+ Always run `acme --help` before using this CLI.
915
+ For subcommand details: `acme <command> --help`
916
+ ````
917
+
918
+ ## Contributor Notes
919
+
920
+ ### Rules
921
+
922
+ 1. Use schema-based options for typed values.
923
+ 2. Do not repeat defaults in `.describe(...)` when using `.default()`.
924
+ 3. Do not manually type action callback arguments; let goke infer them.
925
+ 4. Prefer injected `{ fs, console, process }` over global `console`, `process.exit`, or direct `node:fs/promises` imports.
926
+ 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.
927
+ 6. Define the CLI in app code and import that same CLI in tests; do not construct a separate CLI inside compatibility tests.
928
+
929
+ ### Version
930
+
931
+ Import `package.json` and use its version field so the CLI stays in sync automatically:
932
+
933
+ ```ts
934
+ import pkg from './package.json' with { type: 'json' }
935
+
936
+ cli.version(pkg.version)
937
+ ```
938
+
813
939
  ## References
814
940
 
815
941
  ### CLI Instance
@@ -867,6 +993,26 @@ Register a middleware function that runs before the matched command action. Midd
867
993
 
868
994
  Print the help message to stdout.
869
995
 
996
+ #### cli.clone(options?)
997
+
998
+ - Type: `(options?: GokeOptions) => Goke`
999
+
1000
+ 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:
1001
+
1002
+ ```ts
1003
+ const cli = goke('mycli')
1004
+ cli.command('build', 'Build project').action((options, { console }) => {
1005
+ console.log('building')
1006
+ })
1007
+ cli.help()
1008
+
1009
+ // In tests: override streams without touching the original CLI
1010
+ const stdout = { write: vi.fn<(data: string) => void>() }
1011
+ const isolated = cli.clone({ stdout })
1012
+ isolated.parse(['node', 'mycli', 'build'])
1013
+ expect(stdout.write).toHaveBeenCalledWith('building\n')
1014
+ ```
1015
+
870
1016
  #### cli.helpText()
871
1017
 
872
1018
  - Type: `() => string`
@@ -911,6 +1057,21 @@ Command callbacks receive positional args first, then parsed options, then an in
911
1057
 
912
1058
  - Type: `() => Command`
913
1059
 
1060
+ #### command.hidden()
1061
+
1062
+ - Type: `() => Command`
1063
+
1064
+ 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.
1065
+
1066
+ ```ts
1067
+ cli
1068
+ .command('internal-reset', 'Reset internal state')
1069
+ .hidden()
1070
+ .action((options, { console }) => {
1071
+ console.log('reset done')
1072
+ })
1073
+ ```
1074
+
914
1075
  #### command.example(example)
915
1076
 
916
1077
  - Type: `(example: CommandExample) => Command`
@@ -919,6 +1080,12 @@ Command callbacks receive positional args first, then parsed options, then an in
919
1080
 
920
1081
  - Type: `(text: string) => Command`
921
1082
 
1083
+ #### command.helpText()
1084
+
1085
+ - Type: `() => string`
1086
+
1087
+ Return the formatted help string for this specific command without printing it. Useful for tests or embedding help text programmatically.
1088
+
922
1089
  ### Events
923
1090
 
924
1091
  Listen to commands:
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Smoke tests that keep README examples and documented APIs executable.
3
+ */
4
+ export {};
5
+ //# sourceMappingURL=readme-examples.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readme-examples.test.d.ts","sourceRoot":"","sources":["../../src/__test__/readme-examples.test.ts"],"names":[],"mappings":"AAAA;;GAEG"}
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Smoke tests that keep README examples and documented APIs executable.
3
+ */
4
+ import { describe, expect, test } from 'vitest';
5
+ import { z } from 'zod';
6
+ import goke, { openInBrowser } from '../index.js';
7
+ const ANSI_RE = /\x1B\[[0-9;]*m/g;
8
+ const stripAnsi = (text) => text.replace(ANSI_RE, '');
9
+ function createTestOutputStream() {
10
+ const lines = [];
11
+ return {
12
+ lines,
13
+ get text() { return stripAnsi(lines.join('')); },
14
+ write(data) { lines.push(data); },
15
+ };
16
+ }
17
+ function gokeTestable(name = '', options) {
18
+ return goke(name, {
19
+ ...options,
20
+ exit: () => { },
21
+ });
22
+ }
23
+ describe('README smoke tests', () => {
24
+ test('intro example runs middleware and both command forms', async () => {
25
+ const stdout = createTestOutputStream();
26
+ const cli = gokeTestable('deploy', { stdout });
27
+ cli
28
+ .option('--env <env>', z.enum(['staging', 'production']).default('staging').describe('Target environment'))
29
+ .use((options, { console }) => {
30
+ console.log(`Environment: ${options.env}`);
31
+ });
32
+ cli
33
+ .command('up', 'Deploy the app')
34
+ .option('--dry-run', 'Preview without deploying')
35
+ .action((options, { console, process }) => {
36
+ console.log(`Deploying from ${process.cwd} dryRun=${String(options.dryRun)}`);
37
+ });
38
+ cli
39
+ .command('logs <deploymentId>', 'Stream logs')
40
+ .option('--lines <n>', z.number().default(100).describe('Lines to tail'))
41
+ .action((deploymentId, options, { console }) => {
42
+ console.log(`logs ${deploymentId} ${options.lines}`);
43
+ });
44
+ cli.parse(['node', 'bin', '--env', 'production', 'up', '--dry-run'], { run: false });
45
+ await cli.runMatchedCommand();
46
+ expect(stdout.text).toBe(`Environment: production\nDeploying from ${process.cwd()} dryRun=true\n`);
47
+ stdout.lines.length = 0;
48
+ cli.parse(['node', 'bin', 'logs', 'dep_123'], { run: false });
49
+ await cli.runMatchedCommand();
50
+ expect(stdout.text).toBe('Environment: staging\nlogs dep_123 100\n');
51
+ });
52
+ test('simple parsing example stays executable and keeps examples in help output', async () => {
53
+ const stdout = createTestOutputStream();
54
+ const cli = gokeTestable('mycli', { stdout });
55
+ cli.option('--type [type]', z.string().default('node').describe('Choose a project type'));
56
+ cli.option('--name <name>', 'Provide your name');
57
+ cli.command('lint [...files]', 'Lint files').action((files, options, { console, process }) => {
58
+ console.log(JSON.stringify({ files, options, cwd: process.cwd }));
59
+ });
60
+ cli
61
+ .command('build [entry]', 'Build your app')
62
+ .option('--minify', 'Minify output')
63
+ .example('build src/index.ts')
64
+ .example('build src/index.ts --minify')
65
+ .action(async (entry, options, { console, process }) => {
66
+ console.log(JSON.stringify({ entry, options, nodeEnv: process.env.NODE_ENV }));
67
+ });
68
+ cli.example((bin) => `${bin} lint src/**/*.ts`);
69
+ cli.help();
70
+ cli.version('0.0.0');
71
+ expect(stripAnsi(cli.helpText())).toContain('mycli lint src/**/*.ts');
72
+ cli.parse(['node', 'bin', '--type', 'bun', '--name', 'Tommy', 'build', 'src/index.ts', '--minify'], { run: false });
73
+ await cli.runMatchedCommand();
74
+ expect(stdout.text).toBe(`${JSON.stringify({
75
+ entry: 'src/index.ts',
76
+ options: {
77
+ '--': [],
78
+ type: 'bun',
79
+ name: 'Tommy',
80
+ minify: true,
81
+ },
82
+ nodeEnv: process.env.NODE_ENV,
83
+ })}\n`);
84
+ });
85
+ test('many-commands README example runs root and nested commands', async () => {
86
+ const stdout = createTestOutputStream();
87
+ const cli = gokeTestable('deploy', { stdout });
88
+ cli
89
+ .command('', 'Deploy the current project')
90
+ .option('--env <env>', z.string().default('production').describe('Target environment'))
91
+ .option('--dry-run', 'Preview without deploying')
92
+ .action((options, { console, process }) => {
93
+ console.log(`Deploying to ${options.env} from ${process.cwd} dryRun=${String(options.dryRun)}`);
94
+ });
95
+ cli
96
+ .command('logs <deploymentId>', 'Stream logs for a deployment')
97
+ .option('--follow', 'Follow log output')
98
+ .option('--lines <n>', z.number().default(100).describe('Number of lines'))
99
+ .action((deploymentId, options, { console, process }) => {
100
+ console.log(`Streaming logs for ${deploymentId} from ${process.cwd} follow=${String(options.follow)} lines=${options.lines}`);
101
+ });
102
+ cli.parse(['node', 'bin', '--env', 'staging', '--dry-run'], { run: false });
103
+ await cli.runMatchedCommand();
104
+ expect(stdout.text).toBe(`Deploying to staging from ${process.cwd()} dryRun=true\n`);
105
+ stdout.lines.length = 0;
106
+ cli.parse(['node', 'bin', 'logs', 'abc123', '--follow'], { run: false });
107
+ await cli.runMatchedCommand();
108
+ expect(stdout.text).toBe(`Streaming logs for abc123 from ${process.cwd()} follow=true lines=100\n`);
109
+ });
110
+ });
111
+ describe('documented command APIs', () => {
112
+ test('alias runs the same command through a short name', () => {
113
+ const cli = gokeTestable('mycli');
114
+ let seen = '';
115
+ cli.command('install', 'Install packages').alias('i').action(() => {
116
+ seen = 'install';
117
+ });
118
+ cli.parse(['node', 'bin', 'i'], { run: true });
119
+ expect(seen).toBe('install');
120
+ });
121
+ test('command helpText returns command-specific help without printing', () => {
122
+ const stdout = createTestOutputStream();
123
+ const cli = goke('mycli', { stdout });
124
+ const command = cli
125
+ .command('deploy <env>', 'Deploy to an environment')
126
+ .option('--dry-run', 'Preview without deploying')
127
+ .example('# Deploy safely first')
128
+ .example('mycli deploy staging --dry-run');
129
+ cli.help();
130
+ const help = stripAnsi(command.helpText());
131
+ expect(help).toContain('$ mycli deploy <env>');
132
+ expect(help).toContain('--dry-run');
133
+ expect(help).toContain('Deploy safely first');
134
+ expect(stdout.text).toBe('');
135
+ });
136
+ test('openInBrowser prints the URL to stdout in non-tty environments', () => {
137
+ const url = 'https://example.com/dashboard';
138
+ const originalStdoutWrite = process.stdout.write;
139
+ const originalStderrWrite = process.stderr.write;
140
+ const originalIsTTY = process.stdout.isTTY;
141
+ let stdout = '';
142
+ let stderr = '';
143
+ Object.defineProperty(process.stdout, 'isTTY', {
144
+ configurable: true,
145
+ value: false,
146
+ });
147
+ process.stdout.write = ((chunk) => {
148
+ stdout += String(chunk);
149
+ return true;
150
+ });
151
+ process.stderr.write = ((chunk) => {
152
+ stderr += String(chunk);
153
+ return true;
154
+ });
155
+ try {
156
+ openInBrowser(url);
157
+ }
158
+ finally {
159
+ process.stdout.write = originalStdoutWrite;
160
+ process.stderr.write = originalStderrWrite;
161
+ Object.defineProperty(process.stdout, 'isTTY', {
162
+ configurable: true,
163
+ value: originalIsTTY,
164
+ });
165
+ }
166
+ expect(stdout).toBe(`${url}\n`);
167
+ expect(stderr).toBe('');
168
+ });
169
+ });
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * Type-level tests for schema-based option inference.
3
3
  * These tests verify that TypeScript infers the correct types from
4
- * option names (template literals) and StandardJSONSchemaV1 schemas.
4
+ * option names (template literals) and StandardJSONSchemaV1 schemas,
5
+ * and that `.action()` callbacks receive fully-typed positional args
6
+ * and options objects.
5
7
  *
6
8
  * These use expectTypeOf from vitest for compile-time type assertions.
7
9
  */
8
10
  import { describe, test, expectTypeOf } from 'vitest';
11
+ import { z } from 'zod';
9
12
  import goke from '../index.js';
10
13
  describe('type-level: ExtractOptionName', () => {
11
14
  test('extracts name from --name <value>', () => {
@@ -117,3 +120,237 @@ describe('type-level: middleware use() callback inference', () => {
117
120
  });
118
121
  });
119
122
  });
123
+ describe('type-level: command() .action() positional args inference', () => {
124
+ test('command with no args → action receives only (options, ctx)', () => {
125
+ goke('test')
126
+ .command('deploy', 'Deploy the app')
127
+ .action((options, ctx) => {
128
+ expectTypeOf(options).toEqualTypeOf();
129
+ expectTypeOf(ctx).toEqualTypeOf();
130
+ });
131
+ });
132
+ test('command with one required arg → action receives (arg, options, ctx)', () => {
133
+ goke('test')
134
+ .command('get <id>', 'Fetch a resource by id')
135
+ .action((id, options, ctx) => {
136
+ expectTypeOf(id).toEqualTypeOf();
137
+ expectTypeOf(options).toEqualTypeOf();
138
+ expectTypeOf(ctx).toEqualTypeOf();
139
+ });
140
+ });
141
+ test('command with two required args → action receives both as strings', () => {
142
+ goke('test')
143
+ .command('convert <input> <output>', 'Convert file formats')
144
+ .action((input, output, options) => {
145
+ expectTypeOf(input).toEqualTypeOf();
146
+ expectTypeOf(output).toEqualTypeOf();
147
+ expectTypeOf(options).toEqualTypeOf();
148
+ });
149
+ });
150
+ test('command with optional arg → arg type includes undefined', () => {
151
+ goke('test')
152
+ .command('run [script]', 'Run a script')
153
+ .action((script, options) => {
154
+ expectTypeOf(script).toEqualTypeOf();
155
+ expectTypeOf(options).toEqualTypeOf();
156
+ });
157
+ });
158
+ test('command with variadic required arg → arg is string[]', () => {
159
+ goke('test')
160
+ .command('exec <...args>', 'Run a binary with args')
161
+ .action((args, options) => {
162
+ expectTypeOf(args).toEqualTypeOf();
163
+ expectTypeOf(options).toEqualTypeOf();
164
+ });
165
+ });
166
+ test('command with variadic optional arg → arg is string[]', () => {
167
+ goke('test')
168
+ .command('run [...rest]', 'Variadic optional')
169
+ .action((rest, options) => {
170
+ expectTypeOf(rest).toEqualTypeOf();
171
+ expectTypeOf(options).toEqualTypeOf();
172
+ });
173
+ });
174
+ test('multi-word command with required arg', () => {
175
+ goke('test')
176
+ .command('mcp getNodeXml <id>', 'Get XML for a node')
177
+ .action((id, options) => {
178
+ expectTypeOf(id).toEqualTypeOf();
179
+ expectTypeOf(options).toEqualTypeOf();
180
+ });
181
+ });
182
+ test('default command with one positional arg', () => {
183
+ goke('test')
184
+ .command('<file>', 'Default command')
185
+ .action((file, options) => {
186
+ expectTypeOf(file).toEqualTypeOf();
187
+ expectTypeOf(options).toEqualTypeOf();
188
+ });
189
+ });
190
+ test('mixed required and optional positional args', () => {
191
+ goke('test')
192
+ .command('send <to> [cc]', 'Send a message')
193
+ .action((to, cc, options) => {
194
+ expectTypeOf(to).toEqualTypeOf();
195
+ expectTypeOf(cc).toEqualTypeOf();
196
+ expectTypeOf(options).toEqualTypeOf();
197
+ });
198
+ });
199
+ });
200
+ describe('type-level: command() .action() option inference', () => {
201
+ test('single schema-based option is visible on options param', () => {
202
+ goke('test')
203
+ .command('serve', 'Start server')
204
+ .option('--port <port>', z.number())
205
+ .action((options, ctx) => {
206
+ expectTypeOf(options.port).toEqualTypeOf();
207
+ expectTypeOf(ctx).toEqualTypeOf();
208
+ });
209
+ });
210
+ test('multiple schema-based options are accumulated', () => {
211
+ goke('test')
212
+ .command('serve', 'Start server')
213
+ .option('--port <port>', z.number())
214
+ .option('--host <host>', z.string())
215
+ .option('--verbose', z.boolean())
216
+ .action((options) => {
217
+ expectTypeOf(options.port).toEqualTypeOf();
218
+ expectTypeOf(options.host).toEqualTypeOf();
219
+ // Boolean flag is optional (no <...> brackets)
220
+ expectTypeOf(options.verbose).toEqualTypeOf();
221
+ });
222
+ });
223
+ test('required vs optional option shape', () => {
224
+ goke('test')
225
+ .command('cmd', 'Command')
226
+ .option('--name <name>', z.string())
227
+ .option('--count [count]', z.number())
228
+ .action((options) => {
229
+ expectTypeOf(options.name).toEqualTypeOf();
230
+ expectTypeOf(options.count).toEqualTypeOf();
231
+ });
232
+ });
233
+ test('camelCase conversion for kebab-case option names', () => {
234
+ goke('test')
235
+ .command('build', 'Build')
236
+ .option('--out-dir <dir>', z.string())
237
+ .option('--my-long-flag <val>', z.string())
238
+ .action((options) => {
239
+ expectTypeOf(options.outDir).toEqualTypeOf();
240
+ expectTypeOf(options.myLongFlag).toEqualTypeOf();
241
+ });
242
+ });
243
+ test('options combined with positional args', () => {
244
+ goke('test')
245
+ .command('convert <input> <output>', 'Convert file format')
246
+ .option('--quality <quality>', z.number())
247
+ .option('--format <format>', z.enum(['png', 'jpg', 'webp']))
248
+ .action((input, output, options, ctx) => {
249
+ expectTypeOf(input).toEqualTypeOf();
250
+ expectTypeOf(output).toEqualTypeOf();
251
+ expectTypeOf(options.quality).toEqualTypeOf();
252
+ expectTypeOf(options.format).toEqualTypeOf();
253
+ expectTypeOf(ctx).toEqualTypeOf();
254
+ });
255
+ });
256
+ test('global options from Goke are visible inside command actions', () => {
257
+ goke('test')
258
+ .option('--verbose', z.boolean())
259
+ .command('serve', 'Start server')
260
+ .option('--port <port>', z.number())
261
+ .action((options) => {
262
+ // Global option from cli.option()
263
+ expectTypeOf(options.verbose).toEqualTypeOf();
264
+ // Command-local option
265
+ expectTypeOf(options.port).toEqualTypeOf();
266
+ });
267
+ });
268
+ test('untyped option (string description) produces loose value type', () => {
269
+ goke('test')
270
+ .command('serve', 'Start server')
271
+ .option('--port <port>', 'Port number')
272
+ .action((options) => {
273
+ // Without a schema the runtime still guarantees required value options are strings.
274
+ expectTypeOf(options.port).toEqualTypeOf();
275
+ });
276
+ });
277
+ test('untyped optional value options keep raw mri sentinel shapes', () => {
278
+ goke('test')
279
+ .command('serve', 'Start server')
280
+ .option('--host [host]', 'Optional host override')
281
+ .option('--verbose', 'Verbose output')
282
+ .action((options) => {
283
+ expectTypeOf(options.host).toEqualTypeOf();
284
+ expectTypeOf(options.verbose).toEqualTypeOf();
285
+ });
286
+ });
287
+ test('accessing a non-existent option in action is a type error', () => {
288
+ goke('test')
289
+ .command('serve', 'Start server')
290
+ .option('--port <port>', z.number())
291
+ .action((options) => {
292
+ expectTypeOf(options.port).toEqualTypeOf();
293
+ // @ts-expect-error nonExistent was never declared
294
+ options.nonExistent;
295
+ });
296
+ });
297
+ test('accessing a non-existent positional arg in action is a type error', () => {
298
+ goke('test')
299
+ .command('get <id>', 'Fetch resource')
300
+ .action((id, options, ctx, ...rest) => {
301
+ expectTypeOf(id).toEqualTypeOf();
302
+ expectTypeOf(options).toEqualTypeOf();
303
+ expectTypeOf(ctx).toEqualTypeOf();
304
+ // No more positional slots — rest should be empty
305
+ expectTypeOf(rest).toEqualTypeOf();
306
+ });
307
+ });
308
+ test('action callback can omit trailing params (fewer-args is valid)', () => {
309
+ // Dropping context is fine
310
+ goke('test')
311
+ .command('serve', 'Start server')
312
+ .option('--port <port>', z.number())
313
+ .action((options) => {
314
+ expectTypeOf(options.port).toEqualTypeOf();
315
+ });
316
+ // Dropping everything is fine
317
+ goke('test')
318
+ .command('serve', 'Start server')
319
+ .option('--port <port>', z.number())
320
+ .action(() => { });
321
+ });
322
+ });
323
+ describe('type-level: README TypeScript examples', () => {
324
+ test('README TypeScript example infers positional args and typed options', () => {
325
+ goke('my-program')
326
+ .command('serve <entry>', 'Start the app')
327
+ .option('--port <port>', z.number().default(3000).describe('Port number'))
328
+ .option('--watch', 'Watch files')
329
+ .action((entry, options, { console, process }) => {
330
+ expectTypeOf(entry).toEqualTypeOf();
331
+ expectTypeOf(options.port).toEqualTypeOf();
332
+ expectTypeOf(options.watch).toEqualTypeOf();
333
+ expectTypeOf(console.log).toBeFunction();
334
+ expectTypeOf(process.cwd).toEqualTypeOf();
335
+ });
336
+ });
337
+ test('README global options and middleware example stays typed end-to-end', () => {
338
+ goke('mycli')
339
+ .option('--verbose', z.boolean().default(false).describe('Enable verbose logging'))
340
+ .option('--api-url [url]', z.string().default('https://api.example.com').describe('API base URL'))
341
+ .use((options, { process }) => {
342
+ expectTypeOf(options.verbose).toEqualTypeOf();
343
+ expectTypeOf(options.apiUrl).toEqualTypeOf();
344
+ expectTypeOf(process.stdin).toEqualTypeOf();
345
+ })
346
+ .command('deploy <env>', 'Deploy to an environment')
347
+ .option('--dry-run', 'Preview without deploying')
348
+ .action((env, options, ctx) => {
349
+ expectTypeOf(env).toEqualTypeOf();
350
+ expectTypeOf(options.verbose).toEqualTypeOf();
351
+ expectTypeOf(options.apiUrl).toEqualTypeOf();
352
+ expectTypeOf(options.dryRun).toEqualTypeOf();
353
+ expectTypeOf(ctx).toEqualTypeOf();
354
+ });
355
+ });
356
+ });