goke 6.5.1 → 6.6.0

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.
@@ -1,12 +1,16 @@
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 type { StandardTypedV1, StandardJSONSchemaV1 } from '../coerce.js'
13
+ import type { GokeExecutionContext } from '../goke.js'
10
14
  import goke from '../index.js'
11
15
 
12
16
  // ─── Import type helpers from Command.ts ───
@@ -72,6 +76,11 @@ describe('type-level: IsOptionalOption', () => {
72
76
  })
73
77
  })
74
78
 
79
+ // Every action callback's options param is extended with `{ '--': string[] }`
80
+ // because the runtime always populates that key. Use this alias everywhere
81
+ // we used to write `{}` to mean "no user-declared options".
82
+ type Base = { '--': string[] }
83
+
75
84
  describe('type-level: InferSchemaOutput', () => {
76
85
  test('infers output from StandardTypedV1', () => {
77
86
  type Schema = StandardTypedV1<unknown, number>
@@ -167,3 +176,264 @@ describe('type-level: middleware use() callback inference', () => {
167
176
  })
168
177
  })
169
178
  })
179
+
180
+ describe('type-level: command() .action() positional args inference', () => {
181
+ test('command with no args → action receives only (options, ctx)', () => {
182
+ goke('test')
183
+ .command('deploy', 'Deploy the app')
184
+ .action((options, ctx) => {
185
+ expectTypeOf(options).toEqualTypeOf<Base>()
186
+ expectTypeOf(ctx).toEqualTypeOf<GokeExecutionContext>()
187
+ })
188
+ })
189
+
190
+ test('command with one required arg → action receives (arg, options, ctx)', () => {
191
+ goke('test')
192
+ .command('get <id>', 'Fetch a resource by id')
193
+ .action((id, options, ctx) => {
194
+ expectTypeOf(id).toEqualTypeOf<string>()
195
+ expectTypeOf(options).toEqualTypeOf<Base>()
196
+ expectTypeOf(ctx).toEqualTypeOf<GokeExecutionContext>()
197
+ })
198
+ })
199
+
200
+ test('command with two required args → action receives both as strings', () => {
201
+ goke('test')
202
+ .command('convert <input> <output>', 'Convert file formats')
203
+ .action((input, output, options) => {
204
+ expectTypeOf(input).toEqualTypeOf<string>()
205
+ expectTypeOf(output).toEqualTypeOf<string>()
206
+ expectTypeOf(options).toEqualTypeOf<Base>()
207
+ })
208
+ })
209
+
210
+ test('command with optional arg → arg type includes undefined', () => {
211
+ goke('test')
212
+ .command('run [script]', 'Run a script')
213
+ .action((script, options) => {
214
+ expectTypeOf(script).toEqualTypeOf<string | undefined>()
215
+ expectTypeOf(options).toEqualTypeOf<Base>()
216
+ })
217
+ })
218
+
219
+ test('command with variadic required arg → arg is string[]', () => {
220
+ goke('test')
221
+ .command('exec <...args>', 'Run a binary with args')
222
+ .action((args, options) => {
223
+ expectTypeOf(args).toEqualTypeOf<string[]>()
224
+ expectTypeOf(options).toEqualTypeOf<Base>()
225
+ })
226
+ })
227
+
228
+ test('command with variadic optional arg → arg is string[]', () => {
229
+ goke('test')
230
+ .command('run [...rest]', 'Variadic optional')
231
+ .action((rest, options) => {
232
+ expectTypeOf(rest).toEqualTypeOf<string[]>()
233
+ expectTypeOf(options).toEqualTypeOf<Base>()
234
+ })
235
+ })
236
+
237
+ test('multi-word command with required arg', () => {
238
+ goke('test')
239
+ .command('mcp getNodeXml <id>', 'Get XML for a node')
240
+ .action((id, options) => {
241
+ expectTypeOf(id).toEqualTypeOf<string>()
242
+ expectTypeOf(options).toEqualTypeOf<Base>()
243
+ })
244
+ })
245
+
246
+ test('default command with one positional arg', () => {
247
+ goke('test')
248
+ .command('<file>', 'Default command')
249
+ .action((file, options) => {
250
+ expectTypeOf(file).toEqualTypeOf<string>()
251
+ expectTypeOf(options).toEqualTypeOf<Base>()
252
+ })
253
+ })
254
+
255
+ test('mixed required and optional positional args', () => {
256
+ goke('test')
257
+ .command('send <to> [cc]', 'Send a message')
258
+ .action((to, cc, options) => {
259
+ expectTypeOf(to).toEqualTypeOf<string>()
260
+ expectTypeOf(cc).toEqualTypeOf<string | undefined>()
261
+ expectTypeOf(options).toEqualTypeOf<Base>()
262
+ })
263
+ })
264
+ })
265
+
266
+ describe('type-level: command() .action() option inference', () => {
267
+ test('single schema-based option is visible on options param', () => {
268
+ goke('test')
269
+ .command('serve', 'Start server')
270
+ .option('--port <port>', z.number())
271
+ .action((options, ctx) => {
272
+ expectTypeOf(options.port).toEqualTypeOf<number>()
273
+ expectTypeOf(ctx).toEqualTypeOf<GokeExecutionContext>()
274
+ })
275
+ })
276
+
277
+ test('multiple schema-based options are accumulated', () => {
278
+ goke('test')
279
+ .command('serve', 'Start server')
280
+ .option('--port <port>', z.number())
281
+ .option('--host <host>', z.string())
282
+ .option('--verbose', z.boolean())
283
+ .action((options) => {
284
+ expectTypeOf(options.port).toEqualTypeOf<number>()
285
+ expectTypeOf(options.host).toEqualTypeOf<string>()
286
+ // Boolean flag is optional (no <...> brackets)
287
+ expectTypeOf(options.verbose).toEqualTypeOf<boolean | undefined>()
288
+ })
289
+ })
290
+
291
+ test('required vs optional option shape', () => {
292
+ goke('test')
293
+ .command('cmd', 'Command')
294
+ .option('--name <name>', z.string())
295
+ .option('--count [count]', z.number())
296
+ .action((options) => {
297
+ expectTypeOf(options.name).toEqualTypeOf<string>()
298
+ expectTypeOf(options.count).toEqualTypeOf<number | undefined>()
299
+ })
300
+ })
301
+
302
+ test('camelCase conversion for kebab-case option names', () => {
303
+ goke('test')
304
+ .command('build', 'Build')
305
+ .option('--out-dir <dir>', z.string())
306
+ .option('--my-long-flag <val>', z.string())
307
+ .action((options) => {
308
+ expectTypeOf(options.outDir).toEqualTypeOf<string>()
309
+ expectTypeOf(options.myLongFlag).toEqualTypeOf<string>()
310
+ })
311
+ })
312
+
313
+ test('options combined with positional args', () => {
314
+ goke('test')
315
+ .command('convert <input> <output>', 'Convert file format')
316
+ .option('--quality <quality>', z.number())
317
+ .option('--format <format>', z.enum(['png', 'jpg', 'webp']))
318
+ .action((input, output, options, ctx) => {
319
+ expectTypeOf(input).toEqualTypeOf<string>()
320
+ expectTypeOf(output).toEqualTypeOf<string>()
321
+ expectTypeOf(options.quality).toEqualTypeOf<number>()
322
+ expectTypeOf(options.format).toEqualTypeOf<'png' | 'jpg' | 'webp'>()
323
+ expectTypeOf(ctx).toEqualTypeOf<GokeExecutionContext>()
324
+ })
325
+ })
326
+
327
+ test('global options from Goke are visible inside command actions', () => {
328
+ goke('test')
329
+ .option('--verbose', z.boolean())
330
+ .command('serve', 'Start server')
331
+ .option('--port <port>', z.number())
332
+ .action((options) => {
333
+ // Global option from cli.option()
334
+ expectTypeOf(options.verbose).toEqualTypeOf<boolean | undefined>()
335
+ // Command-local option
336
+ expectTypeOf(options.port).toEqualTypeOf<number>()
337
+ })
338
+ })
339
+
340
+ test('untyped option (string description) produces loose value type', () => {
341
+ goke('test')
342
+ .command('serve', 'Start server')
343
+ .option('--port <port>', 'Port number')
344
+ .action((options) => {
345
+ // Without a schema the runtime still guarantees required value options are strings.
346
+ expectTypeOf(options.port).toEqualTypeOf<string>()
347
+ })
348
+ })
349
+
350
+ test('untyped optional value options surface as string | undefined', () => {
351
+ goke('test')
352
+ .command('serve', 'Start server')
353
+ .option('--host [host]', 'Optional host override')
354
+ .option('--verbose', 'Verbose output')
355
+ .action((options) => {
356
+ // `[value]` options always resolve to `string | undefined`:
357
+ // - omitted → undefined
358
+ // - `--host` → '' (flag present, no value)
359
+ // - `--host example` → 'example'
360
+ expectTypeOf(options.host).toEqualTypeOf<string | undefined>()
361
+ expectTypeOf(options.verbose).toEqualTypeOf<boolean | undefined>()
362
+ })
363
+ })
364
+
365
+ test('accessing a non-existent option in action is a type error', () => {
366
+ goke('test')
367
+ .command('serve', 'Start server')
368
+ .option('--port <port>', z.number())
369
+ .action((options) => {
370
+ expectTypeOf(options.port).toEqualTypeOf<number>()
371
+ // @ts-expect-error nonExistent was never declared
372
+ options.nonExistent
373
+ })
374
+ })
375
+
376
+ test('accessing a non-existent positional arg in action is a type error', () => {
377
+ goke('test')
378
+ .command('get <id>', 'Fetch resource')
379
+ .action((id, options, ctx, ...rest) => {
380
+ expectTypeOf(id).toEqualTypeOf<string>()
381
+ expectTypeOf(options).toEqualTypeOf<Base>()
382
+ expectTypeOf(ctx).toEqualTypeOf<GokeExecutionContext>()
383
+ // No more positional slots — rest should be empty
384
+ expectTypeOf(rest).toEqualTypeOf<[]>()
385
+ })
386
+ })
387
+
388
+ test('action callback can omit trailing params (fewer-args is valid)', () => {
389
+ // Dropping context is fine
390
+ goke('test')
391
+ .command('serve', 'Start server')
392
+ .option('--port <port>', z.number())
393
+ .action((options) => {
394
+ expectTypeOf(options.port).toEqualTypeOf<number>()
395
+ })
396
+
397
+ // Dropping everything is fine
398
+ goke('test')
399
+ .command('serve', 'Start server')
400
+ .option('--port <port>', z.number())
401
+ .action(() => {})
402
+ })
403
+ })
404
+
405
+ describe('type-level: README TypeScript examples', () => {
406
+ test('README TypeScript example infers positional args and typed options', () => {
407
+ goke('my-program')
408
+ .command('serve <entry>', 'Start the app')
409
+ .option('--port <port>', z.number().default(3000).describe('Port number'))
410
+ .option('--watch', 'Watch files')
411
+ .action((entry, options, { console, process }) => {
412
+ expectTypeOf(entry).toEqualTypeOf<string>()
413
+ expectTypeOf(options.port).toEqualTypeOf<number>()
414
+ expectTypeOf(options.watch).toEqualTypeOf<boolean | undefined>()
415
+ expectTypeOf(console.log).toBeFunction()
416
+ expectTypeOf(process.cwd).toEqualTypeOf<string>()
417
+ })
418
+ })
419
+
420
+ test('README global options and middleware example stays typed end-to-end', () => {
421
+ goke('mycli')
422
+ .option('--verbose', z.boolean().default(false).describe('Enable verbose logging'))
423
+ .option('--api-url [url]', z.string().default('https://api.example.com').describe('API base URL'))
424
+ .use((options, { process }) => {
425
+ expectTypeOf(options.verbose).toEqualTypeOf<boolean | undefined>()
426
+ expectTypeOf(options.apiUrl).toEqualTypeOf<string | undefined>()
427
+ expectTypeOf(process.stdin).toEqualTypeOf<string>()
428
+ })
429
+ .command('deploy <env>', 'Deploy to an environment')
430
+ .option('--dry-run', 'Preview without deploying')
431
+ .action((env, options, ctx) => {
432
+ expectTypeOf(env).toEqualTypeOf<string>()
433
+ expectTypeOf(options.verbose).toEqualTypeOf<boolean | undefined>()
434
+ expectTypeOf(options.apiUrl).toEqualTypeOf<string | undefined>()
435
+ expectTypeOf(options.dryRun).toEqualTypeOf<boolean | undefined>()
436
+ expectTypeOf(ctx).toEqualTypeOf<GokeExecutionContext>()
437
+ })
438
+ })
439
+ })
package/src/goke.ts CHANGED
@@ -348,6 +348,111 @@ type OptionEntry<RawName extends string, Schema> =
348
348
  ? { [K in ExtractOptionName<RawName>]?: InferSchemaOutput<Schema> }
