1/**
2 * Defines structured errors for the unstable CLI parser and runner.
3 *
4 * CLI errors describe problems such as unknown or duplicate flags, missing
5 * flags or arguments, unexpected positional arguments, invalid values, unknown
6 * subcommands, user handler failures, and requests to show command help. This
7 * module includes the `CliError` union, the `isCliError` guard, schema-backed
8 * error classes with display messages, and the `NonShowHelpErrors` union used
9 * when parse or validation errors should be shown with help output.
10 *
11 * @stability unstable
12 * @since 4.0.0
13 */
14import * as Predicate from "../Predicate.ts"
15import * as Runtime from "../Runtime.ts"
16import * as Schema from "../Schema.ts"
17
18/**
19 * @stability unstable
20 * @category type IDs
21 * @since 4.0.0
22 */
23const TypeId = "~effect/cli/CliError"
24
25/**
26 * Type guard to check if a value is a CLI error.
27 *
28 * **Example** (Checking CLI errors)
29 *
30 * ```ts import.meta.vitest
31 * import { Effect } from "effect"
32 * import { CliError } from "effect/cli"
33 *
34 * const error = new CliError.MissingOption({ option: "api-key" })
35 * const program = CliError.isCliError(error)
36 * ? Effect.succeed(error.message)
37 * : Effect.fail("Unknown error")
38 *
39 * await Effect.runPromise(program) // => "Missing required flag: --api-key"
40 * ```
41 *
42 * @stability unstable
43 * @category guards
44 * @since 4.0.0
45 */
46export const isCliError = (u: unknown): u is CliError => Predicate.hasProperty(u, TypeId)
47
48/**
49 * Union type representing all possible CLI error conditions.
50 *
51 * **Example** (Handling CLI errors)
52 *
53 * ```ts import.meta.vitest
54 * import { CliError } from "effect/cli"
55 *
56 * const describe = (error: CliError.CliError): string => {
57 * switch (error._tag) {
58 * case "UnrecognizedOption":
59 * return `Unknown flag: ${error.option}`
60 * case "MissingOption":
61 * return `Required flag missing: ${error.option}`
62 * case "InvalidValue":
63 * return `Invalid value: ${error.value} for ${error.option}`
64 * case "ShowHelp":
65 * return `Help requested for: ${error.commandPath.join(" ")}`
66 * default:
67 * return error.message
68 * }
69 * }
70 *
71 * describe(new CliError.MissingOption({ option: "token" })) // => "Required flag missing: token"
72 * ```
73 *
74 * @stability unstable
75 * @category errors
76 * @since 4.0.0
77 */
78export type CliError =
79 | UnrecognizedOption
80 | DuplicateOption
81 | MissingOption
82 | MissingArgument
83 | UnexpectedArgument
84 | InvalidValue
85 | UnknownSubcommand
86 | ShowHelp
87 | UserError
88
89/**
90 * Error thrown when an unrecognized option is encountered.
91 *
92 * **Example** (Creating unrecognized option errors)
93 *
94 * ```ts import.meta.vitest
95 * import { Effect } from "effect"
96 * import { CliError } from "effect/cli"
97 *
98 * // Creating an unrecognized option error
99 * const unrecognizedError = new CliError.UnrecognizedOption({
100 * option: "--unknown-flag",
101 * command: ["deploy", "production"],
102 * suggestions: ["--verbose", "--force"]
103 * })
104 *
105 * unrecognizedError._tag // => "UnrecognizedOption"
106 * unrecognizedError.option // => "--unknown-flag"
107 * unrecognizedError.command // => ["deploy", "production"]
108 *
109 * // In CLI parsing context
110 * const parseCommand = Effect.gen(function*() {
111 * // If parsing encounters unknown flag
112 * return yield* unrecognizedError
113 * })
114 *
115 * const parseError = await Effect.runPromise(Effect.flip(parseCommand))
116 * parseError._tag // => "UnrecognizedOption"
117 * ```
118 *
119 * @stability unstable
120 * @category errors
121 * @since 4.0.0
122 */
123export class UnrecognizedOption extends Schema.TaggedError<UnrecognizedOption>(
124 `${TypeId}/UnrecognizedOption`
125)("UnrecognizedOption", {
126 option: Schema.String,
127 command: Schema.optional(Schema.Array(Schema.String)),
128 suggestions: Schema.Array(Schema.String)
129}) {
130 /**
131 * Marks this value as a CLI parsing error for runtime guards.
132 *
133 * @stability unstable
134 * @since 4.0.0
135 */
136 readonly [TypeId] = TypeId
137
138 /**
139 * Formats the unrecognized option with command context and suggestions.
140 *
141 * @stability unstable
142 * @since 4.0.0
143 */
144 override get message() {
145 const suggestionText = this.suggestions.length > 0
146 ? `\n\n Did you mean this?\n ${this.suggestions.join("\n ")}`
147 : ""
148 const baseMessage = this.command
149 ? `Unrecognized flag: ${this.option} in command ${this.command.join(" ")}`
150 : `Unrecognized flag: ${this.option}`
151 return baseMessage + suggestionText
152 }
153}
154
155/**
156 * Error thrown when duplicate option names are detected between parent and child commands.
157 *
158 * **Example** (Creating duplicate option errors)
159 *
160 * ```ts import.meta.vitest
161 * import { CliError } from "effect/cli"
162 *
163 * const duplicateError = new CliError.DuplicateOption({
164 * option: "--verbose",
165 * parentCommand: "myapp",
166 * childCommand: "deploy"
167 * })
168 *
169 * duplicateError._tag // => "DuplicateOption"
170 * duplicateError.option // => "--verbose"
171 * duplicateError.parentCommand // => "myapp"
172 * duplicateError.childCommand // => "deploy"
173 * ```
174 *
175 * @stability unstable
176 * @category errors
177 * @since 4.0.0
178 */
179export class DuplicateOption extends Schema.TaggedError<DuplicateOption>(
180 `${TypeId}/DuplicateOption`
181)("DuplicateOption", {
182 option: Schema.String,
183 parentCommand: Schema.String,
184 childCommand: Schema.String
185}) {
186 /**
187 * Marks this value as a CLI configuration error for runtime guards.
188 *
189 * @stability unstable
190 * @since 4.0.0
191 */
192 readonly [TypeId] = TypeId
193
194 /**
195 * Explains which parent and child commands define the duplicate option.
196 *
197 * @stability unstable
198 * @since 4.0.0
199 */
200 override get message() {
201 return `Duplicate flag name "${this.option}" in parent command "${this.parentCommand}" and subcommand "${this.childCommand}". ` +
202 `Parent will always claim this flag (Mode A semantics). Consider renaming one of them to avoid confusion.`
203 }
204}
205
206/**
207 * Error thrown when a required option is missing.
208 *
209 * **Example** (Creating missing option errors)
210 *
211 * ```ts import.meta.vitest
212 * import { Effect } from "effect"
213 * import { CliError } from "effect/cli"
214 *
215 * const missingOptionError = new CliError.MissingOption({
216 * option: "api-key"
217 * })
218 *
219 * const details = [missingOptionError._tag, missingOptionError.option] // => ["MissingOption", "api-key"]
220 *
221 * // In validation context
222 * const validateRequiredOptions = (options: Record<string, string | undefined>) =>
223 * Effect.gen(function*() {
224 * const apiKey = options["api-key"]
225 * if (!apiKey) {
226 * return yield* missingOptionError
227 * }
228 * return apiKey
229 * })
230 *
231 * const validationError = await Effect.runPromise(Effect.flip(validateRequiredOptions({})))
232 * validationError._tag // => "MissingOption"
233 * ```
234 *
235 * @stability unstable
236 * @category errors
237 * @since 4.0.0
238 */
239export class MissingOption extends Schema.TaggedError<MissingOption>(
240 `${TypeId}/MissingOption`
241)("MissingOption", {
242 option: Schema.String
243}) {
244 /**
245 * Marks this value as a missing CLI option error for runtime guards.
246 *
247 * @stability unstable
248 * @since 4.0.0
249 */
250 readonly [TypeId] = TypeId
251
252 /**
253 * Formats the missing required flag for display.
254 *
255 * @stability unstable
256 * @since 4.0.0
257 */
258 override get message() {
259 return `Missing required flag: --${this.option}`
260 }
261}
262
263/**
264 * Error thrown when a required positional argument is missing.
265 *
266 * **Example** (Creating missing argument errors)
267 *
268 * ```ts import.meta.vitest
269 * import { Effect } from "effect"
270 * import { CliError } from "effect/cli"
271 *
272 * const missingArgError = new CliError.MissingArgument({
273 * argument: "target"
274 * })
275 *
276 * const details = [missingArgError._tag, missingArgError.argument] // => ["MissingArgument", "target"]
277 *
278 * // In argument parsing
279 * const parseArguments = (args: Array<string>) =>
280 * Effect.gen(function*() {
281 * if (args.length === 0) {
282 * return yield* missingArgError
283 * }
284 * return args[0]
285 * })
286 *
287 * const parseError = await Effect.runPromise(Effect.flip(parseArguments([])))
288 * parseError._tag // => "MissingArgument"
289 * ```
290 *
291 * @stability unstable
292 * @category errors
293 * @since 4.0.0
294 */
295export class MissingArgument extends Schema.TaggedError<MissingArgument>(
296 `${TypeId}/MissingArgument`
297)("MissingArgument", {
298 argument: Schema.String
299}) {
300 /**
301 * Marks this value as a missing CLI argument error for runtime guards.
302 *
303 * @stability unstable
304 * @since 4.0.0
305 */
306 readonly [TypeId] = TypeId
307
308 /**
309 * Formats the missing required positional argument for display.
310 *
311 * @stability unstable
312 * @since 4.0.0
313 */
314 override get message() {
315 return `Missing required argument: ${this.argument}`
316 }
317}
318
319/**
320 * Error thrown when positional arguments remain after a command has parsed all
321 * of its parameters.
322 *
323 * **Example** (Reporting unexpected arguments)
324 *
325 * ```ts import.meta.vitest
326 * import { CliError } from "effect/cli"
327 *
328 * const error = new CliError.UnexpectedArgument({
329 * arguments: ["extra.txt"]
330 * })
331 *
332 * const details = [error._tag, error.arguments] // => ["UnexpectedArgument", ["extra.txt"]]
333 * ```
334 *
335 * @stability unstable
336 * @category errors
337 * @since 4.0.0
338 */
339export class UnexpectedArgument extends Schema.TaggedError<UnexpectedArgument>(
340 `${TypeId}/UnexpectedArgument`
341)("UnexpectedArgument", {
342 arguments: Schema.Array(Schema.String)
343}) {
344 /**
345 * Marks this value as an unexpected CLI argument error for runtime guards.
346 *
347 * @stability unstable
348 * @since 4.0.0
349 */
350 readonly [TypeId] = TypeId
351
352 /**
353 * Formats the unexpected positional arguments for display.
354 *
355 * @stability unstable
356 * @since 4.0.0
357 */
358 override get message() {
359 const label = this.arguments.length === 1 ? "argument" : "arguments"
360 return `Unexpected positional ${label}: ${this.arguments.map((value) => JSON.stringify(value)).join(", ")}`
361 }
362}
363
364/**
365 * Error thrown when an option or argument value is invalid.
366 *
367 * **Example** (Creating invalid value errors)
368 *
369 * ```ts import.meta.vitest
370 * import { CliError } from "effect/cli"
371 *
372 * const invalidValueError = new CliError.InvalidValue({
373 * option: "port",
374 * value: "abc123",
375 * expected: "integer between 1 and 65535",
376 * kind: "flag"
377 * })
378 *
379 * invalidValueError._tag // => "InvalidValue"
380 * invalidValueError.kind // => "flag"
381 * invalidValueError.option // => "port"
382 * invalidValueError.value // => "abc123"
383 *
384 * // For positional arguments
385 * const invalidArgError = new CliError.InvalidValue({
386 * option: "count",
387 * value: "abc",
388 * expected: "integer",
389 * kind: "argument"
390 * })
391 *
392 * const details = [invalidArgError.kind, invalidArgError.option, invalidArgError.value] // => ["argument", "count", "abc"]
393 * ```
394 *
395 * @stability unstable
396 * @category errors
397 * @since 4.0.0
398 */
399export class InvalidValue extends Schema.TaggedError<InvalidValue>(
400 `${TypeId}/InvalidValue`
401)("InvalidValue", {
402 option: Schema.String,
403 value: Schema.String,
404 expected: Schema.String,
405 kind: Schema.Union([Schema.Literal("flag"), Schema.Literal("argument")])
406}) {
407 /**
408 * Marks this value as an invalid CLI value error for runtime guards.
409 *
410 * @stability unstable
411 * @since 4.0.0
412 */
413 readonly [TypeId] = TypeId
414
415 /**
416 * Formats the invalid flag or argument value with the expected input.
417 *
418 * @stability unstable
419 * @since 4.0.0
420 */
421 override get message() {
422 const expectation = this.expected.startsWith("Expected ") || this.expected.startsWith("Expected:")
423 ? this.expected
424 : `Expected: ${this.expected}`
425 if (this.kind === "argument") {
426 return `Invalid value for argument <${this.option}>: "${this.value}". ${expectation}`
427 }
428 if (this.value.length === 0) {
429 return `Missing value for flag --${this.option}. ${expectation}`
430 }
431 return `Invalid value for flag --${this.option}: "${this.value}". ${expectation}`
432 }
433}
434
435/**
436 * Error thrown when an unknown subcommand is encountered.
437 *
438 * **Example** (Creating unknown subcommand errors)
439 *
440 * ```ts import.meta.vitest
441 * import { Effect } from "effect"
442 * import { CliError } from "effect/cli"
443 *
444 * const unknownSubcommandError = new CliError.UnknownSubcommand({
445 * subcommand: "deplyo", // typo
446 * parent: ["myapp"],
447 * suggestions: ["deploy", "destroy"]
448 * })
449 *
450 * unknownSubcommandError._tag // => "UnknownSubcommand"
451 * unknownSubcommandError.subcommand // => "deplyo"
452 * unknownSubcommandError.parent // => ["myapp"]
453 *
454 * // In subcommand parsing
455 * const parseSubcommand = (subcommand: string) =>
456 * Effect.gen(function*() {
457 * const validCommands = ["deploy", "destroy", "status"]
458 * if (!validCommands.includes(subcommand)) {
459 * return yield* unknownSubcommandError
460 * }
461 * return subcommand
462 * })
463 *
464 * const parseError = await Effect.runPromise(Effect.flip(parseSubcommand("deplyo")))
465 * parseError._tag // => "UnknownSubcommand"
466 * ```
467 *
468 * @stability unstable
469 * @category errors
470 * @since 4.0.0
471 */
472export class UnknownSubcommand extends Schema.TaggedError<UnknownSubcommand>(
473 `${TypeId}/UnknownSubcommand`
474)("UnknownSubcommand", {
475 subcommand: Schema.String,
476 parent: Schema.optional(Schema.Array(Schema.String)),
477 suggestions: Schema.Array(Schema.String)
478}) {
479 /**
480 * Marks this value as an unknown CLI subcommand error for runtime guards.
481 *
482 * @stability unstable
483 * @since 4.0.0
484 */
485 readonly [TypeId] = TypeId
486
487 /**
488 * Formats the unknown subcommand with parent command context and suggestions.
489 *
490 * @stability unstable
491 * @since 4.0.0
492 */
493 override get message() {
494 const suggestionText = this.suggestions.length > 0
495 ? `\n\n Did you mean this?\n ${this.suggestions.join("\n ")}`
496 : ""
497 return this.parent
498 ? `Unknown subcommand "${this.subcommand}" for "${this.parent.join(" ")}"${suggestionText}`
499 : `Unknown subcommand "${this.subcommand}"${suggestionText}`
500 }
501}
502
503/**
504 * Error wrapper for user handler failures in the CLI error channel.
505 *
506 * **Details**
507 *
508 * `userMessage` can provide safe, user-facing text independently of the
509 * underlying cause. When omitted or empty, `message` uses a non-empty string
510 * cause or `Error.message`, then falls back to `"An error occurred"`.
511 *
512 * **Example** (Wrapping user errors)
513 *
514 * ```ts import.meta.vitest
515 * import { Effect } from "effect"
516 * import { CliError } from "effect/cli"
517 *
518 * // Wrapping user errors
519 * const userError = new CliError.UserError({
520 * cause: new Error("Database connection failed for postgres://localhost"),
521 * userMessage: "Could not connect to the database"
522 * })
523 *
524 * // In command handler
525 * const deployCommand = Effect.gen(function*() {
526 * const result = yield* Effect.try({
527 * try: () => ({ deployed: true }),
528 * catch: (error) => new CliError.UserError({ cause: error })
529 * })
530 * return result
531 * })
532 *
533 * // In error handling
534 * const handleError = (error: CliError.CliError): Effect.Effect<number> => {
535 * if (error._tag === "UserError") {
536 * return Effect.succeed(1) // Exit code 1
537 * }
538 * return Effect.succeed(0)
539 * }
540 *
541 * await Effect.runPromise(deployCommand) // => { deployed: true }
542 * await Effect.runPromise(handleError(userError)) // => 1
543 * ```
544 *
545 * @stability unstable
546 * @category errors
547 * @since 4.0.0
548 */
549export class UserError extends Schema.TaggedError<UserError>(
550 `${TypeId}/UserError`
551)("UserError", {
552 cause: Schema.Defect(),
553 userMessage: Schema.optionalKey(Schema.String)
554}) {
555 /**
556 * Marks this value as a user handler error for runtime guards.
557 *
558 * @stability unstable
559 * @since 4.0.0
560 */
561 readonly [TypeId] = TypeId
562
563 /**
564 * Controls whether the runtime logger should report this error. The CLI
565 * runner sets this to `false` after rendering the error itself.
566 *
567 * @stability unstable
568 * @since 4.0.0
569 */
570 override [Runtime.errorReported] = true
571
572 /**
573 * Returns the explicit user-facing message or a safe fallback from `cause`.
574 *
575 * @stability unstable
576 * @since 4.0.0
577 */
578 override get message() {
579 if (this.userMessage) return this.userMessage
580 if (typeof this.cause === "string" && this.cause) return this.cause
581 if (this.cause instanceof Error && this.cause.message) return this.cause.message
582 return "An error occurred"
583 }
584}
585
586/**
587 * Schema for concrete CLI errors that can be reported together with help output.
588 *
589 * **Details**
590 *
591 * This excludes `ShowHelp` itself, allowing parse and validation errors to be
592 * stored in `ShowHelp.errors` without nesting another help-control value.
593 *
594 * @stability unstable
595 * @category schemas
596 * @since 4.0.0
597 */
598export const NonShowHelpErrors: Schema.Union<
599 readonly [
600 typeof UnrecognizedOption,
601 typeof DuplicateOption,
602 typeof MissingOption,
603 typeof MissingArgument,
604 typeof UnexpectedArgument,
605 typeof InvalidValue,
606 typeof UnknownSubcommand,
607 typeof UserError
608 ]
609> = Schema.Union([
610 UnrecognizedOption,
611 DuplicateOption,
612 MissingOption,
613 MissingArgument,
614 UnexpectedArgument,
615 InvalidValue,
616 UnknownSubcommand,
617 UserError
618])
619
620/**
621 * Type of CLI errors that are not `ShowHelp`.
622 *
623 * **Details**
624 *
625 * These errors can be accumulated and attached to `ShowHelp.errors` when the
626 * runner should display help along with the underlying parse or validation
627 * failures.
628 *
629 * @stability unstable
630 * @category errors
631 * @since 4.0.0
632 */
633export type NonShowHelpErrors = typeof NonShowHelpErrors.Type
634
635/**
636 * Error data requesting CLI help rendering for a command path.
637 *
638 * **Details**
639 *
640 * It is used for explicit help requests and for parse or validation failures
641 * that should be shown with help text. When `errors` is non-empty, the runtime
642 * exit code is `1`; otherwise it is `0`.
643 *
644 * @stability unstable
645 * @category errors
646 * @since 4.0.0
647 */
648export class ShowHelp extends Schema.TaggedError<ShowHelp>(
649 `${TypeId}/ShowHelp`
650)("ShowHelp", {
651 commandPath: Schema.Array(Schema.String),
652 errors: Schema.Array(NonShowHelpErrors)
653}) {
654 readonly [TypeId] = TypeId
655
656 override readonly [Runtime.errorExitCode] = this.errors.length ? 1 : 0
657 override readonly [Runtime.errorReported] = false
658
659 override get message() {
660 return "Help requested"
661 }
662}