1import { assert, describe, expect, it } from "@effect/vitest"
2import { Context, Effect, Fiber, FileSystem, Layer, Option, Path, Redacted, Runtime, Stdio } from "effect"
3import { Argument, CliConfig, CliError, CliOutput, Command, Flag, GlobalFlag } from "effect/cli"
4import { toImpl } from "effect/cli/internal/command"
5import { ChildProcessSpawner } from "effect/process"
6import { TestConsole } from "effect/testing"
7import * as Cli from "./fixtures/ComprehensiveCli.ts"
8import * as MockTerminal from "./services/MockTerminal.ts"
9import * as TestActions from "./services/TestActions.ts"
10
11const ActionsLayer = TestActions.layer
12const ConsoleLayer = TestConsole.layer
13const FileSystemLayer = FileSystem.layerNoop({})
14const PathLayer = Path.layer
15const TerminalLayer = MockTerminal.layer
16const CliOutputLayer = CliOutput.layer(
17 CliOutput.defaultFormatter({
18 colors: false
19 })
20)
21
22const TestLayer = Layer.mergeAll(
23 ActionsLayer,
24 ConsoleLayer,
25 FileSystemLayer,
26 PathLayer,
27 TerminalLayer,
28 CliOutputLayer,
29 Layer.succeed(
30 ChildProcessSpawner.ChildProcessSpawner,
31 ChildProcessSpawner.make(() => Effect.die("Not implemented"))
32 ),
33 Stdio.layerTest({})
34)
35
36const TestLayerWithoutFormatter = Layer.mergeAll(
37 ActionsLayer,
38 ConsoleLayer,
39 FileSystemLayer,
40 PathLayer,
41 TerminalLayer,
42 Layer.succeed(
43 ChildProcessSpawner.ChildProcessSpawner,
44 ChildProcessSpawner.make(() => Effect.die("Not implemented"))
45 ),
46 Stdio.layerTest({})
47)
48
49describe("Command", () => {
50 describe("annotations", () => {
51 it.effect("should expose annotations in help docs", () =>
52 Effect.gen(function*() {
53 const Team = Context.Service<never, string>("effect/test/unstable/cli/Team")
54 const Priority = Context.Service<never, number>("effect/test/unstable/cli/Priority")
55 const docs: Array<Parameters<CliOutput.Formatter["formatHelpDoc"]>[0]> = []
56
57 const formatter: CliOutput.Formatter = {
58 ...CliOutput.defaultFormatter({ colors: false }),
59 formatHelpDoc: (doc) => {
60 docs.push(doc)
61 return ""
62 }
63 }
64
65 const command = Command.make("deploy").pipe(
66 Command.annotate(Team, "runtime"),
67 Command.annotateMerge(Context.make(Priority, 2))
68 )
69
70 yield* Command.runWith(command, { version: "1.0.0" })(["--help"]).pipe(
71 Effect.provide(TestLayerWithoutFormatter),
72 Effect.provideService(CliOutput.Formatter, formatter)
73 )
74
75 assert.strictEqual(docs.length, 1)
76 const annotations = docs[0].annotations
77 assert.strictEqual(Context.get(annotations, Team), "runtime")
78 assert.strictEqual(Context.get(annotations, Priority), 2)
79 }))
80
81 it.effect("should keep annotations when adding subcommands", () =>
82 Effect.gen(function*() {
83 const Scope = Context.Service<never, string>("effect/test/unstable/cli/Scope")
84 const docs: Array<Parameters<CliOutput.Formatter["formatHelpDoc"]>[0]> = []
85
86 const formatter: CliOutput.Formatter = {
87 ...CliOutput.defaultFormatter({ colors: false }),
88 formatHelpDoc: (doc) => {
89 docs.push(doc)
90 return ""
91 }
92 }
93
94 const child = Command.make("child").pipe(Command.annotate(Scope, "child"))
95 const command = Command.make("root").pipe(
96 Command.annotate(Scope, "root"),
97 Command.withSubcommands([child])
98 )
99
100 const run = Command.runWith(command, { version: "1.0.0" })
101
102 yield* run(["--help"]).pipe(
103 Effect.provide(TestLayerWithoutFormatter),
104 Effect.provideService(CliOutput.Formatter, formatter)
105 )
106
107 yield* run(["child", "--help"]).pipe(
108 Effect.provide(TestLayerWithoutFormatter),
109 Effect.provideService(CliOutput.Formatter, formatter)
110 )
111
112 assert.strictEqual(docs.length, 2)
113 assert.strictEqual(Context.get(docs[0].annotations, Scope), "root")
114 assert.strictEqual(Context.get(docs[1].annotations, Scope), "child")
115 }))
116 })
117
118 describe("run", () => {
119 it.effect("should invoke the wizard programmatically from a command handler", () =>
120 Effect.gen(function*() {
121 const captured: Array<ReadonlyArray<string>> = []
122 const target = Command.make("greet", {
123 name: Flag.String("name"),
124 count: Argument.Int("count")
125 })
126 const command = Command.make("launcher", {}, () =>
127 Effect.flatMap(Command.wizard(target), (args) =>
128 Effect.sync(() => {
129 captured.push(args)
130 })))
131
132 const fiber = yield* Command.runWith(command, { version: "1.0.0" })([]).pipe(Effect.forkChild)
133 yield* MockTerminal.inputText("Alice")
134 yield* MockTerminal.inputKey("enter")
135 yield* MockTerminal.inputText("2")
136 yield* MockTerminal.inputKey("enter")
137 yield* Fiber.join(fiber)
138
139 assert.deepStrictEqual(captured, [["greet", "--name", "Alice", "2"]])
140 }).pipe(Effect.provide(TestLayer)))
141
142 it.effect("should run wizard-generated arguments with --wizard", () =>
143 Effect.gen(function*() {
144 const captured: Array<string> = []
145 const command = Command.make("greet", {
146 name: Flag.String("name")
147 }, ({ name }) => Effect.sync(() => captured.push(name)))
148
149 const fiber = yield* Command.runWith(command, { version: "1.0.0" })(["--wizard"]).pipe(Effect.forkChild)
150 yield* MockTerminal.inputText("Alice Smith")
151 yield* MockTerminal.inputKey("enter")
152 yield* MockTerminal.inputKey("enter")
153 yield* Fiber.join(fiber)
154
155 assert.deepStrictEqual(captured, ["Alice Smith"])
156 const output = [...yield* TestConsole.logLines, ...yield* MockTerminal.displayLines].join("\n")
157 assert.include(output, "Command wizard")
158 assert.include(output, "Build a command interactively. Press Ctrl+C to cancel.")
159 assert.include(output, "Current command")
160 assert.include(output, "ROOT")
161 assert.include(output, "FLAGS")
162 assert.include(output, "Name (--name)")
163 assert.include(output, "Command ready")
164 assert.include(output, "$ greet --name 'Alice Smith'")
165 assert.include(output, "Run this command?")
166 }).pipe(Effect.provide(TestLayer)))
167
168 it.effect("should parse negative numbers as flag and argument values", () =>
169 Effect.gen(function*() {
170 const captured: Array<readonly [number, number, number]> = []
171 const command = Command.make("geo", {
172 lat: Flag.Finite("lat"),
173 lon: Flag.Finite("lon"),
174 offset: Argument.Int("offset")
175 }, ({ lat, lon, offset }) => Effect.sync(() => captured.push([lat, lon, offset])))
176
177 yield* Command.runWith(command, { version: "1.0.0" })(["--lat", "-40.41", "--lon", "-3.70", "-2"])
178
179 assert.deepStrictEqual(captured, [[-40.41, -3.7, -2]])
180 }).pipe(Effect.provide(TestLayer)))
181
182 it.effect("should run negative integer flag values with --wizard", () =>
183 Effect.gen(function*() {
184 const captured: Array<number> = []
185 const command = Command.make("demo", {
186 offset: Flag.Int("offset")
187 }, ({ offset }) => Effect.sync(() => captured.push(offset)))
188
189 const fiber = yield* Command.runWith(command, { version: "1.0.0" })(["--wizard"]).pipe(Effect.forkChild)
190 yield* MockTerminal.inputText("-2")
191 yield* MockTerminal.inputKey("enter")
192 yield* MockTerminal.inputKey("enter")
193 yield* Fiber.join(fiber)
194
195 assert.deepStrictEqual(captured, [-2])
196 const output = [...yield* TestConsole.logLines, ...yield* MockTerminal.displayLines].join("\n")
197 assert.include(output, "$ demo --offset=-2")
198 }).pipe(Effect.provide(TestLayer)))
199
200 for (
201 const { args: expectedArgs, value } of [
202 { value: "-v", args: ["--value=-v"] },
203 { value: "--verbose", args: ["--value=--verbose"] },
204 { value: "--", args: ["--value=--"] },
205 { value: "--mode=x=y", args: ["--value=--mode=x=y"] },
206 { value: "plain", args: ["--value", "plain"] },
207 { value: "", args: ["--value", ""] },
208 { value: "-", args: ["--value", "-"] }
209 ]
210 ) {
211 it.effect(`should round trip wizard flag value ${JSON.stringify(value)}`, () =>
212 Effect.gen(function*() {
213 const captured: Array<string> = []
214 const command = Command.make("demo", {
215 value: Flag.String("value")
216 }, ({ value }) => Effect.sync(() => captured.push(value)))
217
218 const fiber = yield* Command.wizard(command).pipe(Effect.forkChild)
219 yield* MockTerminal.inputText(value)
220 yield* MockTerminal.inputKey("enter")
221 const args = yield* Fiber.join(fiber)
222
223 assert.deepStrictEqual(args, ["demo", ...expectedArgs])
224 yield* Command.runWith(command, { version: "1.0.0" })(args.slice(1))
225 assert.deepStrictEqual(captured, [value])
226 }).pipe(Effect.provide(TestLayer)))
227 }
228
229 it.effect("should round trip mixed repeated wizard flag values", () =>
230 Effect.gen(function*() {
231 const values = ["-v", "plain", "--name=x", ""]
232 const captured: Array<ReadonlyArray<string>> = []
233 const command = Command.make("demo", {
234 value: Flag.String("value").pipe(Flag.atLeast(1))
235 }, ({ value }) => Effect.sync(() => captured.push(value)))
236
237 const fiber = yield* Command.wizard(command).pipe(Effect.forkChild)
238 yield* MockTerminal.inputKey("backspace")
239 yield* MockTerminal.inputText("4")
240 yield* MockTerminal.inputKey("enter")
241 for (const value of values) {
242 yield* MockTerminal.inputText(value)
243 yield* MockTerminal.inputKey("enter")
244 }
245 const args = yield* Fiber.join(fiber)
246
247 assert.deepStrictEqual(args, ["demo", "--value=-v", "--value", "plain", "--value=--name=x", "--value", ""])
248 yield* Command.runWith(command, { version: "1.0.0" })(args.slice(1))
249 assert.deepStrictEqual(captured, [values])
250 }).pipe(Effect.provide(TestLayer)))
251
252 it.effect("should redact option-looking values in wizard command output", () =>
253 Effect.gen(function*() {
254 const secret = "-hunter2-secret"
255 const captured: Array<readonly [string, string]> = []
256 const command = Command.make("login", {
257 password: Flag.Redacted("password"),
258 account: Argument.String("account")
259 }, ({ account, password }) =>
260 Effect.sync(() => {
261 captured.push([Redacted.value(password), account])
262 }))
263
264 const fiber = yield* Command.runWith(command, { version: "1.0.0" })(["--wizard"]).pipe(Effect.forkChild)
265 yield* MockTerminal.inputText(secret)
266 yield* MockTerminal.inputKey("enter")
267 yield* MockTerminal.inputText("alice")
268 yield* MockTerminal.inputKey("enter")
269 yield* MockTerminal.inputKey("enter")
270 yield* Fiber.join(fiber)
271
272 const output = [...yield* TestConsole.logLines, ...yield* MockTerminal.displayLines].join("\n")
273 const currentCommand = output.slice(output.indexOf("Current command"), output.indexOf("Command ready"))
274 const commandReady = output.slice(output.indexOf("Command ready"))
275
276 assert.deepStrictEqual(captured, [[secret, "alice"]])
277 assert.include(currentCommand, "--password=<redacted>")
278 assert.include(commandReady, "--password=<redacted>")
279 assert.notInclude(output, secret)
280 }).pipe(Effect.provide(TestLayer)))
281
282 it.effect("should print a message when wizard mode is cancelled", () =>
283 Effect.gen(function*() {
284 let invoked = false
285 const command = Command.make("greet", {
286 name: Flag.String("name")
287 }, () =>
288 Effect.sync(() => {
289 invoked = true
290 }))
291
292 const fiber = yield* Command.runWith(command, { version: "1.0.0" })(["--wizard"]).pipe(Effect.forkChild)
293 yield* MockTerminal.inputKey("c", { ctrl: true })
294 yield* Fiber.join(fiber)
295
296 const output = [...yield* TestConsole.logLines, ...yield* MockTerminal.displayLines].join("\n")
297 assert.isFalse(invoked)
298 assert.include(output, "Wizard cancelled.")
299 }).pipe(Effect.provide(TestLayer)))
300
301 it.effect("should render boolean wizard values consistently", () =>
302 Effect.gen(function*() {
303 const captured: Array<boolean> = []
304 const command = Command.make("deploy", {
305 dryRun: Flag.Boolean("dry-run")
306 }, ({ dryRun }) => Effect.sync(() => captured.push(dryRun)))
307
308 const fiber = yield* Command.runWith(command, { version: "1.0.0" })(["--wizard"]).pipe(Effect.forkChild)
309 yield* MockTerminal.inputKey("y")
310 yield* MockTerminal.inputKey("enter")
311 yield* Fiber.join(fiber)
312
313 const output = [...yield* TestConsole.logLines, ...yield* MockTerminal.displayLines].join("\n")
314 assert.deepStrictEqual(captured, [true])
315 assert.include(output, "Dry run (--dry-run)")
316 assert.include(output, "deploy --dry-run true")
317 assert.notInclude(output, "on / off")
318 }).pipe(Effect.provide(TestLayer)))
319
320 it.effect("should start wizard mode at a selected subcommand", () =>
321 Effect.gen(function*() {
322 const captured: Array<string> = []
323 const child = Command.make("child", {
324 value: Argument.String("value")
325 }, ({ value }) => Effect.sync(() => captured.push(value)))
326 const command = Command.make("root").pipe(Command.withSubcommands([child]))
327
328 const fiber = yield* Command.runWith(command, { version: "1.0.0" })(["child", "--wizard"]).pipe(
329 Effect.forkChild
330 )
331 yield* MockTerminal.inputText("selected")
332 yield* MockTerminal.inputKey("enter")
333 yield* MockTerminal.inputKey("enter")
334 yield* Fiber.join(fiber)
335
336 assert.deepStrictEqual(captured, ["selected"])
337 }).pipe(Effect.provide(TestLayer)))
338
339 it.effect("should reject --completions without a shell", () =>
340 Effect.gen(function*() {
341 let invoked = false
342 const command = Command.make("demo", {}, () =>
343 Effect.sync(() => {
344 invoked = true
345 }))
346
347 yield* Command.runWith(command, { version: "1.0.0" })(["--completions"]).pipe(Effect.ignore)
348
349 const stderr = yield* TestConsole.errorLines
350 assert.isFalse(invoked)
351 assert.isTrue(
352 stderr.some((line) =>
353 String(line).includes("Missing value for flag --completions. Expected: bash | zsh | fish | sh")
354 )
355 )
356 }).pipe(Effect.provide(TestLayer)))
357
358 const missingValueCases: ReadonlyArray<{
359 readonly flag: Flag.Flag<unknown>
360 readonly name: string
361 readonly expected: string
362 }> = [
363 { flag: Flag.String("name").pipe(Flag.optional), name: "name", expected: "string" },
364 { flag: Flag.Int("count").pipe(Flag.optional), name: "count", expected: "integer" },
365 { flag: Flag.Path("config").pipe(Flag.optional), name: "config", expected: "path" },
366 { flag: Flag.KeyValuePair("env").pipe(Flag.optional), name: "env", expected: "key=value" }
367 ]
368
369 for (const { expected, flag, name } of missingValueCases) {
370 it.effect(`should reject --${name} without a ${expected} value`, () =>
371 Effect.gen(function*() {
372 let invoked = false
373 const command = Command.make("demo", { value: flag }, () =>
374 Effect.sync(() => {
375 invoked = true
376 }))
377
378 yield* Command.runWith(command, { version: "1.0.0" })([`--${name}`]).pipe(Effect.ignore)
379
380 const stderr = yield* TestConsole.errorLines
381 assert.isFalse(invoked)
382 assert.isTrue(
383 stderr.some((line) => String(line).includes(`Missing value for flag --${name}. Expected: ${expected}`))
384 )
385 }).pipe(Effect.provide(TestLayer)))
386 }
387
388 it.effect("should render invalid choice values without doubling the Expected prefix", () =>
389 Effect.gen(function*() {
390 let invoked = false
391 const command = Command.make("demo", {
392 size: Flag.Literals("size", ["small", "medium", "large"])
393 }, () =>
394 Effect.sync(() => {
395 invoked = true
396 }))
397
398 yield* Command.runWith(command, { version: "1.0.0" })(["--size", "bogus"]).pipe(Effect.ignore)
399
400 const stderr = yield* TestConsole.errorLines
401 assert.isFalse(invoked)
402 assert.isTrue(
403 stderr.some((line) =>
404 String(line).includes(`Invalid value for flag --size: "bogus". Expected: "small" | "medium" | "large"`)
405 )
406 )
407 }).pipe(Effect.provide(TestLayer)))
408
409 it.effect("should execute handler with parsed config", () =>
410 Effect.gen(function*() {
411 const path = yield* Path.Path
412 const resolvedSrc = path.resolve("src.txt")
413 const resolvedDest = path.resolve("dest.txt")
414
415 yield* Cli.run(["copy", "src.txt", "dest.txt", "--recursive", "--force"])
416
417 const actions = yield* TestActions.getActions
418 assert.strictEqual(actions.length, 1)
419 assert.deepStrictEqual(actions[0], {
420 command: "copy",
421 details: {
422 source: resolvedSrc,
423 destination: resolvedDest,
424 recursive: true,
425 force: true,
426 bufferSize: 64
427 }
428 })
429 }).pipe(Effect.provide(TestLayer)))
430
431 it.effect("should handle nested config in handler", () =>
432 Effect.gen(function*() {
433 const username = "john_doe"
434 const email = "john@example.com"
435 const role = "admin"
436
437 yield* Cli.run(["admin", "users", "create", username, email, "--role", role, "--notify"])
438
439 const actions = yield* TestActions.getActions
440 assert.strictEqual(actions.length, 1)
441 assert.deepStrictEqual(actions[0], {
442 command: "users create",
443 details: { username, email: Option.some(email), role, notify: true }
444 })
445 }).pipe(Effect.provide(TestLayer)))
446
447 it.effect("should work with effectful handlers", () =>
448 Effect.gen(function*() {
449 const files = ["file1.txt", "file2.txt", "dir/"]
450
451 yield* Cli.run(["remove", ...files, "--recursive", "--force", "--verbose"])
452
453 const actions = yield* TestActions.getActions
454 assert.strictEqual(actions.length, 1)
455 assert.deepStrictEqual(actions[0], {
456 command: "remove",
457 details: {
458 files,
459 recursive: true,
460 force: true,
461 verbose: true
462 }
463 })
464 }).pipe(Effect.provide(TestLayer)))
465
466 it.effect("should work with option aliases in handler", () =>
467 Effect.gen(function*() {
468 const config = "build.json"
469 const output = "dist/"
470
471 yield* Cli.run(["build", "-o", output, "-v", "-f", config])
472
473 const actions = yield* TestActions.getActions
474 assert.strictEqual(actions.length, 1)
475 assert.deepStrictEqual(actions[0], {
476 command: "build",
477 details: { output, verbose: true, config }
478 })
479 }).pipe(Effect.provide(TestLayer)))
480
481 it.effect("should merge repeated key=value flags into a single record", () =>
482 Effect.gen(function*() {
483 const captured: Array<Record<string, string>> = []
484
485 const command = Command.make("env", {
486 env: Flag.KeyValuePair("env")
487 }, (config) =>
488 Effect.sync(() => {
489 captured.push(config.env)
490 }))
491
492 const runCommand = Command.runWith(command, {
493 version: "1.0.0"
494 })
495
496 yield* runCommand([
497 "--env",
498 "foo=bar",
499 "--env",
500 "cool=dude"
501 ])
502
503 assert.deepStrictEqual(captured, [{ foo: "bar", cool: "dude" }])
504 }).pipe(Effect.provide(TestLayer)))
505
506 it.effect("should merge key=value flags even when interleaved with other options", () =>
507 Effect.gen(function*() {
508 const captured: Array<Record<string, unknown>> = []
509
510 const command = Command.make("env", {
511 env: Flag.KeyValuePair("env"),
512 verbose: Flag.Boolean("verbose"),
513 profile: Flag.String("profile")
514 }, (config) =>
515 Effect.sync(() => {
516 captured.push(config)
517 }))
518
519 const runCommand = Command.runWith(command, {
520 version: "1.0.0"
521 })
522
523 yield* runCommand([
524 "--env",
525 "foo=bar",
526 "--profile",
527 "dev",
528 "--env",
529 "cool=dude",
530 "--verbose",
531 "--env",
532 "zip=zop"
533 ])
534
535 assert.deepStrictEqual(captured, [{
536 env: { foo: "bar", cool: "dude", zip: "zop" },
537 verbose: true,
538 profile: "dev"
539 }])
540 }).pipe(Effect.provide(TestLayer)))
541
542 it.effect("should expose setting global flags to command handlers", () =>
543 Effect.gen(function*() {
544 const Region = GlobalFlag.Setting("region")({
545 flag: Flag.String("region").pipe(Flag.optional)
546 })
547 const captured: Array<Option.Option<string>> = []
548
549 const command = Command.make("deploy", {}, () =>
550 Effect.gen(function*() {
551 captured.push(yield* Region)
552 })).pipe(
553 Command.withGlobalFlags([Region])
554 )
555
556 const runCommand = Command.runWith(command, {
557 version: "1.0.0"
558 })
559
560 yield* runCommand(["--region", "us-east-1"])
561 yield* runCommand([])
562
563 assert.deepStrictEqual(captured, [Option.some("us-east-1"), Option.none()])
564 }).pipe(Effect.provide(TestLayer)))
565
566 it.effect("should expose setting global flags with Flag.withDefault", () =>
567 Effect.gen(function*() {
568 const Region = GlobalFlag.Setting("region")({
569 flag: Flag.String("region").pipe(Flag.withDefault("us-west-2"))
570 })
571 const captured: Array<string> = []
572
573 const command = Command.make("deploy", {}, () =>
574 Effect.gen(function*() {
575 captured.push(yield* Region)
576 })).pipe(
577 Command.withGlobalFlags([Region])
578 )
579
580 const runCommand = Command.runWith(command, {
581 version: "1.0.0"
582 })
583
584 yield* runCommand(["--region", "eu-west-1"])
585 yield* runCommand([])
586
587 assert.deepStrictEqual(captured, ["eu-west-1", "us-west-2"])
588 }).pipe(Effect.provide(TestLayer)))
589
590 it.effect("should configure built-in global flags per run", () =>
591 Effect.gen(function*() {
592 const command = Command.make("app", {}, () => Effect.void)
593 const runCommand = Command.runWith(command, { version: "1.0.0" })
594 const config = CliConfig.make({
595 builtIns: GlobalFlag.BuiltIns.filter((flag) => flag !== GlobalFlag.LogLevel)
596 })
597
598 yield* runCommand(["--help"]).pipe(Effect.provide(CliConfig.layer(config)))
599 const configuredHelp = yield* TestConsole.logLines
600 assert.isFalse(configuredHelp.some((line) => String(line).includes("--log-level")))
601
602 yield* runCommand(["--log-level", "debug"]).pipe(
603 Effect.provideService(CliConfig.CliConfig, config),
604 Effect.ignore
605 )
606 const errors = yield* TestConsole.errorLines
607 assert.isTrue(errors.some((line) => String(line).includes("Unrecognized flag: --log-level")))
608
609 const helpCountBeforeDefault = (yield* TestConsole.logLines).length
610 yield* runCommand(["--help"])
611 const allHelp = yield* TestConsole.logLines
612 const defaultHelp = allHelp.slice(helpCountBeforeDefault)
613 assert.isTrue(defaultHelp.some((line) => String(line).includes("--log-level")))
614 }).pipe(Effect.provide(TestLayer)))
615
616 it.effect("should support mixed action and setting global flags", () =>
617 Effect.gen(function*() {
618 const actions: Array<boolean> = []
619 const captured: Array<string> = []
620 let handlerInvocations = 0
621
622 const VerboseAction = GlobalFlag.Action({
623 flag: Flag.Boolean("verbose").pipe(Flag.withDefault(false)),
624 run: (value) =>
625 Effect.sync(() => {
626 actions.push(value)
627 })
628 })
629 const Format = GlobalFlag.Setting("format")({
630 flag: Flag.String("format").pipe(Flag.withDefault("text"))
631 })
632
633 const command = Command.make("deploy", {}, () =>
634 Effect.gen(function*() {
635 handlerInvocations += 1
636 captured.push(yield* Format)
637 })).pipe(
638 Command.withGlobalFlags([VerboseAction, Format])
639 )
640
641 const runCommand = Command.runWith(command, {
642 version: "1.0.0"
643 })
644
645 yield* runCommand(["--format", "json"])
646 yield* runCommand(["--verbose", "--format", "yaml"])
647
648 assert.deepStrictEqual(captured, ["json"])
649 assert.deepStrictEqual(actions, [true])
650 assert.strictEqual(handlerInvocations, 1)
651 }).pipe(Effect.provide(TestLayer)))
652
653 it.effect("should reject explicit values for canonical negated global flags", () =>
654 Effect.gen(function*() {
655 let handlerInvoked = false
656 const Verbose = GlobalFlag.Setting("verbose")({
657 flag: Flag.Boolean("verbose")
658 })
659
660 const command = Command.make("deploy", {}, () =>
661 Effect.sync(() => {
662 handlerInvoked = true
663 })).pipe(
664 Command.withGlobalFlags([Verbose])
665 )
666
667 const runCommand = Command.runWith(command, {
668 version: "1.0.0"
669 })
670
671 yield* runCommand(["--no-verbose", "false"]).pipe(Effect.catchTag("ShowHelp", () => Effect.void))
672
673 assert.isFalse(handlerInvoked)
674
675 const stderr = yield* TestConsole.errorLines
676 assert.isTrue(stderr.join("\n").includes("use --no-verbose by itself to set --verbose to false"))
677 }).pipe(Effect.provide(TestLayer)))
678
679 it.effect("should expose setting global flags in Command.provide APIs", () =>
680 Effect.gen(function*() {
681 const Region = GlobalFlag.Setting("region")({
682 flag: Flag.String("region").pipe(Flag.optional)
683 })
684 const RegionFromProvide = Context.Service<never, Option.Option<string>>(
685 "effect/test/unstable/cli/RegionFromProvide"
686 )
687 const capturedFromProvide: Array<Option.Option<string>> = []
688 const capturedFromProvideEffect: Array<Option.Option<string>> = []
689
690 const command = Command.make("deploy", {}, () =>
691 Effect.gen(function*() {
692 capturedFromProvide.push(yield* RegionFromProvide)
693 })).pipe(
694 Command.provide(() =>
695 Layer.effect(
696 RegionFromProvide,
697 Effect.gen(function*() {
698 return yield* Region
699 })
700 )
701 ),
702 Command.provideEffectDiscard(() =>
703 Effect.gen(function*() {
704 capturedFromProvideEffect.push(yield* Region)
705 })
706 ),
707 Command.withGlobalFlags([Region])
708 )
709
710 const runCommand = Command.runWith(command, {
711 version: "1.0.0"
712 })
713
714 yield* runCommand(["--region", "eu-west-1"])
715
716 assert.deepStrictEqual(capturedFromProvide, [Option.some("eu-west-1")])
717 assert.deepStrictEqual(capturedFromProvideEffect, [Option.some("eu-west-1")])
718 }).pipe(Effect.provide(TestLayer)))
719
720 it.effect("should reject global flags that are out of scope for selected subcommand", () =>
721 Effect.gen(function*() {
722 const Region = GlobalFlag.Setting("region")({
723 flag: Flag.String("region").pipe(Flag.withDefault("us-east-1"))
724 })
725 let dbInvoked = false
726 let deployInvoked = false
727
728 const deploy = Command.make("deploy", {}, () =>
729 Effect.sync(() => {
730 deployInvoked = true
731 })).pipe(
732 Command.withGlobalFlags([Region])
733 )
734
735 const db = Command.make("db", {}, () =>
736 Effect.sync(() => {
737 dbInvoked = true
738 }))
739
740 const root = Command.make("app", {}).pipe(Command.withSubcommands([deploy, db]))
741
742 const runCommand = Command.runWith(root, {
743 version: "1.0.0"
744 })
745
746 yield* runCommand(["db", "--region", "eu-west-1"]).pipe(
747 Effect.ignore
748 )
749
750 const stderr = yield* TestConsole.errorLines
751 assert.isTrue(
752 stderr.some((line) => String(line).includes("--region")),
753 "expected CLI to report out-of-scope global flag"
754 )
755 assert.isFalse(dbInvoked)
756 assert.isFalse(deployInvoked)
757 }).pipe(Effect.provide(TestLayer)))
758
759 it.effect("should show only path-active global flags in subcommand help", () =>
760 Effect.gen(function*() {
761 const Region = GlobalFlag.Setting("region")({
762 flag: Flag.String("region").pipe(Flag.withDefault("us-east-1"))
763 })
764
765 const deploy = Command.make("deploy", {}, () => Effect.void).pipe(
766 Command.withGlobalFlags([Region])
767 )
768 const db = Command.make("db", {}, () => Effect.void)
769 const root = Command.make("app", {}).pipe(Command.withSubcommands([deploy, db]))
770
771 const runCommand = Command.runWith(root, {
772 version: "1.0.0"
773 })
774
775 yield* runCommand(["deploy", "--help"])
776 const deployHelp = yield* TestConsole.logLines
777 assert.isTrue(deployHelp.some((line) => String(line).includes("--region")))
778
779 yield* runCommand(["db", "--help"])
780 const dbHelp = yield* TestConsole.logLines
781 const dbSection = dbHelp.slice(deployHelp.length)
782 assert.isFalse(dbSection.some((line) => String(line).includes("--region")))
783 }).pipe(Effect.provide(TestLayer)))
784
785 it.effect("should fail for malformed key=value flags", () =>
786 Effect.gen(function*() {
787 let invoked = false
788
789 const command = Command.make("env", {
790 env: Flag.KeyValuePair("env")
791 }, () =>
792 Effect.sync(() => {
793 invoked = true
794 }))
795
796 const runCommand = Command.runWith(command, {
797 version: "1.0.0"
798 })
799
800 yield* runCommand([
801 "--env",
802 "invalid"
803 ]).pipe(Effect.ignore)
804
805 const stderr = yield* TestConsole.errorLines
806 assert.isTrue(
807 stderr.some((line) => String(line).includes("Invalid key=value format")),
808 "expected CLI to report invalid key=value format"
809 )
810
811 assert.isFalse(invoked)
812 }).pipe(Effect.provide(TestLayer)))
813
814 it.effect("should handle parsing errors from run", () =>
815 Effect.gen(function*() {
816 const runCommand = Command.runWith(Cli.ComprehensiveCli, {
817 version: "1.0.0"
818 })
819
820 yield* runCommand(["invalid-command"]).pipe(Effect.ignore)
821
822 // Check that help text was shown to stdout
823 const stdout = yield* TestConsole.logLines
824 assert.isTrue(stdout.some((line) => String(line).includes("DESCRIPTION")))
825 assert.isTrue(stdout.some((line) => String(line).includes("comprehensive CLI tool")))
826
827 // Check that error was shown to stderr
828 const stderr = yield* TestConsole.errorLines
829 assert.isTrue(stderr.some((line) => String(line).includes("ERROR")))
830 assert.isTrue(stderr.some((line) => String(line).includes("Unknown subcommand")))
831 assert.isTrue(stderr.some((line) => String(line).includes("invalid-command")))
832 }).pipe(Effect.provide(TestLayer)))
833
834 it.effect("should propagate handler errors from run", () =>
835 Effect.gen(function*() {
836 const result = yield* Effect.flip(Cli.run(["test-failing", "--input", "test"]))
837 assert.strictEqual(result, "Handler error")
838 }).pipe(Effect.provide(TestLayer)))
839
840 it.effect("should render and rethrow UserError handler failures without help", () =>
841 Effect.gen(function*() {
842 const failure = new CliError.UserError({
843 cause: new Error("internal details"),
844 userMessage: "Deployment failed"
845 })
846 const command = Command.make("deploy", {}, () => failure)
847
848 const error = yield* Effect.flip(Command.runWith(command, { version: "1.0.0" })([]))
849
850 assert.strictEqual(error, failure)
851 assert.isFalse(Runtime.getErrorReported(error))
852 const stderr = yield* TestConsole.errorLines
853 assert.lengthOf(stderr, 1)
854 assert.strictEqual(String(stderr[0]), "\nERROR\n Deployment failed")
855 assert.isEmpty(yield* TestConsole.logLines)
856 }).pipe(Effect.provide(TestLayer)))
857
858 it.effect("should render UserError handler failures with the installed formatter", () =>
859 Effect.gen(function*() {
860 const formatter: CliOutput.Formatter = {
861 ...CliOutput.defaultFormatter({ colors: false }),
862 formatError: (error) => `CUSTOM ERROR: ${error.message}`
863 }
864 const failure = new CliError.UserError({ cause: "Deployment failed" })
865 const command = Command.make("deploy", {}, () => failure)
866
867 yield* Command.runWith(command, { version: "1.0.0" })([]).pipe(
868 Effect.flip,
869 Effect.provide(TestLayerWithoutFormatter),
870 Effect.provideService(CliOutput.Formatter, formatter)
871 )
872
873 assert.deepStrictEqual(yield* TestConsole.errorLines, ["CUSTOM ERROR: Deployment failed"])
874 }))
875
876 it.effect("should render UserError once when running wizard-generated arguments", () =>
877 Effect.gen(function*() {
878 const failure = new CliError.UserError({ cause: "Deployment failed" })
879 const command = Command.make("deploy", {}, () => failure)
880
881 const fiber = yield* Command.runWith(command, { version: "1.0.0" })(["--wizard"]).pipe(
882 Effect.flip,
883 Effect.forkChild
884 )
885 yield* MockTerminal.inputKey("enter")
886 const error = yield* Fiber.join(fiber)
887
888 assert.strictEqual(error, failure)
889 assert.deepStrictEqual(yield* TestConsole.errorLines, ["\nERROR\n Deployment failed"])
890 }).pipe(Effect.provide(TestLayer)))
891
892 it.effect("should render UserError argument failures with command help", () =>
893 Effect.gen(function*() {
894 const command = Command.make("deploy", {
895 target: Argument.String("target").pipe(
896 Argument.mapEffect(() =>
897 Effect.fail(
898 new CliError.UserError({
899 cause: "Invalid deployment target"
900 })
901 )
902 )
903 )
904 })
905
906 const error = yield* Effect.flip(Command.runWith(command, { version: "1.0.0" })(["invalid"]))
907
908 assert.instanceOf(error, CliError.ShowHelp)
909 assert.include((yield* TestConsole.errorLines).join("\n"), "Invalid deployment target")
910 assert.include((yield* TestConsole.logLines).join("\n"), "USAGE")
911 }).pipe(Effect.provide(TestLayer)))
912
913 it.effect("should suppress automatic error rendering", () =>
914 Effect.gen(function*() {
915 const userError = new CliError.UserError({ cause: "Deployment failed" })
916 const command = Command.make("deploy", {}, () => userError)
917 const config = { version: "1.0.0", renderErrors: false } as const
918
919 const handlerFailure = yield* Effect.flip(Command.runWith(command, config)([]))
920 const parseFailure = yield* Effect.flip(Command.runWith(command, config)(["--unknown"]))
921
922 assert.strictEqual(handlerFailure, userError)
923 assert.isTrue(Runtime.getErrorReported(handlerFailure))
924 assert.instanceOf(parseFailure, CliError.ShowHelp)
925 assert.isEmpty(yield* TestConsole.errorLines)
926 assert.include((yield* TestConsole.logLines).join("\n"), "USAGE")
927 }).pipe(Effect.provide(TestLayer)))
928
929 it.effect("should still render help when automatic error rendering is disabled", () =>
930 Effect.gen(function*() {
931 const child = Command.make("child")
932 const command = Command.make("app").pipe(Command.withSubcommands([child]))
933
934 const error = yield* Effect.flip(
935 Command.runWith(command, { version: "1.0.0", renderErrors: false })([])
936 )
937
938 assert.instanceOf(error, CliError.ShowHelp)
939 assert.isEmpty(error.errors)
940 assert.include((yield* TestConsole.logLines).join("\n"), "USAGE")
941 assert.isEmpty(yield* TestConsole.errorLines)
942 }).pipe(Effect.provide(TestLayer)))
943 })
944
945 describe("withSubcommands", () => {
946 it("preserves unlisted metadata when adding subcommands", () => {
947 const withChild = Command.make("internal").pipe(
948 Command.unlisted,
949 Command.withSubcommands([Command.make("child")])
950 )
951
952 assert.isTrue(withChild.unlisted)
953 })
954
955 it("preserves unlisted metadata when adding shared flags", () => {
956 const withShared = Command.make("internal").pipe(
957 Command.unlisted,
958 Command.withSharedFlags({ verbose: Flag.Boolean("verbose") })
959 )
960
961 assert.isTrue(withShared.unlisted)
962 })
963
964 it.effect("should execute parent handler when no subcommand provided", () =>
965 Effect.gen(function*() {
966 const command = "git"
967
968 yield* Cli.run([command, "--verbose"])
969
970 const actions = yield* TestActions.getActions
971 assert.strictEqual(actions.length, 1)
972 assert.deepStrictEqual(actions[0], { command, details: { verbose: true } })
973 }).pipe(Effect.provide(TestLayer)))
974
975 it.effect("should execute subcommand when provided", () =>
976 Effect.gen(function*() {
977 const command = ["git", "clone"]
978 const repository = "myrepo"
979 const branch = "develop"
980
981 yield* Cli.run([...command, repository, "--branch", branch])
982
983 const actions = yield* TestActions.getActions
984 assert.strictEqual(actions.length, 1)
985 assert.deepStrictEqual(actions[0], {
986 command: command.join(" "),
987 details: { repository, branch }
988 })
989 }).pipe(Effect.provide(TestLayer)))
990
991 it.effect("should execute subcommand when provided by alias", () =>
992 Effect.gen(function*() {
993 const executed: Array<string> = []
994
995 const plan = Command.make("plan", {}, () => Effect.sync(() => executed.push("plan"))).pipe(
996 Command.withAlias("p")
997 )
998 const root = Command.make("tool").pipe(Command.withSubcommands([plan]))
999 const runRoot = Command.runWith(root, { version: "1.0.0" })
1000
1001 yield* runRoot(["p"])
1002
1003 assert.deepStrictEqual(executed, ["plan"])
1004 }).pipe(Effect.provide(TestLayer)))
1005
1006 it.effect("should accept grouped and ungrouped subcommands", () =>
1007 Effect.gen(function*() {
1008 const executed: Array<string> = []
1009
1010 const root = Command.make("tool")
1011 const login = Command.make("login", {}, () => Effect.sync(() => executed.push("login")))
1012 const logout = Command.make("logout", {}, () => Effect.sync(() => executed.push("logout")))
1013 const status = Command.make("status", {}, () => Effect.sync(() => executed.push("status")))
1014
1015 const cli = root.pipe(Command.withSubcommands([
1016 {
1017 group: "Auth commands",
1018 commands: [login, logout]
1019 },
1020 status
1021 ]))
1022
1023 const runCli = Command.runWith(cli, { version: "1.0.0" })
1024
1025 yield* runCli(["login"])
1026 yield* runCli(["status"])
1027
1028 assert.deepStrictEqual(executed, ["login", "status"])
1029 }).pipe(Effect.provide(TestLayer)))
1030
1031 it.effect("should handle multiple subcommands correctly", () =>
1032 Effect.gen(function*() {
1033 yield* Cli.run(["git", "clone", "repo1"])
1034 yield* Cli.run(["git", "add", "file1", "--update"])
1035 yield* Cli.run(["git", "status", "--short"])
1036
1037 const actions = yield* TestActions.getActions
1038 assert.strictEqual(actions.length, 3)
1039 assert.deepStrictEqual(actions[0], {
1040 command: "git clone",
1041 details: { repository: "repo1", branch: "main" }
1042 })
1043 assert.deepStrictEqual(actions[1], {
1044 command: "git add",
1045 details: { files: "file1", update: true }
1046 })
1047 assert.deepStrictEqual(actions[2], {
1048 command: "git status",
1049 details: { short: true }
1050 })
1051 }).pipe(Effect.provide(TestLayer)))
1052
1053 it.effect("should handle nested config structures in subcommands", () =>
1054 Effect.gen(function*() {
1055 const service = "api-service"
1056 const environment = "production"
1057 const dbHost = "localhost"
1058 const dbPort = 5432
1059
1060 yield* Cli.run([
1061 "app",
1062 "--env",
1063 "prod",
1064 "deploy",
1065 service,
1066 environment,
1067 "--db-host",
1068 dbHost,
1069 "--db-port",
1070 dbPort.toString(),
1071 "--dry-run"
1072 ])
1073
1074 const actions = yield* TestActions.getActions
1075 assert.strictEqual(actions.length, 1)
1076 assert.deepStrictEqual(actions[0], {
1077 command: "deploy",
1078 details: {
1079 service,
1080 environment,
1081 database: { host: dbHost, port: dbPort },
1082 dryRun: true
1083 }
1084 })
1085 }).pipe(Effect.provide(TestLayer)))
1086
1087 it.effect("should execute parent handler with options when no subcommand provided", () =>
1088 Effect.gen(function*() {
1089 // Use git command with only --verbose flag (git doesn't have an "unknown" option)
1090 // This will execute the parent git handler instead of trying to match subcommands
1091 yield* Cli.run(["git", "--verbose"])
1092
1093 const actions = yield* TestActions.getActions
1094 assert.strictEqual(actions.length, 1)
1095 assert.deepStrictEqual(actions[0], {
1096 command: "git",
1097 details: { verbose: true }
1098 })
1099 }).pipe(Effect.provide(TestLayer)))
1100
1101 it.effect("should propagate subcommand errors", () =>
1102 Effect.gen(function*() {
1103 const result = yield* Effect.flip(Cli.run(["test-failing", "--input", "test"]))
1104 assert.strictEqual(result, "Handler error")
1105 }).pipe(Effect.provide(TestLayer)))
1106
1107 it.effect("should provide shared parent context to subcommands", () =>
1108 Effect.gen(function*() {
1109 const messages: Array<string> = []
1110
1111 const parent = Command.make("parent").pipe(
1112 Command.withSharedFlags({
1113 verbose: Flag.Boolean("verbose"),
1114 config: Flag.String("config")
1115 })
1116 )
1117
1118 // Create subcommand that accesses parent context
1119 const child = Command.make("child", { action: Flag.String("action") }, (config) =>
1120 Effect.gen(function*() {
1121 const parentConfig = yield* parent
1122 messages.push(`child: parent.verbose=${parentConfig.verbose}`)
1123 messages.push(`child: parent.config=${parentConfig.config}`)
1124 messages.push(`child: action=${config.action}`)
1125 }))
1126
1127 const combined = parent.pipe(Command.withSubcommands([child]))
1128
1129 const runCommand = Command.runWith(combined, {
1130 version: "1.0.0"
1131 })
1132
1133 yield* runCommand([
1134 "--verbose",
1135 "--config",
1136 "prod.json",
1137 "child",
1138 "--action",
1139 "deploy"
1140 ])
1141
1142 assert.deepStrictEqual(messages, [
1143 "child: parent.verbose=true",
1144 "child: parent.config=prod.json",
1145 "child: action=deploy"
1146 ])
1147 }).pipe(Effect.provide(TestLayer)))
1148
1149 it.effect("should accept shared parent flags before or after a subcommand (npm-style)", () =>
1150 Effect.gen(function*() {
1151 const messages: Array<string> = []
1152
1153 const root = Command.make("npm").pipe(
1154 Command.withSharedFlags({
1155 global: Flag.Boolean("global")
1156 })
1157 )
1158
1159 const install = Command.make("install", {
1160 pkg: Flag.String("pkg")
1161 }, (config) =>
1162 Effect.gen(function*() {
1163 const parentConfig = yield* root
1164 messages.push(`install: global=${parentConfig.global}, pkg=${config.pkg}`)
1165 }))
1166
1167 const npm = root.pipe(Command.withSubcommands([install]))
1168
1169 const runNpm = Command.runWith(npm, { version: "1.0.0" })
1170
1171 yield* runNpm(["--global", "install", "--pkg", "cowsay"])
1172 yield* runNpm(["install", "--pkg", "cowsay", "--global"])
1173
1174 assert.deepStrictEqual(messages, [
1175 "install: global=true, pkg=cowsay",
1176 "install: global=true, pkg=cowsay"
1177 ])
1178 }).pipe(Effect.provide(TestLayer)))
1179
1180 it.effect("should parse shared flags for parent handlers", () =>
1181 Effect.gen(function*() {
1182 const messages: Array<string> = []
1183
1184 const root = Command.make("tool", {
1185 workspace: Flag.String("workspace")
1186 }).pipe(
1187 Command.withSharedFlags({
1188 model: Flag.String("model")
1189 }),
1190 Command.withHandler((config) =>
1191 Effect.sync(() => {
1192 messages.push(`root: workspace=${config.workspace}, model=${config.model}`)
1193 })
1194 )
1195 )
1196
1197 const runRoot = Command.runWith(root, { version: "1.0.0" })
1198 yield* runRoot(["--workspace", "docs", "--model", "gpt-4o"])
1199
1200 assert.deepStrictEqual(messages, ["root: workspace=docs, model=gpt-4o"])
1201 }).pipe(Effect.provide(TestLayer)))
1202
1203 it.effect("should allow child flags to reuse parent local flag names", () =>
1204 Effect.gen(function*() {
1205 const messages: Array<string> = []
1206
1207 const root = Command.make("tool", {
1208 model: Flag.String("model")
1209 }).pipe(
1210 Command.withSharedFlags({
1211 workspace: Flag.String("workspace")
1212 })
1213 )
1214
1215 const chat = Command.make("chat", {
1216 model: Flag.String("model")
1217 }, (config) =>
1218 Effect.gen(function*() {
1219 const parent = yield* root
1220 messages.push(`chat: workspace=${parent.workspace}, model=${config.model}`)
1221 }))
1222
1223 const cli = root.pipe(Command.withSubcommands([chat]))
1224 const runCli = Command.runWith(cli, { version: "1.0.0" })
1225
1226 yield* runCli(["--workspace", "docs", "chat", "--model", "gpt-4o-mini"])
1227
1228 assert.deepStrictEqual(messages, ["chat: workspace=docs, model=gpt-4o-mini"])
1229 }).pipe(Effect.provide(TestLayer)))
1230
1231 it.effect("should reject parent local flags on subcommand paths", () =>
1232 Effect.gen(function*() {
1233 const root = Command.make("tool", {
1234 model: Flag.String("model")
1235 }).pipe(
1236 Command.withSharedFlags({
1237 workspace: Flag.String("workspace")
1238 })
1239 )
1240
1241 const chat = Command.make("chat", {
1242 topic: Flag.String("topic")
1243 })
1244
1245 const cli = root.pipe(Command.withSubcommands([chat]))
1246 const runCli = Command.runWith(cli, { version: "1.0.0" })
1247
1248 yield* runCli(["--workspace", "docs", "--model", "gpt-4o", "chat", "--topic", "bugs"]).pipe(
1249 Effect.catchTag("ShowHelp", () => Effect.void)
1250 )
1251
1252 const stderr = yield* TestConsole.errorLines
1253 const text = stderr.join("\n")
1254 assert.isTrue(text.includes("Unrecognized flag: --model in command tool chat"))
1255 }).pipe(Effect.provide(TestLayer)))
1256
1257 it.effect("should allow direct accessing parent config in subcommands", () =>
1258 Effect.gen(function*() {
1259 const messages: Array<string> = []
1260
1261 const root = Command.make("npm").pipe(
1262 Command.withSharedFlags({
1263 global: Flag.Boolean("global")
1264 })
1265 )
1266
1267 const install = Command.make("install", {
1268 pkg: Flag.String("pkg")
1269 }, (config) =>
1270 Effect.gen(function*() {
1271 const parentConfig = yield* root
1272 messages.push(`install: global=${parentConfig.global}, pkg=${config.pkg}`)
1273 }))
1274
1275 const npm = root.pipe(Command.withSubcommands([install]))
1276
1277 const runNpm = Command.runWith(npm, { version: "1.0.0" })
1278 yield* runNpm(["--global", "install", "--pkg", "effect"])
1279
1280 assert.deepStrictEqual(messages, [
1281 "install: global=true, pkg=effect"
1282 ])
1283 }).pipe(Effect.provide(TestLayer)))
1284
1285 it.effect("should handle nested subcommands with context sharing", () =>
1286 Effect.gen(function*() {
1287 const messages: Array<string> = []
1288
1289 const root = Command.make("app").pipe(
1290 Command.withSharedFlags({
1291 env: Flag.String("env")
1292 }),
1293 Command.withHandler((config) =>
1294 Effect.gen(function*() {
1295 messages.push(`root: env=${config.env}`)
1296 })
1297 )
1298 )
1299
1300 const service = Command.make("service").pipe(
1301 Command.withSharedFlags({
1302 name: Flag.String("name")
1303 }),
1304 Command.withHandler((config) =>
1305 Effect.gen(function*() {
1306 const rootConfig = yield* root
1307 messages.push(`service: root.env=${rootConfig.env}`)
1308 messages.push(`service: name=${config.name}`)
1309 })
1310 )
1311 )
1312
1313 const deploy = Command.make("deploy", {
1314 targetVersion: Flag.String("target-version")
1315 }, (config) =>
1316 Effect.gen(function*() {
1317 const rootConfig = yield* root
1318 const serviceConfig = yield* service
1319 messages.push(`deploy: root.env=${rootConfig.env}`)
1320 messages.push(`deploy: service.name=${serviceConfig.name}`)
1321 messages.push(`deploy: target-version=${config.targetVersion}`)
1322 }))
1323
1324 const serviceWithDeploy = service.pipe(
1325 Command.withSubcommands([deploy])
1326 )
1327
1328 const appWithService = root.pipe(
1329 Command.withSubcommands([serviceWithDeploy])
1330 )
1331
1332 const runCommand = Command.runWith(appWithService, { version: "1.0.0" })
1333 yield* runCommand([
1334 "--env",
1335 "production",
1336 "service",
1337 "--name",
1338 "api",
1339 "deploy",
1340 "--target-version",
1341 "1.0.0"
1342 ])
1343
1344 assert.deepStrictEqual(messages, [
1345 "deploy: root.env=production",
1346 "deploy: service.name=api",
1347 "deploy: target-version=1.0.0"
1348 ])
1349 }).pipe(Effect.provide(TestLayer)))
1350
1351 it.effect("should handle boolean shared flags before subcommands", () =>
1352 Effect.gen(function*() {
1353 const messages: Array<string> = []
1354
1355 const parent = Command.make("app").pipe(
1356 Command.withSharedFlags({
1357 verbose: Flag.Boolean("verbose"),
1358 config: Flag.String("config")
1359 }),
1360 Command.withHandler((config) =>
1361 Effect.gen(function*() {
1362 messages.push(`parent: verbose=${config.verbose}, config=${config.config}`)
1363 })
1364 )
1365 )
1366
1367 const deploy = Command.make("deploy", {
1368 targetVersion: Flag.String("target-version")
1369 }, (config) =>
1370 Effect.gen(function*() {
1371 const parentConfig = yield* parent
1372 messages.push(`deploy: parent.verbose=${parentConfig.verbose}`)
1373 messages.push(`deploy: target-version=${config.targetVersion}`)
1374 }))
1375
1376 const combined = parent.pipe(
1377 Command.withSubcommands([deploy])
1378 )
1379
1380 const runCommand = Command.runWith(combined, { version: "1.0.0" })
1381 yield* runCommand([
1382 "--config",
1383 "prod.json",
1384 "--verbose",
1385 "deploy",
1386 "--target-version",
1387 "1.0.0"
1388 ])
1389
1390 assert.deepStrictEqual(messages, [
1391 "deploy: parent.verbose=true",
1392 "deploy: target-version=1.0.0"
1393 ])
1394 }).pipe(Effect.provide(TestLayer)))
1395
1396 it.effect("should merge stacked withSharedFlags calls", () =>
1397 Effect.gen(function*() {
1398 const messages: Array<string> = []
1399
1400 const root = Command.make("tool", {
1401 dryRun: Flag.Boolean("dry-run")
1402 }).pipe(
1403 Command.withSharedFlags({
1404 verbose: Flag.Boolean("verbose")
1405 }),
1406 Command.withSharedFlags({
1407 format: Flag.String("format")
1408 })
1409 )
1410
1411 const child = Command.make("run", {}, () =>
1412 Effect.gen(function*() {
1413 const parent = yield* root
1414 messages.push(`verbose=${parent.verbose}, format=${parent.format}`)
1415 }))
1416
1417 const cli = root.pipe(Command.withSubcommands([child]))
1418 const runCli = Command.runWith(cli, { version: "1.0.0" })
1419
1420 yield* runCli(["--verbose", "--format", "json", "run"])
1421
1422 assert.deepStrictEqual(messages, ["verbose=true, format=json"])
1423 }).pipe(Effect.provide(TestLayer)))
1424
1425 it.effect("should work with Command.provide when shared flags are present", () =>
1426 Effect.gen(function*() {
1427 const messages: Array<string> = []
1428
1429 const DbUrl = Context.Service<never, string>("effect/test/unstable/cli/DbUrl")
1430
1431 const root = Command.make("app", {
1432 dryRun: Flag.Boolean("dry-run")
1433 }).pipe(
1434 Command.withSharedFlags({
1435 env: Flag.String("env")
1436 })
1437 )
1438
1439 const deploy = Command.make("deploy", {}, () =>
1440 Effect.gen(function*() {
1441 const parent = yield* root
1442 const dbUrl = yield* DbUrl
1443 messages.push(`env=${parent.env}, db=${dbUrl}`)
1444 }))
1445
1446 const cli = root.pipe(
1447 Command.withSubcommands([deploy]),
1448 Command.provideSync(DbUrl, (input) => `db://${input.env}.example.com`)
1449 )
1450
1451 const runCli = Command.runWith(cli, { version: "1.0.0" })
1452 yield* runCli(["--env", "production", "deploy"])
1453
1454 assert.deepStrictEqual(messages, ["env=production, db=db://production.example.com"])
1455 }).pipe(Effect.provide(TestLayer)))
1456
1457 it.effect("should reject shared/child flag collisions when withSharedFlags is applied after withSubcommands", () =>
1458 Effect.sync(() => {
1459 const child = Command.make("run", {
1460 verbose: Flag.Boolean("verbose")
1461 })
1462
1463 assert.throws(() =>
1464 Command.make("tool").pipe(
1465 Command.withSubcommands([child]),
1466 Command.withSharedFlags({
1467 verbose: Flag.Boolean("verbose")
1468 })
1469 )
1470 )
1471 }))
1472
1473 it.effect("should require withSharedFlags before withSubcommands for subcommand access", () =>
1474 Effect.gen(function*() {
1475 const messages: Array<string> = []
1476
1477 // withSharedFlags BEFORE withSubcommands — correct ordering
1478 const root = Command.make("tool").pipe(
1479 Command.withSharedFlags({
1480 verbose: Flag.Boolean("verbose")
1481 })
1482 )
1483
1484 const child = Command.make("run", {}, () =>
1485 Effect.gen(function*() {
1486 const parent = yield* root
1487 messages.push(`verbose=${parent.verbose}`)
1488 }))
1489
1490 const cli = root.pipe(
1491 Command.withSubcommands([child])
1492 )
1493
1494 const runCli = Command.runWith(cli, { version: "1.0.0" })
1495 yield* runCli(["--verbose", "run"])
1496
1497 assert.deepStrictEqual(messages, ["verbose=true"])
1498 }).pipe(Effect.provide(TestLayer)))
1499
1500 it.effect("should treat tokens after -- as operands (no subcommand or flags)", () =>
1501 Effect.gen(function*() {
1502 const captured: Array<ReadonlyArray<string>> = []
1503 let childInvoked = false
1504
1505 const root = Command.make("tool", {
1506 rest: Argument.String("rest").pipe(Argument.variadic())
1507 }, (config) =>
1508 Effect.sync(() => {
1509 captured.push(config.rest)
1510 }))
1511
1512 const child = Command.make("child", {
1513 value: Flag.String("value")
1514 }, () =>
1515 Effect.sync(() => {
1516 childInvoked = true
1517 }))
1518
1519 const cli = root.pipe(Command.withSubcommands([child]))
1520 const runCli = Command.runWith(cli, { version: "1.0.0" })
1521
1522 yield* runCli(["--", "child", "--value", "x"])
1523
1524 assert.isFalse(childInvoked)
1525 assert.deepStrictEqual(captured, [["child", "--value", "x"]])
1526 }).pipe(Effect.provide(TestLayer)))
1527
1528 it.effect("should pass trailing operands to the selected subcommand", () =>
1529 Effect.gen(function*() {
1530 const captured: Array<ReadonlyArray<string>> = []
1531
1532 const child = Command.make("child", {
1533 values: Argument.String("value").pipe(Argument.variadic())
1534 }, ({ values }) => Effect.sync(() => captured.push(values)))
1535
1536 const cli = Command.make("tool").pipe(Command.withSubcommands([child]))
1537
1538 yield* Command.runWith(cli, { version: "1.0.0" })([
1539 "child",
1540 "--",
1541 "value",
1542 "--literal",
1543 "-x"
1544 ])
1545
1546 assert.deepStrictEqual(captured, [["value", "--literal", "-x"]])
1547 }).pipe(Effect.provide(TestLayer)))
1548
1549 it.effect("should pass trailing operands through nested subcommands", () =>
1550 Effect.gen(function*() {
1551 const captured: Array<string> = []
1552
1553 const child = Command.make("child", {
1554 value: Argument.String("value")
1555 }, ({ value }) => Effect.sync(() => captured.push(value)))
1556 const group = Command.make("group").pipe(Command.withSubcommands([child]))
1557 const cli = Command.make("tool").pipe(Command.withSubcommands([group]))
1558
1559 yield* Command.runWith(cli, { version: "1.0.0" })(["group", "child", "--", "-literal"])
1560
1561 assert.deepStrictEqual(captured, ["-literal"])
1562 }).pipe(Effect.provide(TestLayer)))
1563
1564 it.effect("should allow no trailing operands after -- for a subcommand", () =>
1565 Effect.gen(function*() {
1566 let invoked = false
1567
1568 const child = Command.make("child", {}, () =>
1569 Effect.sync(() => {
1570 invoked = true
1571 }))
1572 const cli = Command.make("tool").pipe(Command.withSubcommands([child]))
1573
1574 yield* Command.runWith(cli, { version: "1.0.0" })(["child", "--"])
1575
1576 assert.isTrue(invoked)
1577 }).pipe(Effect.provide(TestLayer)))
1578
1579 it.effect("should preserve trailing operands for a leaf command", () =>
1580 Effect.gen(function*() {
1581 const captured: Array<ReadonlyArray<string>> = []
1582
1583 const command = Command.make("tool", {
1584 values: Argument.String("value").pipe(Argument.variadic())
1585 }, ({ values }) => Effect.sync(() => captured.push(values)))
1586
1587 yield* Command.runWith(command, { version: "1.0.0" })(["--", "--literal", "-x"])
1588
1589 assert.deepStrictEqual(captured, [["--literal", "-x"]])
1590 }).pipe(Effect.provide(TestLayer)))
1591
1592 it.effect("should preserve inherited flags around a subcommand with trailing operands", () =>
1593 Effect.gen(function*() {
1594 const captured: Array<{ before: boolean; after: boolean; value: string }> = []
1595
1596 const root = Command.make("tool").pipe(
1597 Command.withSharedFlags({
1598 before: Flag.Boolean("before"),
1599 after: Flag.Boolean("after")
1600 })
1601 )
1602 const child = Command.make("child", {
1603 value: Argument.String("value")
1604 }, ({ value }) =>
1605 Effect.gen(function*() {
1606 const parent = yield* root
1607 captured.push({ before: parent.before, after: parent.after, value })
1608 }))
1609 const cli = root.pipe(Command.withSubcommands([child]))
1610
1611 yield* Command.runWith(cli, { version: "1.0.0" })([
1612 "--before",
1613 "child",
1614 "--after",
1615 "--",
1616 "-literal"
1617 ])
1618
1619 assert.deepStrictEqual(captured, [{ before: true, after: true, value: "-literal" }])
1620 }).pipe(Effect.provide(TestLayer)))
1621
1622 it.effect("should coerce boolean flags to false when given falsey literals", () =>
1623 Effect.gen(function*() {
1624 const captured: Array<boolean> = []
1625
1626 const cmd = Command.make("tool", {
1627 verbose: Flag.Boolean("verbose")
1628 }, (config) => Effect.sync(() => captured.push(config.verbose)))
1629
1630 const runCmd = Command.runWith(cmd, { version: "1.0.0" })
1631
1632 yield* runCmd(["--verbose", "false"])
1633 yield* runCmd(["--verbose", "0"])
1634
1635 assert.deepStrictEqual(captured, [false, false])
1636 }).pipe(Effect.provide(TestLayer)))
1637
1638 it.effect("should parse required boolean flags when explicitly enabled or disabled", () =>
1639 Effect.gen(function*() {
1640 const captured: Array<boolean> = []
1641
1642 const cmd = Command.make("tool", {
1643 verbose: Flag.Boolean("verbose")
1644 }, (config) => Effect.sync(() => captured.push(config.verbose)))
1645
1646 const runCmd = Command.runWith(cmd, { version: "1.0.0" })
1647
1648 yield* runCmd(["--verbose"])
1649 yield* runCmd(["--no-verbose"])
1650
1651 assert.deepStrictEqual(captured, [true, false])
1652 }).pipe(Effect.provide(TestLayer)))
1653
1654 it.effect("should support optional boolean flags and --no-<flag> negation", () =>
1655 Effect.gen(function*() {
1656 const captured: Array<Option.Option<boolean>> = []
1657
1658 const cmd = Command.make("tool", {
1659 open: Flag.Boolean("open").pipe(Flag.optional)
1660 }, (config) => Effect.sync(() => captured.push(config.open)))
1661
1662 const runCmd = Command.runWith(cmd, { version: "1.0.0" })
1663
1664 yield* runCmd([])
1665 yield* runCmd(["--open"])
1666 yield* runCmd(["--no-open"])
1667
1668 assert.deepStrictEqual(captured, [
1669 Option.none(),
1670 Option.some(true),
1671 Option.some(false)
1672 ])
1673 }).pipe(Effect.provide(TestLayer)))
1674
1675 it.effect("should prefer canonical negation over conflicting aliases", () =>
1676 Effect.gen(function*() {
1677 const captured: Array<readonly [boolean, boolean]> = []
1678
1679 const cmd = Command.make("tool", {
1680 prompt: Flag.Boolean("prompt"),
1681 force: Flag.Boolean("force").pipe(
1682 Flag.withAlias("no-prompt"),
1683 Flag.withDefault(false)
1684 )
1685 }, (config) => Effect.sync(() => captured.push([config.prompt, config.force] as const)))
1686
1687 const runCmd = Command.runWith(cmd, { version: "1.0.0" })
1688
1689 yield* runCmd(["--no-prompt"])
1690
1691 assert.deepStrictEqual(captured, [[false, false]])
1692 }).pipe(Effect.provide(TestLayer)))
1693
1694 it.effect("should reject explicit values for canonical negated flags", () =>
1695 Effect.gen(function*() {
1696 let invoked = false
1697
1698 const cmd = Command.make("tool", {
1699 open: Flag.Boolean("open")
1700 }, () =>
1701 Effect.sync(() => {
1702 invoked = true
1703 }))
1704
1705 const runCmd = Command.runWith(cmd, { version: "1.0.0" })
1706
1707 yield* runCmd(["--no-open", "false"]).pipe(Effect.catchTag("ShowHelp", () => Effect.void))
1708 yield* runCmd(["--no-open=true"]).pipe(Effect.catchTag("ShowHelp", () => Effect.void))
1709
1710 assert.isFalse(invoked)
1711
1712 const stderr = yield* TestConsole.errorLines
1713 assert.isTrue(stderr.join("\n").includes("use --no-open by itself to set --open to false"))
1714 }).pipe(Effect.provide(TestLayer)))
1715
1716 it.effect("should not leak a negated flag literal into subcommand parsing", () =>
1717 Effect.gen(function*() {
1718 let parentInvoked = false
1719 let childInvoked = false
1720
1721 const child = Command.make("child", {}, () =>
1722 Effect.sync(() => {
1723 childInvoked = true
1724 }))
1725
1726 const root = Command.make("tool", {
1727 open: Flag.Boolean("open")
1728 }, () =>
1729 Effect.sync(() => {
1730 parentInvoked = true
1731 })).pipe(Command.withSubcommands([child]))
1732
1733 const runCmd = Command.runWith(root, { version: "1.0.0" })
1734
1735 yield* runCmd(["--no-open", "false", "child"]).pipe(Effect.catchTag("ShowHelp", () => Effect.void))
1736
1737 assert.isFalse(parentInvoked)
1738 assert.isFalse(childInvoked)
1739
1740 const stderr = yield* TestConsole.errorLines
1741 const output = stderr.join("\n")
1742 assert.isTrue(output.includes("use --no-open by itself to set --open to false"))
1743 assert.isFalse(output.includes("Unknown subcommand \"false\""))
1744 }).pipe(Effect.provide(TestLayer)))
1745
1746 it.effect("should fail when a required flag value is missing", () =>
1747 Effect.gen(function*() {
1748 let invoked = false
1749
1750 const cmd = Command.make("tool", {
1751 pkg: Flag.String("pkg")
1752 }, () =>
1753 Effect.sync(() => {
1754 invoked = true
1755 }))
1756
1757 const runCmd = Command.runWith(cmd, { version: "1.0.0" })
1758
1759 yield* runCmd(["--pkg"]).pipe(Effect.catchTag("ShowHelp", () => Effect.void))
1760
1761 assert.isFalse(invoked)
1762 const stderr = yield* TestConsole.errorLines
1763 assert.isAbove(stderr.length, 0)
1764 assert.isTrue(stderr.join("\n").includes("--pkg"))
1765 }).pipe(Effect.provide(TestLayer)))
1766
1767 it.effect("should parse combined short flags including one that expects a value", () =>
1768 Effect.gen(function*() {
1769 const captured: Array<{ all: boolean; verbose: boolean; pkg: string }> = []
1770
1771 const cmd = Command.make("tool", {
1772 all: Flag.Boolean("all").pipe(Flag.withAlias("a")),
1773 verbose: Flag.Boolean("verbose").pipe(Flag.withAlias("v")),
1774 pkg: Flag.String("pkg").pipe(Flag.withAlias("p"))
1775 }, (config) =>
1776 Effect.sync(() => {
1777 captured.push(config)
1778 }))
1779
1780 const runCmd = Command.runWith(cmd, { version: "1.0.0" })
1781
1782 yield* runCmd(["-avp", "cowsay"])
1783
1784 assert.deepStrictEqual(captured, [{ all: true, verbose: true, pkg: "cowsay" }])
1785 }).pipe(Effect.provide(TestLayer)))
1786
1787 it.effect("should honor -- while still applying parent flags", () =>
1788 Effect.gen(function*() {
1789 const captured: Array<{ global: boolean; rest: ReadonlyArray<string> }> = []
1790
1791 const root = Command.make("tool", {
1792 global: Flag.Boolean("global"),
1793 rest: Argument.String("rest").pipe(Argument.variadic())
1794 }, (config) => Effect.sync(() => captured.push({ global: config.global, rest: config.rest })))
1795
1796 const child = Command.make("child", {
1797 value: Flag.String("value")
1798 })
1799
1800 const cli = root.pipe(Command.withSubcommands([child]))
1801 const runCli = Command.runWith(cli, { version: "1.0.0" })
1802
1803 yield* runCli(["--global", "--", "child", "--value", "x"])
1804
1805 assert.deepStrictEqual(captured, [{ global: true, rest: ["child", "--value", "x"] }])
1806 }).pipe(Effect.provide(TestLayer)))
1807
1808 it.effect("should report unknown flag even when subcommand is unknown", () =>
1809 Effect.gen(function*() {
1810 const root = Command.make("root", {})
1811 const known = Command.make("known", {})
1812 const cli = root.pipe(Command.withSubcommands([known]))
1813 const runCli = Command.runWith(cli, { version: "1.0.0" })
1814
1815 yield* runCli(["--unknown", "bogus"]).pipe(Effect.catchTag("ShowHelp", () => Effect.void))
1816
1817 const stderr = yield* TestConsole.errorLines
1818 const text = stderr.join("\n")
1819 assert.isTrue(text.includes("Unrecognized flag: --unknown"))
1820 // Parser may also surface the unknown subcommand; ensure at least one error is emitted.
1821 }).pipe(Effect.provide(TestLayer)))
1822
1823 it.effect("should keep variadic argument order when options are interleaved", () =>
1824 Effect.gen(function*() {
1825 const captured: Array<{ files: ReadonlyArray<string>; verbose: boolean }> = []
1826
1827 const cmd = Command.make("copy", {
1828 verbose: Flag.Boolean("verbose"),
1829 files: Argument.String("file").pipe(Argument.variadic())
1830 }, (config) => Effect.sync(() => captured.push({ files: config.files, verbose: config.verbose })))
1831
1832 const runCmd = Command.runWith(cmd, { version: "1.0.0" })
1833
1834 yield* runCmd(["--verbose", "a.txt", "b.txt", "--verbose", "c.txt"])
1835
1836 assert.deepStrictEqual(captured, [{ files: ["a.txt", "b.txt", "c.txt"], verbose: true }])
1837 }).pipe(Effect.provide(TestLayer)))
1838
1839 it.effect("should support options before, after, or between operands (relaxed POSIX Syntax Guideline No. 9)", () =>
1840 Effect.gen(function*() {
1841 // Test both orderings work: POSIX (options before operands) and modern (mixed)
1842
1843 // Test 1: POSIX style - options before operands
1844 yield* Cli.run([
1845 "copy",
1846 "--recursive",
1847 "--force",
1848 "src.txt",
1849 "dest.txt"
1850 ])
1851
1852 // Test 2: Modern style - options after operands
1853 yield* Cli.run([
1854 "copy",
1855 "src.txt",
1856 "dest.txt",
1857 "--recursive",
1858 "--force"
1859 ])
1860
1861 // Test 3: Mixed style - some options before, some after
1862 yield* Cli.run([
1863 "copy",
1864 "--recursive",
1865 "src.txt",
1866 "dest.txt",
1867 "--force"
1868 ])
1869
1870 // Check all three commands worked
1871 const actions = yield* TestActions.getActions
1872 assert.strictEqual(actions.length, 3)
1873
1874 for (let i = 0; i < 3; i++) {
1875 assert.strictEqual(actions[i].command, "copy")
1876 assert.strictEqual(actions[i].details.recursive, true)
1877 assert.strictEqual(actions[i].details.force, true)
1878 assert.strictEqual(actions[i].details.bufferSize, 64)
1879 // Source and destination will be resolved paths - just check they contain the filenames
1880 assert.isTrue(String(actions[i].details.source).includes("src.txt"))
1881 assert.isTrue(String(actions[i].details.destination).includes("dest.txt"))
1882 }
1883 }).pipe(Effect.provide(TestLayer)))
1884
1885 it.effect("should suggest similar subcommands for unknown subcommands", () =>
1886 Effect.gen(function*() {
1887 yield* Cli.run(["cpy"]).pipe(Effect.catchTag("ShowHelp", () => Effect.void))
1888
1889 const errorOutput = yield* TestConsole.errorLines
1890 const errorText = errorOutput.join("\n")
1891 expect(errorText).toMatchInlineSnapshot(`
1892 "
1893 ERROR
1894 Unknown subcommand "cpy" for "mycli"
1895
1896 Did you mean this?
1897 copy"
1898 `)
1899 }).pipe(Effect.provide(TestLayer)))
1900
1901 it.effect("should suggest similar subcommands for nested unknown subcommands", () =>
1902 Effect.gen(function*() {
1903 yield* Cli.run(["admin", "usrs", "list"]).pipe(Effect.catchTag("ShowHelp", () => Effect.void))
1904
1905 // Capture the error output
1906 const errorOutput = yield* TestConsole.errorLines
1907 const errorText = errorOutput.join("\n")
1908 expect(errorText).toMatchInlineSnapshot(`
1909 "
1910 ERROR
1911 Unknown subcommand "usrs" for "mycli admin"
1912
1913 Did you mean this?
1914 users"
1915 `)
1916 }).pipe(Effect.provide(TestLayer)))
1917
1918 it.effect("should suggest similar options for unrecognized options", () =>
1919 Effect.gen(function*() {
1920 yield* Cli.run(["--debugs", "copy", "src.txt", "dest.txt"]).pipe(Effect.catchTag("ShowHelp", () => Effect.void))
1921
1922 const errorOutput = yield* TestConsole.errorLines
1923 const errorText = errorOutput.join("\n")
1924 expect(errorText).toMatchInlineSnapshot(`
1925 "
1926 ERROR
1927 Unrecognized flag: --debugs in command mycli
1928
1929 Did you mean this?
1930 --debug"
1931 `)
1932 }).pipe(Effect.provide(TestLayer)))
1933
1934 it.effect("should suggest similar short options for unrecognized short options", () =>
1935 Effect.gen(function*() {
1936 yield* Cli.run(["-u", "copy", "src.txt", "dest.txt"]).pipe(Effect.catchTag("ShowHelp", () => Effect.void))
1937
1938 const errorOutput = yield* TestConsole.errorLines
1939 const errorText = errorOutput.join("\n")
1940 expect(errorText).toMatchInlineSnapshot(`
1941 "
1942 ERROR
1943 Unrecognized flag: -u in command mycli
1944
1945 Did you mean this?
1946 -d
1947 -c
1948 -q"
1949 `)
1950 }).pipe(Effect.provide(TestLayer)))
1951
1952 it.effect("should print version and exit even with subcommands (global precedence)", () =>
1953 Effect.gen(function*() {
1954 // --version should work on a command with subcommands
1955 yield* Cli.run(["--version"])
1956
1957 const output = yield* TestConsole.logLines
1958 const outputText = output.join("\n")
1959 expect(outputText).toContain("1.0.0")
1960 }).pipe(Effect.provide(TestLayer)))
1961
1962 it.effect("should print version and exit when --version appears before subcommand", () =>
1963 Effect.gen(function*() {
1964 // --version should take precedence over subcommand
1965 yield* Cli.run(["--version", "copy", "src.txt", "dest.txt"])
1966
1967 const output = yield* TestConsole.logLines
1968 const outputText = output.join("\n")
1969 expect(outputText).toContain("1.0.0")
1970
1971 // Subcommand should NOT have run
1972 const actions = yield* TestActions.getActions
1973 assert.strictEqual(actions.length, 0)
1974 }).pipe(Effect.provide(TestLayer)))
1975
1976 it.effect("should let global flags before subcommands override non-shared parent locals", () =>
1977 Effect.gen(function*() {
1978 const captured: Array<boolean> = []
1979 let childInvoked = false
1980 const child = Command.make("child", {}, () =>
1981 Effect.sync(() => {
1982 childInvoked = true
1983 }))
1984 const command = Command.make("tool", {
1985 version: Flag.Boolean("version")
1986 }, (config) =>
1987 Effect.sync(() => {
1988 captured.push(config.version)
1989 })).pipe(Command.withSubcommands([child]))
1990
1991 const runCommand = Command.runWith(command, { version: "1.0.0" })
1992 yield* runCommand(["--version", "child"])
1993
1994 assert.deepStrictEqual(captured, [])
1995 assert.isFalse(childInvoked)
1996
1997 const output = yield* TestConsole.logLines
1998 assert.isTrue(output.some((line) => String(line).includes("1.0.0")))
1999 }).pipe(Effect.provide(TestLayer)))
2000
2001 it.effect("should keep shared flags before subcommands over globals", () =>
2002 Effect.gen(function*() {
2003 const captured: Array<boolean> = []
2004 const root = Command.make("tool", {}, () => Effect.void).pipe(
2005 Command.withSharedFlags({
2006 version: Flag.Boolean("version")
2007 })
2008 )
2009 const child = Command.make("child", {}, () =>
2010 Effect.gen(function*() {
2011 const parent = yield* root
2012 captured.push(parent.version)
2013 }))
2014 const command = root.pipe(Command.withSubcommands([child]))
2015
2016 const runCommand = Command.runWith(command, { version: "1.0.0" })
2017 yield* runCommand(["--version", "child"])
2018
2019 assert.deepStrictEqual(captured, [true])
2020
2021 const output = yield* TestConsole.logLines
2022 assert.isFalse(output.some((line) => String(line).includes("1.0.0")))
2023
2024 yield* runCommand(["child", "--version"])
2025
2026 assert.deepStrictEqual(captured, [true, true])
2027 }).pipe(Effect.provide(TestLayer)))
2028
2029 it.effect("should keep root local flags over globals when no subcommand is selected", () =>
2030 Effect.gen(function*() {
2031 const captured: Array<string> = []
2032 const child = Command.make("child", {}, () => Effect.void)
2033 const command = Command.make("tool", {
2034 version: Flag.String("version")
2035 }, (config) =>
2036 Effect.sync(() => {
2037 captured.push(config.version)
2038 })).pipe(Command.withSubcommands([child]))
2039
2040 const runCommand = Command.runWith(command, { version: "1.0.0" })
2041 yield* runCommand(["--version", "canary"])
2042
2043 assert.deepStrictEqual(captured, ["canary"])
2044
2045 const output = yield* TestConsole.logLines
2046 assert.isFalse(output.some((line) => String(line).includes("1.0.0")))
2047 }).pipe(Effect.provide(TestLayer)))
2048
2049 it.effect("should skip intervening local flag values before selecting subcommands", () =>
2050 Effect.gen(function*() {
2051 const captured: Array<{ name: string; version: boolean }> = []
2052 let childInvoked = false
2053 const child = Command.make("child", {}, () =>
2054 Effect.sync(() => {
2055 childInvoked = true
2056 }))
2057 const command = Command.make("tool", {
2058 name: Flag.String("name"),
2059 version: Flag.Boolean("version")
2060 }, (config) =>
2061 Effect.sync(() => {
2062 captured.push(config)
2063 })).pipe(Command.withSubcommands([child]))
2064
2065 const runCommand = Command.runWith(command, { version: "1.0.0" })
2066 yield* runCommand(["--version", "--name", "child"])
2067
2068 assert.deepStrictEqual(captured, [{ name: "child", version: true }])
2069 assert.isFalse(childInvoked)
2070
2071 const output = yield* TestConsole.logLines
2072 assert.isFalse(output.some((line) => String(line).includes("1.0.0")))
2073 }).pipe(Effect.provide(TestLayer)))
2074
2075 it.effect("should stop looking for subcommands after the first positional argument", () =>
2076 Effect.gen(function*() {
2077 const captured: Array<{ files: ReadonlyArray<string>; version: boolean }> = []
2078 let childInvoked = false
2079 const child = Command.make("child", {}, () =>
2080 Effect.sync(() => {
2081 childInvoked = true
2082 }))
2083 const command = Command.make("tool", {
2084 files: Argument.String("file").pipe(Argument.variadic()),
2085 version: Flag.Boolean("version")
2086 }, (config) =>
2087 Effect.sync(() => {
2088 captured.push(config)
2089 })).pipe(Command.withSubcommands([child]))
2090
2091 const runCommand = Command.runWith(command, { version: "1.0.0" })
2092 yield* runCommand(["--version", "file", "child"])
2093
2094 assert.deepStrictEqual(captured, [{ files: ["file", "child"], version: true }])
2095 assert.isFalse(childInvoked)
2096
2097 const output = yield* TestConsole.logLines
2098 assert.isFalse(output.some((line) => String(line).includes("1.0.0")))
2099 }).pipe(Effect.provide(TestLayer)))
2100
2101 it.effect("should still select subcommands after skipped flag values", () =>
2102 Effect.gen(function*() {
2103 const captured: Array<{ name: string; version: boolean }> = []
2104 let childInvoked = false
2105 const child = Command.make("child", {}, () =>
2106 Effect.sync(() => {
2107 childInvoked = true
2108 }))
2109 const command = Command.make("tool", {
2110 name: Flag.String("name"),
2111 version: Flag.Boolean("version")
2112 }, (config) =>
2113 Effect.sync(() => {
2114 captured.push(config)
2115 })).pipe(Command.withSubcommands([child]))
2116
2117 const runCommand = Command.runWith(command, { version: "1.0.0" })
2118 yield* runCommand(["--version", "--name", "value", "child"])
2119
2120 assert.deepStrictEqual(captured, [])
2121 assert.isFalse(childInvoked)
2122
2123 const output = yield* TestConsole.logLines
2124 assert.isTrue(output.some((line) => String(line).includes("1.0.0")))
2125 }).pipe(Effect.provide(TestLayer)))
2126
2127 it.effect("should let local flags override global flags on the selected command", () =>
2128 Effect.gen(function*() {
2129 const captured: Array<string> = []
2130 const release = Command.make("release", {
2131 version: Flag.String("version")
2132 }, (config) =>
2133 Effect.sync(() => {
2134 captured.push(config.version)
2135 }))
2136 const packageCommand = Command.make("package").pipe(Command.withSubcommands([release]))
2137 const command = Command.make("tool").pipe(Command.withSubcommands([packageCommand]))
2138
2139 const runCommand = Command.runWith(command, { version: "1.0.0" })
2140 yield* runCommand(["package", "release", "--version", "canary"])
2141
2142 assert.deepStrictEqual(captured, ["canary"])
2143
2144 const output = yield* TestConsole.logLines
2145 assert.isFalse(output.some((line) => String(line).includes("1.0.0")))
2146 }).pipe(Effect.provide(TestLayer)))
2147
2148 it.effect("should let local short aliases override global short aliases on the selected command", () =>
2149 Effect.gen(function*() {
2150 const Output = GlobalFlag.Setting("output")({
2151 flag: Flag.Literals("output", ["pretty", "json", "yaml"] as const).pipe(
2152 Flag.withAlias("o"),
2153 Flag.withDefault("pretty")
2154 )
2155 })
2156 const captured: Array<"summary" | "json" | "csv"> = []
2157 const report = Command.make("report", {
2158 output: Flag.Literals("output", ["summary", "json", "csv"] as const).pipe(
2159 Flag.withAlias("o"),
2160 Flag.withDefault("summary")
2161 )
2162 }, (config) =>
2163 Effect.sync(() => {
2164 captured.push(config.output)
2165 }))
2166 const command = Command.make("tool").pipe(
2167 Command.withSubcommands([report]),
2168 Command.withGlobalFlags([Output])
2169 )
2170
2171 const runCommand = Command.runWith(command, { version: "1.0.0" })
2172 yield* runCommand(["report", "-o", "csv"])
2173
2174 assert.deepStrictEqual(captured, ["csv"])
2175 }).pipe(Effect.provide(TestLayer)))
2176
2177 it.effect("should let local flags override scoped global flags from another command branch", () =>
2178 Effect.gen(function*() {
2179 const Region = GlobalFlag.Setting("region")({
2180 flag: Flag.Literals("region", ["us", "eu"] as const).pipe(Flag.withDefault("us"))
2181 })
2182 const captured: Array<string> = []
2183 let deployInvoked = false
2184 const deploy = Command.make("deploy", {}, () =>
2185 Effect.sync(() => {
2186 deployInvoked = true
2187 })).pipe(Command.withGlobalFlags([Region]))
2188 const status = Command.make("status", {
2189 region: Flag.String("region")
2190 }, (config) =>
2191 Effect.sync(() => {
2192 captured.push(config.region)
2193 }))
2194 const command = Command.make("app").pipe(Command.withSubcommands([deploy, status]))
2195
2196 const runCommand = Command.runWith(command, { version: "1.0.0" })
2197 yield* runCommand(["status", "--region", "local"])
2198
2199 assert.deepStrictEqual(captured, ["local"])
2200 assert.isFalse(deployInvoked)
2201 }).pipe(Effect.provide(TestLayer)))
2202
2203 it.effect("should print help when invoked with no arguments", () =>
2204 Effect.gen(function*() {
2205 yield* Cli.run([]).pipe(Effect.catchTag("ShowHelp", () => Effect.void))
2206
2207 // Check that help text was shown to stdout
2208 const stdout = yield* TestConsole.logLines
2209 assert.isTrue(stdout.some((line) => String(line).includes("DESCRIPTION")))
2210 assert.isTrue(stdout.some((line) => String(line).includes("comprehensive CLI tool")))
2211
2212 // Handler should NOT have run
2213 const actions = yield* TestActions.getActions
2214 assert.strictEqual(actions.length, 0)
2215 }).pipe(Effect.provide(TestLayer)))
2216
2217 it.effect("should use short descriptions in subcommand listings", () =>
2218 Effect.gen(function*() {
2219 const withShortDescription = Command.make("build").pipe(
2220 Command.withDescription("Build the project and all artifacts"),
2221 Command.withShortDescription("Build artifacts")
2222 )
2223 const withFallbackDescription = Command.make("test").pipe(
2224 Command.withDescription("Run the full test suite")
2225 )
2226 const root = Command.make("tool").pipe(
2227 Command.withSubcommands([withShortDescription, withFallbackDescription])
2228 )
2229
2230 const runCommand = Command.runWith(root, { version: "1.0.0" })
2231 yield* runCommand(["--help"])
2232
2233 const stdout = (yield* TestConsole.logLines).join("\n")
2234 assert.isTrue(stdout.includes("Build artifacts"))
2235 assert.isFalse(stdout.includes("Build the project and all artifacts"))
2236 assert.isTrue(stdout.includes("Run the full test suite"))
2237 }).pipe(Effect.provide(TestLayer)))
2238
2239 it.effect("should keep full description on the command help page", () =>
2240 Effect.gen(function*() {
2241 const child = Command.make("build").pipe(
2242 Command.withDescription("Build the project and all artifacts"),
2243 Command.withShortDescription("Build artifacts")
2244 )
2245 const root = Command.make("tool").pipe(Command.withSubcommands([child]))
2246 const runCommand = Command.runWith(root, { version: "1.0.0" })
2247
2248 yield* runCommand(["build", "--help"])
2249
2250 const stdout = (yield* TestConsole.logLines).join("\n")
2251 assert.isTrue(stdout.includes("Build the project and all artifacts"))
2252 }).pipe(Effect.provide(TestLayer)))
2253
2254 it.effect("should include short description metadata in HelpDoc subcommands", () =>
2255 Effect.gen(function*() {
2256 const child = Command.make("build").pipe(
2257 Command.withDescription("Build the project and all artifacts"),
2258 Command.withShortDescription("Build artifacts"),
2259 Command.withAlias("b")
2260 )
2261 const root = Command.make("tool").pipe(Command.withSubcommands([child]))
2262
2263 const helpDoc = toImpl(root).buildHelpDoc(["tool"])
2264 const listed = helpDoc.subcommands?.[0]?.commands[0]
2265
2266 assert.strictEqual(listed?.alias, "b")
2267 assert.strictEqual(listed?.shortDescription, "Build artifacts")
2268 assert.strictEqual(listed?.description, "Build the project and all artifacts")
2269 }))
2270 })
2271
2272 describe("withExamples", () => {
2273 it.effect("should expose examples in help docs", () =>
2274 Effect.gen(function*() {
2275 const command = Command.make("login").pipe(
2276 Command.withExamples([
2277 { command: "myapp login", description: "Log in with browser OAuth" },
2278 { command: "myapp login --token sbp_abc123", description: "Log in with a token" }
2279 ])
2280 )
2281
2282 const helpDoc = toImpl(command).buildHelpDoc(["login"])
2283
2284 assert.deepStrictEqual(helpDoc.examples, [
2285 { command: "myapp login", description: "Log in with browser OAuth" },
2286 { command: "myapp login --token sbp_abc123", description: "Log in with a token" }
2287 ])
2288 }))
2289
2290 it.effect("should preserve examples when adding subcommands", () =>
2291 Effect.gen(function*() {
2292 const root = Command.make("root").pipe(
2293 Command.withExamples([
2294 { command: "myapp root", description: "Run root command" }
2295 ]),
2296 Command.withSubcommands([Command.make("child")])
2297 )
2298
2299 const helpDoc = toImpl(root).buildHelpDoc(["root"])
2300
2301 assert.deepStrictEqual(helpDoc.examples, [
2302 { command: "myapp root", description: "Run root command" }
2303 ])
2304 }))
2305 })
2306
2307 describe("help docs", () => {
2308 it.effect("should include flag choices in help doc descriptions", () =>
2309 Effect.gen(function*() {
2310 const command = Command.make("tool", {
2311 mode: Flag.Literals("mode", ["dev", "prod"]).pipe(
2312 Flag.withDescription("Execution mode")
2313 ),
2314 format: Flag.Literals("format", ["json", "yaml"])
2315 })
2316
2317 const helpDoc = toImpl(command).buildHelpDoc(["tool"])
2318
2319 assert.deepStrictEqual(helpDoc.flags, [
2320 {
2321 name: "mode",
2322 aliases: [],
2323 type: "choice",
2324 description: Option.some("Execution mode (choices: dev, prod)"),
2325 required: true
2326 },
2327 {
2328 name: "format",
2329 aliases: [],
2330 type: "choice",
2331 description: Option.some("(choices: json, yaml)"),
2332 required: true
2333 }
2334 ])
2335 }))
2336
2337 it.effect("should render flag choices in formatted help output", () =>
2338 Effect.gen(function*() {
2339 const command = Command.make("tool", {
2340 mode: Flag.Literals("mode", ["dev", "prod"]).pipe(
2341 Flag.withDescription("Execution mode")
2342 )
2343 })
2344 const runCommand = Command.runWith(command, { version: "1.0.0" })
2345
2346 yield* runCommand(["--help"])
2347
2348 const stdout = (yield* TestConsole.logLines).join("\n")
2349 assert.isTrue(stdout.includes("Execution mode (choices: dev, prod)"))
2350 }).pipe(Effect.provide(TestLayer)))
2351 })
2352})