349
349
  : { [K in ExtractOptionName<RawName>]: InferSchemaOutput<Schema> }
350
350
 
351
+ /**
352
+ * Infer the raw runtime value shape for an option declared without a schema.
353
+ *
354
+ * Required value options (`--port <port>`) always reach actions as strings.
355
+ * Optional value options (`--host [host]`) reach actions as strings: the
356
+ * empty string `''` when the flag is passed bare (`--host`), the given
357
+ * value when passed with one (`--host example.com`), and `undefined` when
358
+ * the flag is omitted entirely. This lets callers use a single `typeof`
359
+ * check and, if they really care, distinguish "omitted" from "present but
360
+ * empty" via `=== undefined` vs `=== ''`.
361
+ * Plain flags (`--verbose`) are booleans.
362
+ */
363
+ type UntypedOptionValue<RawName extends string> =
364
+ RawName extends `${string}<${string}>` ? string :
365
+ RawName extends `${string}[${string}]` ? string :
366
+ boolean | undefined
367
+
368
+ /**
369
+ * Build the option type entry for a `.option()` call that uses a plain
370
+ * description (no schema).
371
+ */
372
+ type UntypedOptionEntry<RawName extends string> =
373
+ RawName extends `${string}<${string}>`
374
+ ? { [K in ExtractOptionName<RawName>]: UntypedOptionValue<RawName> }
375
+ : { [K in ExtractOptionName<RawName>]?: UntypedOptionValue<RawName> }
376
+
377
+ /**
378
+ * Tokenize a command raw name by splitting on whitespace.
379
+ * "mcp getNodeXml <id>" → ["mcp", "getNodeXml", "<id>"]
380
+ * "" → []
381
+ */
382
+ type TokenizeName<S extends string, Acc extends readonly string[] = []> =
383
+ S extends `${infer Head} ${infer Rest}`
384
+ ? TokenizeName<Rest, [...Acc, Head]>
385
+ : S extends ''
386
+ ? Acc
387
+ : [...Acc, S]
388
+
389
+ /**
390
+ * Given a single token, return the corresponding positional arg type or
391
+ * `never` if the token is not a bracketed arg.
392
+ *
393
+ * `<id>` → string (required)
394
+ * `[id]` → string | undefined (optional)
395
+ * `<...files>` → string[] (variadic required)
396
+ * `[...files]` → string[] (variadic optional)
397
+ * Anything else → never (filtered out by ExtractCommandArgs)
398
+ */
399
+ type TokenToArgType<T extends string> =
400
+ T extends `<...${string}>` ? string[] :
401
+ T extends `[...${string}]` ? string[] :
402
+ T extends `<${string}>` ? string :
403
+ T extends `[${string}]` ? string | undefined :
404
+ never
405
+
406
+ /**
407
+ * Filter a tokenized command raw name down to the positional arg tokens
408
+ * and map each to its inferred type.
409
+ */
410
+ type ExtractCommandArgs<T extends readonly string[]> =
411
+ T extends readonly [infer Head extends string, ...infer Tail extends string[]]
412
+ ? [TokenToArgType<Head>] extends [never]
413
+ ? ExtractCommandArgs<Tail>
414
+ : [TokenToArgType<Head>, ...ExtractCommandArgs<Tail>]
415
+ : []
416
+
417
+ /**
418
+ * Extract the tuple of positional arg types from a command raw name.
419
+ *
420
+ * "mcp getNodeXml <id>" → [string]
421
+ * "convert <input> <output>" → [string, string]
422
+ * "run [script]" → [string | undefined]
423
+ * "exec [...args]" → [string[]]
424
+ * "deploy" → []
425
+ */
426
+ type ExtractPositionalArgs<RawName extends string> =
427
+ ExtractCommandArgs<TokenizeName<RawName>>
428
+
429
+ /**
430
+ * Everything after a literal `--` on the command line is collected into
431
+ * `options['--']` as a string array (empty when `--` is absent). This key
432
+ * is always present at runtime, so it's merged into every action's options
433
+ * type regardless of which options the user declared.
434
+ */
435
+ type DoubleDashOptions = { '--': string[] }
436
+
437
+ /**
438
+ * Build the full argument tuple passed to a command's action callback.
439
+ *
440
+ * Format: [...positionalArgs, options, executionContext]
441
+ *
442
+ * This matches the runtime behavior in Goke.runMatchedCommand(): the action
443
+ * is called with positional args from the parsed command, then the parsed
444
+ * options object, then the injected GokeExecutionContext.
445
+ *
446
+ * The options type is always extended with `{ '--': string[] }` because the
447
+ * parser always populates that key (see `Goke.parse()`).
448
+ */
449
+ type ActionArgs<RawName extends string, Opts> =
450
+ [
451
+ ...ExtractPositionalArgs<RawName>,
452
+ Opts & DoubleDashOptions,
453
+ GokeExecutionContext,
454
+ ]
455
+
351
456
  interface CommandArg {
352
457
  required: boolean
353
458
  value: string
@@ -368,7 +473,7 @@ type HelpCallback = (sections: HelpSection[]) => void | HelpSection[]
368
473
 
369
474
  type CommandExample = ((bin: string) => string) | string
370
475
 
371
- class Command {
476
+ class Command<RawName extends string = string, Opts = {}> {
372
477
  options: Option[]
373
478
  aliasNames: string[]
374
479
  /* Parsed command name */
@@ -383,7 +488,7 @@ class Command {
383
488
  _hidden?: boolean
384
489
 
385
490
  constructor(
386
- public rawName: string,
491
+ public rawName: RawName,
387
492
  public description: string,
388
493
  public config: CommandConfig = {},
389
494
  public cli: Goke<any>
@@ -426,7 +531,9 @@ class Command {
426
531
  *
427
532
  * The second argument is either a description string or a StandardJSONSchemaV1
428
533
  * schema. When a schema is provided, description and default are extracted from
429
- * the JSON Schema automatically.
534
+ * the JSON Schema automatically, and the option's type is tracked on the
535
+ * Command's `Opts` type parameter so that subsequent `.action()` callbacks
536
+ * receive a fully-typed options object.
430
537
  *
431
538
  * @example
432
539
  * ```ts
@@ -438,10 +545,16 @@ class Command {
438
545
  * ```
439
546
  */
440
547
  option<
441
- RawName extends string,
548
+ OptionRawName extends string,
442
549
  S extends StandardJSONSchemaV1
443
- >(rawName: RawName, schema: S): Command & { __opts: OptionEntry<RawName, S> }
444
- option(rawName: string, descriptionOrSchema?: string | StandardJSONSchemaV1): this
550
+ >(
551
+ rawName: OptionRawName,
552
+ schema: S,
553
+ ): Command<RawName, Opts & OptionEntry<OptionRawName, S>>
554
+ option<OptionRawName extends string>(
555
+ rawName: OptionRawName,
556
+ description?: string,
557
+ ): Command<RawName, Opts & UntypedOptionEntry<OptionRawName>>
445
558
  option(rawName: string, descriptionOrSchema?: string | StandardJSONSchemaV1): any {
446
559
  const option = new Option(rawName, descriptionOrSchema)
447
560
  this.options.push(option)
@@ -458,7 +571,24 @@ class Command {
458
571
  return this
459
572
  }
460
573
 
461
- action(callback: (...args: any[]) => any) {
574
+ /**
575
+ * Register the action callback that runs when this command is matched.
576
+ *
577
+ * The callback receives positional args extracted from the command's raw name,
578
+ * followed by the parsed options object and the injected GokeExecutionContext.
579
+ *
580
+ * Positional arg types are inferred from the raw name at the type level:
581
+ * `command('convert <input> <output>')` → `(input: string, output: string, options, ctx)`
582
+ * `command('run [script]')` → `(script: string | undefined, options, ctx)`
583
+ * `command('exec [...args]')` → `(args: string[], options, ctx)`
584
+ *
585
+ * The options object is typed according to every `.option()` call chained
586
+ * on this command, plus any global options declared on the parent Goke
587
+ * instance before `.command()` was called.
588
+ */
589
+ action(
590
+ callback: (...args: ActionArgs<RawName, Opts>) => unknown | Promise<unknown>,
591
+ ): this {
462
592
  this.commandAction = callback
463
593
  return this
464
594
  }
@@ -919,14 +1049,14 @@ interface ParsedArgv {
919
1049
  }
920
1050
  }
921
1051
 
922
- class Goke<Opts extends Record<string, any> = {}> extends EventEmitter {
1052
+ class Goke<Opts = {}> extends EventEmitter {
923
1053
  /** The program name to display in help and version message */
924
1054
  name: string
925
- commands: Command[]
1055
+ commands: Command<any, any>[]
926
1056
  /** Middleware functions that run before the matched command action, in registration order */
927
1057
  middlewares: Array<{ action: (options: any, context: GokeExecutionContext) => void | Promise<void> }>
928
1058
  globalCommand: GlobalCommand
929
- matchedCommand?: Command
1059
+ matchedCommand?: Command<any, any>
930
1060
  matchedCommandName?: string
931
1061
  /**
932
1062
  * Raw CLI arguments
@@ -1056,10 +1186,24 @@ class Goke<Opts extends Record<string, any> = {}> extends EventEmitter {
1056
1186
  }
1057
1187
 
1058
1188
  /**
1059
- * Add a sub-command
1189
+ * Add a sub-command.
1190
+ *
1191
+ * The returned Command is parameterized by the literal `rawName` (so positional
1192
+ * args can be inferred at the type level) and by this Goke's accumulated global
1193
+ * `Opts` (so global options declared before `.command()` are visible inside
1194
+ * `.action()` callbacks alongside the command's own options).
1060
1195
  */
1061
- command(rawName: string, description?: string, config?: CommandConfig) {
1062
- const command = new Command(rawName, description || '', config, this)
1196
+ command<CommandRawName extends string>(
1197
+ rawName: CommandRawName,
1198
+ description?: string,
1199
+ config?: CommandConfig,
1200
+ ): Command<CommandRawName, Opts> {
1201
+ const command = new Command<CommandRawName, Opts>(
1202
+ rawName,
1203
+ description || '',
1204
+ config,
1205
+ this,
1206
+ )
1063
1207
  command.globalCommand = this.globalCommand
1064
1208
  this.commands.push(command)
1065
1209
  return command
@@ -1071,13 +1215,21 @@ class Goke<Opts extends Record<string, any> = {}> extends EventEmitter {
1071
1215
  * Which is also applied to sub-commands.
1072
1216
  *
1073
1217
  * When a StandardJSONSchemaV1 schema is provided, the return type is narrowed
1074
- * to include the inferred option type — enabling type-safe `.use()` callbacks.
1218
+ * to include the inferred option type — enabling type-safe `.use()` callbacks
1219
+ * and typed `options` params inside command `.action()` handlers.
1220
+ *
1221
+ * When a plain description string is provided, the option is still tracked on
1222
+ * the Goke's `Opts` type, but with a loose `string | boolean | undefined` value
1223
+ * type (since no coercion schema is available).
1075
1224
  */
1076
1225
  option<
1077
1226
  RawName extends string,
1078
1227
  S extends StandardJSONSchemaV1
1079
1228
  >(rawName: RawName, schema: S): Goke<Opts & OptionEntry<RawName, S>>
1080
- option(rawName: string, descriptionOrSchema?: string | StandardJSONSchemaV1): this
1229
+ option<RawName extends string>(
1230
+ rawName: RawName,
1231
+ description?: string,
1232
+ ): Goke<Opts & UntypedOptionEntry<RawName>>
1081
1233
  option(rawName: string, descriptionOrSchema?: string | StandardJSONSchemaV1): any {
1082
1234
  const option = new Option(rawName, descriptionOrSchema)
1083
1235
  this.globalCommand.options.push(option)
@@ -1106,7 +1258,12 @@ class Goke<Opts extends Record<string, any> = {}> extends EventEmitter {
1106
1258
  * })
1107
1259
  * ```
1108
1260
  */
1109
- use(callback: (options: Opts, context: GokeExecutionContext) => void | Promise<void>): this {
1261
+ use(
1262
+ callback: (
1263
+ options: Opts & DoubleDashOptions,
1264
+ context: GokeExecutionContext,
1265
+ ) => void | Promise<void>,
1266
+ ): this {
1110
1267
  this.middlewares.push({ action: callback })
1111
1268
  return this
1112
1269
  }
@@ -1459,7 +1616,9 @@ class Goke<Opts extends Record<string, any> = {}> extends EventEmitter {
1459
1616
  //
1460
1617
  // When mri returns `true` for value-taking options, it means "flag present, no value given".
1461
1618
  // For required options (<...>), the sentinel is preserved so checkOptionValue() throws.
1462
- // For optional options ([...]) with a schema, we replace `true` with `undefined`.
1619
+ // For optional options ([...]) we want a single, uniform shape: `string`
1620
+ // with `''` meaning "flag present but no value" — callers get clean
1621
+ // `string | undefined` types instead of `string | boolean | undefined`.
1463
1622
  const requiredValueOptions = new Set<string>()
1464
1623
  const optionalValueOptions = new Set<string>()
1465
1624
  for (const cliOption of cliOptions) {
@@ -1485,18 +1644,25 @@ class Goke<Opts extends Record<string, any> = {}> extends EventEmitter {
1485
1644
  // When value is boolean `true` and the option takes a value, it's mri's sentinel
1486
1645
  // for "flag present, no value given":
1487
1646
  // - Required options (<...>): preserve `true` so checkOptionValue() throws
1488
- // - Optional options ([...]) with schema: replace with `undefined` (no typed value)
1489
- // - Optional options ([...]) without schema: preserve `true` (original goke behavior)
1647
+ // - Optional options ([...]) with schema: replace with `undefined` so
1648
+ // any `.default(...)` on the schema kicks in (e.g. z.number().default(30)
1649
+ // should produce 30, not try to coerce the `''` empty-string sentinel).
1490
1650
  const schemaInfo = schemaMap.get(key)
1491
1651
  if (schemaInfo && value !== undefined) {
1492
1652
  if (value === true && requiredValueOptions.has(key)) {
1493
1653
  // Keep sentinel for checkOptionValue() to detect
1494
1654
  } else if (value === true && optionalValueOptions.has(key)) {
1495
- // Optional value not given — schema expects a typed value, so return undefined
1496
1655
  value = undefined
1497
1656
  } else {
1498
1657
  value = coerceBySchema(value, schemaInfo.jsonSchema, schemaInfo.optionName)
1499
1658
  }
1659
+ } else if (value === true && optionalValueOptions.has(key)) {
1660
+ // Untyped optional-value flag with no schema: normalize bare `true`
1661
+ // to `''` so callers get a clean `string | undefined` shape. `''`
1662
+ // means "flag passed with no argument", distinct from `undefined`
1663
+ // (flag omitted). This matches the new type inference that treats
1664
+ // `[value]` as `string` instead of `string | boolean`.
1665
+ value = ''
1500
1666
  }
1501
1667
 
1502
1668
  setDotProp(options, keys, value)
@@ -12,7 +12,7 @@ const fs: GokeFs = nodeFs
12
12
 
13
13
  function openInBrowser(url: string): void {
14
14
  if (!process.stdout.isTTY) {
15
- process.stderr.write(url + '\n')
15
+ process.stdout.write(url + '\n')
16
16
  return
17
17
  }
18
18
 
@@ -25,7 +25,7 @@ function openInBrowser(url: string): void {
25
25
  execSync(`xdg-open ${JSON.stringify(url)}`, { stdio: 'ignore' })
26
26
  }
27
27
  } catch {
28
- process.stderr.write(url + '\n')
28
+ process.stdout.write(url + '\n')
29
29
  }
30
30
  }
31
31