diff --git a/packages/effect/src/unstable/cli/Argument.ts b/packages/effect/src/unstable/cli/Argument.ts index 62a27e769..a193bc902 100644 --- a/packages/effect/src/unstable/cli/Argument.ts +++ b/packages/effect/src/unstable/cli/Argument.ts @@ -3,8 +3,9 @@ */ import type * as Option from "../../data/Option.ts" import type * as Redacted from "../../data/Redacted.ts" +import type * as Result from "../../data/Result.ts" import type * as Effect from "../../Effect.ts" -import { dual } from "../../Function.ts" +import { dual, type LazyArg } from "../../Function.ts" import type * as FileSystem from "../../platform/FileSystem.ts" import type * as Path from "../../platform/Path.ts" import type * as Schema from "../../schema/Schema.ts" @@ -13,13 +14,26 @@ import type { Environment } from "./Command.ts" import * as Param from "./Param.ts" import type * as Primitive from "./Primitive.ts" +// ------------------------------------------------------------------------------------- +// models +// ------------------------------------------------------------------------------------- + /** * Represents a positional command-line argument. * + * Note: `boolean` is intentionally omitted from Argument constructors. + * Positional boolean arguments are ambiguous in CLI design since there's + * no flag name to negate (e.g., `--no-verbose`). Use Flag.boolean instead, + * or use Argument.choice with explicit "true"/"false" strings if needed. + * * @since 4.0.0 * @category models */ -export interface Argument extends Param.Param {} +export interface Argument extends Param.Param {} + +// ------------------------------------------------------------------------------------- +// constructors +// ------------------------------------------------------------------------------------- /** * Creates a positional string argument. @@ -34,7 +48,7 @@ export interface Argument extends Param.Param {} * @since 4.0.0 * @category constructors */ -export const string = (name: string): Argument => Param.string(Param.Argument, name) +export const string = (name: string): Argument => Param.string(Param.argumentKind, name) /** * Creates a positional integer argument. @@ -49,7 +63,7 @@ export const string = (name: string): Argument => Param.string(Param.Arg * @since 4.0.0 * @category constructors */ -export const integer = (name: string): Argument => Param.integer(Param.Argument, name) +export const integer = (name: string): Argument => Param.integer(Param.argumentKind, name) /** * Creates a positional file path argument. @@ -67,7 +81,7 @@ export const integer = (name: string): Argument => Param.integer(Param.A */ export const file = (name: string, options?: { readonly mustExist?: boolean | undefined -}): Argument => Param.file(Param.Argument, name, options) +}): Argument => Param.file(Param.argumentKind, name, options) /** * Creates a positional directory path argument. @@ -84,7 +98,7 @@ export const file = (name: string, options?: { */ export const directory = (name: string, options?: { readonly mustExist?: boolean | undefined -}): Argument => Param.directory(Param.Argument, name, options) +}): Argument => Param.directory(Param.argumentKind, name, options) /** * Creates a positional float argument. @@ -99,7 +113,7 @@ export const directory = (name: string, options?: { * @since 4.0.0 * @category constructors */ -export const float = (name: string): Argument => Param.float(Param.Argument, name) +export const float = (name: string): Argument => Param.float(Param.argumentKind, name) /** * Creates a positional date argument. @@ -114,7 +128,7 @@ export const float = (name: string): Argument => Param.float(Param.Argum * @since 4.0.0 * @category constructors */ -export const date = (name: string): Argument => Param.date(Param.Argument, name) +export const date = (name: string): Argument => Param.date(Param.argumentKind, name) /** * Creates a positional choice argument. @@ -132,7 +146,7 @@ export const date = (name: string): Argument => Param.date(Param.Argument, export const choice = >( name: string, choices: Choices -): Argument => Param.choice(Param.Argument, name, choices) +): Argument => Param.choice(Param.argumentKind, name, choices) /** * Creates a positional path argument. @@ -150,7 +164,7 @@ export const choice = >( export const path = (name: string, options?: { pathType?: "file" | "directory" | "either" mustExist?: boolean -}): Argument => Param.path(Param.Argument, name, options) +}): Argument => Param.path(Param.argumentKind, name, options) /** * Creates a positional redacted argument that obscures its value. @@ -165,7 +179,7 @@ export const path = (name: string, options?: { * @since 4.0.0 * @category constructors */ -export const redacted = (name: string): Argument> => Param.redacted(Param.Argument, name) +export const redacted = (name: string): Argument> => Param.redacted(Param.argumentKind, name) /** * Creates a positional argument that reads file content as a string. @@ -180,7 +194,7 @@ export const redacted = (name: string): Argument> => P * @since 4.0.0 * @category constructors */ -export const fileText = (name: string): Argument => Param.fileString(Param.Argument, name) +export const fileText = (name: string): Argument => Param.fileText(Param.argumentKind, name) /** * Creates a positional argument that reads and validates file content using a schema. @@ -198,7 +212,7 @@ export const fileText = (name: string): Argument => Param.fileString(Par export const fileParse = ( name: string, options?: Primitive.FileParseOptions | undefined -): Argument => Param.fileParse(Param.Argument, name, options) +): Argument => Param.fileParse(Param.argumentKind, name, options) /** * Creates a positional argument that reads and validates file content using a schema. @@ -225,7 +239,7 @@ export const fileSchema = ( name: string, schema: Schema.Codec, options?: Primitive.FileSchemaOptions | undefined -): Argument => Param.fileSchema(Param.Argument, name, schema, options) +): Argument => Param.fileSchema(Param.argumentKind, name, schema, options) /** * Creates an empty sentinel argument that always fails to parse. @@ -241,7 +255,11 @@ export const fileSchema = ( * @since 4.0.0 * @category constructors */ -export const none: Argument = Param.none(Param.Argument) +export const none: Argument = Param.none(Param.argumentKind) + +// ------------------------------------------------------------------------------------- +// combinators +// ------------------------------------------------------------------------------------- /** * Makes a positional argument optional. @@ -409,21 +427,6 @@ export const mapTryCatch: { onError: (error: unknown) => string ) => Param.mapTryCatch(self, f, onError)) -/** - * Creates a variadic argument that accepts multiple values (same as variadic). - * - * @example - * ```ts - * import { Argument } from "effect/unstable/cli" - * - * const files = Argument.string("files").pipe(Argument.repeated) - * ``` - * - * @since 4.0.0 - * @category combinators - */ -export const repeated = (arg: Argument): Argument> => Param.repeated(arg) - /** * Creates a variadic argument that requires at least n values. * @@ -498,3 +501,150 @@ export const withSchema: { (schema: Schema.Codec): (self: Argument) => Argument (self: Argument, schema: Schema.Codec): Argument } = dual(2, (self: Argument, schema: Schema.Codec) => Param.withSchema(self, schema)) + +/** + * Creates a positional choice argument with custom value mapping. + * + * @example + * ```ts + * import { Argument } from "effect/unstable/cli" + * + * const logLevel = Argument.choiceWithValue("level", [ + * ["debug", 0], + * ["info", 1], + * ["warn", 2], + * ["error", 3] + * ]) + * ``` + * + * @since 4.0.0 + * @category constructors + */ +export const choiceWithValue = >( + name: string, + choices: Choices +): Argument => Param.choiceWithValue(Param.argumentKind, name, choices) + +// ------------------------------------------------------------------------------------- +// metadata +// ------------------------------------------------------------------------------------- + +/** + * Sets a custom metavar (placeholder name) for the argument in help documentation. + * + * The metavar is displayed in usage text to indicate what value the user should provide. + * For example, `` shows `FILE` as the metavar. + * + * @example + * ```ts + * import { Argument } from "effect/unstable/cli" + * + * const port = Argument.integer("port").pipe( + * Argument.withMetavar("PORT") + * ) + * ``` + * + * @since 4.0.0 + * @category metadata + */ +export const withMetavar: { + (metavar: string): (self: Argument) => Argument + (self: Argument, metavar: string): Argument +} = dual(2, (self: Argument, metavar: string) => Param.withMetavar(self, metavar)) + +/** + * Filters parsed values, failing with a custom error message if the predicate returns false. + * + * @example + * ```ts + * import { Argument } from "effect/unstable/cli" + * + * const positiveInt = Argument.integer("count").pipe( + * Argument.filter( + * n => n > 0, + * n => `Expected positive integer, got ${n}` + * ) + * ) + * ``` + * + * @since 4.0.0 + * @category combinators + */ +export const filter: { + (predicate: (a: A) => boolean, onFalse: (a: A) => string): (self: Argument) => Argument + (self: Argument, predicate: (a: A) => boolean, onFalse: (a: A) => string): Argument +} = dual(3, ( + self: Argument, + predicate: (a: A) => boolean, + onFalse: (a: A) => string +) => Param.filter(self, predicate, onFalse)) + +/** + * Filters and transforms parsed values, failing with a custom error message + * if the filter function returns None. + * + * @example + * ```ts + * import { Option } from "effect/data" + * import { Argument } from "effect/unstable/cli" + * + * const positiveInt = Argument.integer("count").pipe( + * Argument.filterMap( + * (n) => n > 0 ? Option.some(n) : Option.none(), + * (n) => `Expected positive integer, got ${n}` + * ) + * ) + * ``` + * + * @since 4.0.0 + * @category combinators + */ +export const filterMap: { + (f: (a: A) => Option.Option, onNone: (a: A) => string): (self: Argument) => Argument + (self: Argument, f: (a: A) => Option.Option, onNone: (a: A) => string): Argument +} = dual(3, ( + self: Argument, + f: (a: A) => Option.Option, + onNone: (a: A) => string +) => Param.filterMap(self, f, onNone)) + +/** + * Provides a fallback argument to use if this argument fails to parse. + * + * @example + * ```ts + * import { Argument } from "effect/unstable/cli" + * + * const value = Argument.integer("value").pipe( + * Argument.orElse(() => Argument.string("value")) + * ) + * ``` + * + * @since 4.0.0 + * @category combinators + */ +export const orElse: { + (that: LazyArg>): (self: Argument) => Argument + (self: Argument, that: LazyArg>): Argument +} = dual(2, (self: Argument, that: LazyArg>) => Param.orElse(self, that)) + +/** + * Provides a fallback argument, wrapping results in Result to distinguish which succeeded. + * + * @example + * ```ts + * import { Argument } from "effect/unstable/cli" + * + * const source = Argument.file("source").pipe( + * Argument.orElseResult(() => Argument.string("url")) + * ) + * // Returns Result + * ``` + * + * @since 4.0.0 + * @category combinators + */ +export const orElseResult: { + (that: LazyArg>): (self: Argument) => Argument> + (self: Argument, that: LazyArg>): Argument> +} = dual(2, (self: Argument, that: LazyArg>) => Param.orElseResult(self, that)) diff --git a/packages/effect/src/unstable/cli/CliError.ts b/packages/effect/src/unstable/cli/CliError.ts index ba9385f3c..5c23205ff 100644 --- a/packages/effect/src/unstable/cli/CliError.ts +++ b/packages/effect/src/unstable/cli/CliError.ts @@ -6,9 +6,9 @@ import * as Schema from "../../schema/Schema.ts" /** * @since 4.0.0 - * @category TypeId + * @category type id */ -export const TypeId = "~effect/cli/CliError" +const TypeId = "~effect/cli/CliError" /** * Type guard to check if a value is a CLI error. @@ -37,7 +37,7 @@ export const TypeId = "~effect/cli/CliError" * ``` * * @since 4.0.0 - * @category Guards + * @category guards */ export const isCliError = (u: unknown): u is CliError => Predicate.hasProperty(u, TypeId) @@ -71,7 +71,7 @@ export const isCliError = (u: unknown): u is CliError => Predicate.hasProperty(u * ``` * * @since 4.0.0 - * @category Models + * @category models */ export type CliError = | UnrecognizedOption @@ -113,7 +113,7 @@ export type CliError = * ``` * * @since 4.0.0 - * @category Models + * @category models */ export class UnrecognizedOption extends Schema.ErrorClass(`${TypeId}/UnrecognizedOption`)({ _tag: Schema.tag("UnrecognizedOption"), @@ -159,7 +159,7 @@ export class UnrecognizedOption extends Schema.ErrorClass(`${TypeId}/Unrecognize * ``` * * @since 4.0.0 - * @category Models + * @category models */ export class DuplicateOption extends Schema.ErrorClass(`${TypeId}/DuplicateOption`)({ _tag: Schema.tag("DuplicateOption"), @@ -207,7 +207,7 @@ export class DuplicateOption extends Schema.ErrorClass(`${TypeId}/DuplicateOptio * ``` * * @since 4.0.0 - * @category Models + * @category models */ export class MissingOption extends Schema.ErrorClass(`${TypeId}/MissingOption`)({ _tag: Schema.tag("MissingOption"), @@ -251,7 +251,7 @@ export class MissingOption extends Schema.ErrorClass(`${TypeId}/MissingOption`)( * ``` * * @since 4.0.0 - * @category Models + * @category models */ export class MissingArgument extends Schema.ErrorClass(`${TypeId}/MissingArgument`)({ _tag: Schema.tag("MissingArgument"), @@ -298,7 +298,7 @@ export class MissingArgument extends Schema.ErrorClass(`${TypeId}/MissingArgumen * ``` * * @since 4.0.0 - * @category Models + * @category models */ export class InvalidValue extends Schema.ErrorClass(`${TypeId}/InvalidValue`)({ _tag: Schema.tag("InvalidValue"), @@ -351,7 +351,7 @@ export class InvalidValue extends Schema.ErrorClass(`${TypeId}/InvalidValue`)({ * ``` * * @since 4.0.0 - * @category Models + * @category models */ export class UnknownSubcommand extends Schema.ErrorClass(`${TypeId}/UnknownSubcommand`)({ _tag: Schema.tag("UnknownSubcommand"), @@ -412,7 +412,7 @@ export class UnknownSubcommand extends Schema.ErrorClass(`${TypeId}/UnknownSubco * ``` * * @since 4.0.0 - * @category Models + * @category models */ export class ShowHelp extends Schema.ErrorClass(`${TypeId}/ShowHelp`)({ _tag: Schema.tag("ShowHelp"), @@ -464,7 +464,7 @@ export class ShowHelp extends Schema.ErrorClass(`${TypeId}/ShowHelp`)({ * ``` * * @since 4.0.0 - * @category Models + * @category models */ export class UserError extends Schema.ErrorClass(`${TypeId}/UserError`)({ _tag: Schema.tag("UserError"), diff --git a/packages/effect/src/unstable/cli/HelpFormatter.ts b/packages/effect/src/unstable/cli/CliOutput.ts similarity index 65% rename from packages/effect/src/unstable/cli/HelpFormatter.ts rename to packages/effect/src/unstable/cli/CliOutput.ts index 70462981c..3445c61e5 100644 --- a/packages/effect/src/unstable/cli/HelpFormatter.ts +++ b/packages/effect/src/unstable/cli/CliOutput.ts @@ -8,42 +8,43 @@ import type * as CliError from "./CliError.ts" import type { HelpDoc } from "./HelpDoc.ts" /** - * Service interface for rendering help documentation into formatted text. - * This allows customization of help output formatting, including color support. + * Service interface for formatting CLI output including help, errors, and version info. + * This allows customization of output formatting, including color support. * * @example * ```ts * import { Effect } from "effect" - * import { HelpFormatter } from "effect/unstable/cli" + * import { CliOutput } from "effect/unstable/cli" * - * // Create a custom renderer implementation - * const customRenderer: HelpFormatter.HelpRenderer = { + * // Create a custom formatter implementation + * const customFormatter: CliOutput.Formatter = { * formatHelpDoc: (doc) => `Custom Help: ${doc.usage}`, * formatCliError: (error) => `Error: ${error.message}`, * formatError: (error) => `[ERROR] ${error.message}`, - * formatVersion: (name, version) => `${name} (${version})` + * formatVersion: (name, version) => `${name} (${version})`, + * formatErrors: (errors) => errors.map((error) => error.message).join("\\n") * } * - * // Use the custom renderer in a program + * // Use the custom formatter in a program * const program = Effect.gen(function*() { - * const renderer = yield* HelpFormatter.HelpRenderer - * const helpText = renderer.formatVersion("myapp", "1.0.0") + * const formatter = yield* CliOutput.Formatter + * const helpText = formatter.formatVersion("myapp", "1.0.0") * console.log(helpText) * }).pipe( - * Effect.provide(HelpFormatter.layer(customRenderer)) + * Effect.provide(CliOutput.layer(customFormatter)) * ) * ``` * * @since 4.0.0 * @category models */ -export interface HelpRenderer { +export interface Formatter { /** * Formats a HelpDoc structure into a readable string format. * * @example * ```ts - * import { HelpFormatter, HelpDoc } from "effect/unstable/cli" + * import { CliOutput, HelpDoc } from "effect/unstable/cli" * * const helpDoc: HelpDoc = { * usage: "myapp [options] ", @@ -68,8 +69,8 @@ export interface HelpRenderer { * ] * } * - * const renderer = HelpFormatter.defaultHelpRenderer() - * const helpText = renderer.formatHelpDoc(helpDoc) + * const formatter = CliOutput.defaultFormatter() + * const helpText = formatter.formatHelpDoc(helpDoc) * console.log(helpText) * // Outputs formatted help with sections: DESCRIPTION, USAGE, ARGUMENTS, FLAGS * ``` @@ -83,16 +84,16 @@ export interface HelpRenderer { * * @example * ```ts - * import { HelpFormatter, CliError } from "effect/unstable/cli" + * import { CliOutput, CliError } from "effect/unstable/cli" * import * as Data from "effect/Data" * * class InvalidOption extends Data.TaggedError("InvalidOption")<{ * readonly message: string * }> {} * - * const renderer = HelpFormatter.defaultHelpRenderer() + * const formatter = CliOutput.defaultFormatter() * const error = new InvalidOption({ message: "Unknown flag '--invalid'" }) - * const errorMessage = renderer.formatCliError(error) + * const errorMessage = formatter.formatCliError(error) * console.log(errorMessage) // "Unknown flag '--invalid'" * ``` * @@ -105,22 +106,22 @@ export interface HelpRenderer { * * @example * ```ts - * import { HelpFormatter, CliError } from "effect/unstable/cli" + * import { CliOutput, CliError } from "effect/unstable/cli" * import * as Data from "effect/Data" * * class ValidationError extends Data.TaggedError("ValidationError")<{ * readonly message: string * }> {} * - * const colorRenderer = HelpFormatter.defaultHelpRenderer({ colors: true }) - * const noColorRenderer = HelpFormatter.defaultHelpRenderer({ colors: false }) + * const colorFormatter = CliOutput.defaultFormatter({ colors: true }) + * const noColorFormatter = CliOutput.defaultFormatter({ colors: false }) * * const error = new ValidationError({ message: "Value must be positive" }) * - * const coloredError = colorRenderer.formatError(error) + * const coloredError = colorFormatter.formatError(error) * console.log(coloredError) // "\n\x1b[1m\x1b[31mERROR\x1b[0m\n Value must be positive\x1b[0m" * - * const plainError = noColorRenderer.formatError(error) + * const plainError = noColorFormatter.formatError(error) * console.log(plainError) // "\nERROR\n Value must be positive" * ``` * @@ -133,114 +134,138 @@ export interface HelpRenderer { * * @example * ```ts - * import { HelpFormatter } from "effect/unstable/cli" + * import { CliOutput } from "effect/unstable/cli" * - * const colorRenderer = HelpFormatter.defaultHelpRenderer({ colors: true }) - * const noColorRenderer = HelpFormatter.defaultHelpRenderer({ colors: false }) + * const colorFormatter = CliOutput.defaultFormatter({ colors: true }) + * const noColorFormatter = CliOutput.defaultFormatter({ colors: false }) * * const appName = "my-awesome-tool" * const version = "1.2.3" * - * const coloredVersion = colorRenderer.formatVersion(appName, version) + * const coloredVersion = colorFormatter.formatVersion(appName, version) * console.log(coloredVersion) // "\x1b[1mmy-awesome-tool\x1b[0m \x1b[2mv\x1b[0m\x1b[1m1.2.3\x1b[0m" * - * const plainVersion = noColorRenderer.formatVersion(appName, version) + * const plainVersion = noColorFormatter.formatVersion(appName, version) * console.log(plainVersion) // "my-awesome-tool v1.2.3" * ``` * * @since 4.0.0 */ readonly formatVersion: (name: string, version: string) => string + + /** + * Formats multiple CLI errors for display, grouping by error type. + * + * @example + * ```ts + * import { CliOutput, CliError } from "effect/unstable/cli" + * + * const formatter = CliOutput.defaultFormatter({ colors: false }) + * + * const errors = [ + * new CliError.UnrecognizedOption({ option: "--foo", suggestions: ["--force"] }), + * new CliError.UnrecognizedOption({ option: "--bar", suggestions: [] }), + * new CliError.MissingOption({ option: "--required" }) + * ] + * + * const output = formatter.formatErrors(errors) + * // Groups errors by type and displays all at once + * ``` + * + * @since 4.0.0 + */ + readonly formatErrors: (errors: ReadonlyArray) => string } /** - * Service reference for the help renderer. Provides a default implementation + * Service reference for the CLI output formatter. Provides a default implementation * that can be overridden for custom formatting or testing. * * @example * ```ts - * import { HelpFormatter } from "effect/unstable/cli" + * import { CliOutput } from "effect/unstable/cli" * import * as Effect from "effect/Effect" * - * // Access the help renderer service + * // Access the formatter service * const program = Effect.gen(function* () { - * const renderer = yield* HelpFormatter.HelpRenderer + * const formatter = yield* CliOutput.Formatter * * // Format version information - * const versionText = renderer.formatVersion("my-cli", "2.1.0") + * const versionText = formatter.formatVersion("my-cli", "2.1.0") * console.log(versionText) // "my-cli v2.1.0" (with colors if supported) * * return versionText * }) * - * // Run with default renderer + * // Run with default formatter * const result = Effect.runSync(program) * ``` * * @since 4.0.0 * @category services */ -export const HelpRenderer: ServiceMap.Reference = ServiceMap.Reference( - "effect/cli/HelpRenderer", - { defaultValue: () => defaultHelpRenderer() } +export const Formatter: ServiceMap.Reference = ServiceMap.Reference( + "effect/cli/CliOutput", + { defaultValue: () => defaultFormatter() } ) /** - * Creates a Layer that provides a custom HelpRenderer implementation. + * Creates a Layer that provides a custom Formatter implementation. * * @example * ```ts - * import { HelpFormatter } from "effect/unstable/cli" + * import { CliOutput } from "effect/unstable/cli" * import * as Effect from "effect/Effect" * import * as Console from "effect/Console" * - * // Create a custom renderer without colors - * const noColorRenderer = HelpFormatter.defaultHelpRenderer({ colors: false }) - * const NoColorLayer = HelpFormatter.layer(noColorRenderer) + * // Create a custom formatter without colors + * const noColorFormatter = CliOutput.defaultFormatter({ colors: false }) + * const NoColorLayer = CliOutput.layer(noColorFormatter) * - * // Create a program that uses the custom help renderer + * // Create a program that uses the custom formatter * const program = Effect.gen(function* () { - * const renderer = yield* HelpFormatter.HelpRenderer - * const versionText = renderer.formatVersion("my-cli", "1.0.0") - * yield* Console.log(`Using custom renderer: ${versionText}`) + * const formatter = yield* CliOutput.Formatter + * const versionText = formatter.formatVersion("my-cli", "1.0.0") + * yield* Console.log(`Using custom formatter: ${versionText}`) * }).pipe( * Effect.provide(NoColorLayer) * ) * - * // You can also create completely custom renderers - * const jsonRenderer: HelpFormatter.HelpRenderer = { + * // You can also create completely custom formatters + * const jsonFormatter: CliOutput.Formatter = { * formatHelpDoc: (doc) => JSON.stringify(doc, null, 2), * formatCliError: (error) => JSON.stringify({ error: error.message }), * formatError: (error) => JSON.stringify({ type: "error", message: error.message }), - * formatVersion: (name, version) => JSON.stringify({ name, version }) + * formatVersion: (name, version) => JSON.stringify({ name, version }), + * formatErrors: (errors) => JSON.stringify(errors.map((error) => error.message)) * } - * const JsonLayer = HelpFormatter.layer(jsonRenderer) + * const JsonLayer = CliOutput.layer(jsonFormatter) * ``` * * @since 4.0.0 * @category layers */ -export const layer = (renderer: HelpRenderer): Layer.Layer => Layer.succeed(HelpRenderer)(renderer) +export const layer = (formatter: Formatter): Layer.Layer => Layer.succeed(Formatter)(formatter) /** - * Creates a default help renderer with configurable options. + * Creates a default formatter with configurable options. * * @example * ```ts * import { Effect } from "effect" - * import { CliError, HelpFormatter } from "effect/unstable/cli" + * import { CliError, CliOutput } from "effect/unstable/cli" * - * // Create a renderer without colors for tests or CI environments - * const noColorRenderer = HelpFormatter.defaultHelpRenderer({ colors: false }) + * // Create a formatter without colors for tests or CI environments + * const noColorFormatter = CliOutput.defaultFormatter({ colors: false }) * - * // Create a renderer with colors forced on - * const colorRenderer = HelpFormatter.defaultHelpRenderer({ colors: true }) + * // Create a formatter with colors forced on + * const colorFormatter = CliOutput.defaultFormatter({ colors: true }) * * // Auto-detect colors based on terminal support (default behavior) - * const autoRenderer = HelpFormatter.defaultHelpRenderer() + * const autoFormatter = CliOutput.defaultFormatter() * * const program = Effect.gen(function*() { - * const renderer = colorRenderer + * const formatter = colorFormatter * * // Format an error with proper styling * const error = new CliError.InvalidValue({ @@ -248,11 +273,11 @@ export const layer = (renderer: HelpRenderer): Layer.Layer => Layer.succe * value: "bar", * expected: "baz" * }) - * const errorText = renderer.formatError(error) + * const errorText = formatter.formatError(error) * console.log(errorText) * * // Format version information - * const versionText = renderer.formatVersion("my-tool", "1.2.3") + * const versionText = formatter.formatVersion("my-tool", "1.2.3") * console.log(versionText) * }) * ``` @@ -260,7 +285,7 @@ export const layer = (renderer: HelpRenderer): Layer.Layer => Layer.succe * @since 4.0.0 * @category constructors */ -export const defaultHelpRenderer = (options?: { colors?: boolean }): HelpRenderer => { +export const defaultFormatter = (options?: { colors?: boolean }): Formatter => { const globalProcess = (globalThis as any).process const hasProcess = typeof globalProcess === "object" && globalProcess !== null @@ -294,15 +319,42 @@ export const defaultHelpRenderer = (options?: { colors?: boolean }): HelpRendere magenta: (text: string): string => text } + const reset = useColor ? "\x1b[0m" : "" + const red = useColor ? "\x1b[31m" : "" + const bold = useColor ? "\x1b[1m" : "" + return { formatHelpDoc: (doc: HelpDoc): string => formatHelpDocImpl(doc, colors), formatCliError: (error): string => error.message, formatError: (error): string => { - const reset = useColor ? "\x1b[0m" : "" - const red = useColor ? "\x1b[31m" : "" - const bold = useColor ? "\x1b[1m" : "" return `\n${bold}${red}ERROR${reset}\n ${error.message}${reset}` }, + formatErrors: (errors): string => { + if (errors.length === 0) return "" + if (errors.length === 1) { + return `\n${bold}${red}ERROR${reset}\n ${errors[0].message}${reset}` + } + + // Group errors by _tag + const grouped = new Map>() + for (const error of errors) { + const tag = (error as any)._tag ?? "Error" + const group = grouped.get(tag) ?? [] + group.push(error) + grouped.set(tag, group) + } + + const sections: Array = [] + sections.push(`\n${bold}${red}ERRORS${reset}`) + + for (const [, group] of grouped) { + for (const error of group) { + sections.push(` ${error.message}${reset}`) + } + } + + return sections.join("\n") + }, formatVersion: (name: string, version: string): string => `${colors.bold(name)} ${colors.dim("v")}${colors.bold(version)}` } diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index 5b0296c56..bdbd70622 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -5,32 +5,31 @@ import * as Console from "../../Console.ts" import * as Predicate from "../../data/Predicate.ts" import * as Effect from "../../Effect.ts" import { dual } from "../../Function.ts" -import { type Pipeable, pipeArguments } from "../../interfaces/Pipeable.ts" -import { YieldableProto } from "../../internal/core.ts" +import type { Pipeable } from "../../interfaces/Pipeable.ts" import type * as Layer from "../../Layer.ts" import type * as FileSystem from "../../platform/FileSystem.ts" import type * as Path from "../../platform/Path.ts" import type * as Terminal from "../../platform/Terminal.ts" import * as References from "../../References.ts" -import * as ServiceMap from "../../ServiceMap.ts" +import type * as ServiceMap from "../../ServiceMap.ts" import type { Simplify } from "../../types/Types.ts" import * as CliError from "./CliError.ts" -import type { ArgDoc, FlagDoc, HelpDoc, SubcommandDoc } from "./HelpDoc.ts" -import * as HelpFormatter from "./HelpFormatter.ts" -import { generateBashCompletions, generateFishCompletions, generateZshCompletions } from "./internal/completions.ts" +import * as CliOutput from "./CliOutput.ts" +import { checkForDuplicateFlags, getHelpForCommandPath, makeCommand, toImpl, type TypeId } from "./internal/command.ts" import { generateDynamicCompletion, handleCompletionRequest, isCompletionRequest -} from "./internal/completions/dynamic/index.ts" +} from "./internal/completions/index.ts" +import { parseConfig } from "./internal/config.ts" import * as Lexer from "./internal/lexer.ts" import * as Parser from "./internal/parser.ts" -import * as Param from "./Param.ts" -import * as Primitive from "./Primitive.ts" +import type * as Param from "./Param.ts" import * as Prompt from "./Prompt.ts" -const TypeId = "~effect/cli/Command" as const -const ParsedConfigTypeId = "~effect/cli/Command/ParsedConfig" as const +/* ========================================================================== */ +/* Public Types */ +/* ========================================================================== */ /** * Represents a CLI command with its configuration, handler, and metadata. @@ -80,7 +79,7 @@ export interface Command exten Command, Input, never, - ParentCommand + CommandContext > { readonly [TypeId]: typeof TypeId @@ -98,62 +97,123 @@ export interface Command exten /** * The subcommands available under this command. */ - readonly subcommands: ReadonlyArray> - - /** - * The configuration object that will be used to parse command-line flags - * and positional arguments for the command. - */ - readonly config: ParsedConfig + readonly subcommands: ReadonlyArray +} +/** + * @since 4.0.0 + */ +export declare namespace Command { /** - * A service which can be used to extract this command's positional arguments - * and flags in subcommand handlers. + * Configuration object for defining command flags, arguments, and nested structures. + * + * Command.Config allows you to specify: + * - Individual flags and arguments using Param types + * - Nested configuration objects for organization + * - Arrays of parameters for repeated elements + * + * @example + * ```ts + * import type * as CliCommand from "effect/unstable/cli/Command" + * import { Argument, Flag } from "effect/unstable/cli" + * + * // Simple flat configuration + * const simpleConfig: CliCommand.Command.Config = { + * name: Flag.string("name"), + * age: Flag.integer("age"), + * file: Argument.string("file") + * } + * + * // Nested configuration for organization + * const nestedConfig: CliCommand.Command.Config = { + * user: { + * name: Flag.string("name"), + * email: Flag.string("email") + * }, + * server: { + * host: Flag.string("host"), + * port: Flag.integer("port") + * } + * } + * ``` + * + * @since 4.0.0 + * @category models */ - readonly service: ServiceMap.Service, Input> + export interface Config { + readonly [key: string]: + | Param.Param + | ReadonlyArray | Config> + | Config + } /** - * The method which will be invoked to parse the command-line input for the - * command. + * Utilities for working with command configurations. + * + * @since 4.0.0 + * @category models */ - readonly parse: (input: RawInput) => Effect.Effect + export namespace Config { + /** + * Infers the TypeScript type from a Command.Config structure. + * + * This type utility extracts the final configuration type that handlers will receive, + * preserving the nested structure while converting Param types to their values. + * + * @example + * ```ts + * import type * as CliCommand from "effect/unstable/cli/Command" + * import { Argument, Flag } from "effect/unstable/cli" + * + * const config = { + * name: Flag.string("name"), + * server: { + * host: Flag.string("host"), + * port: Flag.integer("port") + * } + * } as const + * + * type Result = CliCommand.Command.Config.Infer + * // { + * // readonly name: string + * // readonly server: { + * // readonly host: string + * // readonly port: number + * // } + * // } + * ``` + * + * @since 4.0.0 + * @category models + */ + export type Infer = Simplify< + { readonly [Key in keyof A]: InferValue } + > + + /** + * Helper type utility for recursively inferring types from Config values. + * + * @since 4.0.0 + * @category models + */ + export type InferValue = A extends ReadonlyArray ? { readonly [Key in keyof A]: InferValue } + : A extends Param.Param ? _Value + : A extends Config ? Infer + : never + } /** - * The method which will be invoked with the command-line input and path to - * execute the logic associated with the command. + * Represents any Command regardless of its type parameters. + * + * @since 4.0.0 + * @category models */ - readonly handle: ( - input: Input, - commandPath: ReadonlyArray - ) => Effect.Effect + export type Any = Command } /** * The environment required by CLI commands, including file system and path operations. * - * This type represents the services that CLI commands may need access to, - * particularly for file operations and path manipulations. - * - * @example - * ```ts - * import { Command } from "effect/unstable/cli" - * import { Effect, Console } from "effect" - * import { FileSystem, Path } from "effect/platform" - * - * // Commands that need file system access require Environment - * const readConfig = Command.make("read-config", {}, () => - * Effect.gen(function*() { - * const fs = yield* FileSystem.FileSystem - * const path = yield* Path.Path - * const configPath = path.join(process.cwd(), "config.json") - * const content = yield* fs.readFileString(configPath) - * yield* Console.log(content) - * }) - * ) - * - * // Environment is provided automatically by Command.run - * ``` - * * @since 4.0.0 * @category utility types */ @@ -174,355 +234,66 @@ export type Error = C extends Command< never /** - * Service context for a specific command, providing access to command input through Effect's service system. + * Service context for a specific command, enabling subcommands to access their parent's parsed configuration. * - * Context allows commands and subcommands to access their parsed configuration - * through Effect's dependency injection system. + * When a subcommand handler needs access to flags or arguments from a parent command, + * it can yield the parent command directly to retrieve its config. This is powered by + * Effect's service system - each command automatically creates a service that provides + * its parsed input to child commands. * * @example * ```ts - * import { Console, Effect } from "effect" * import { Command, Flag } from "effect/unstable/cli" + * import { Effect, Console } from "effect" * - * const parentCommand = Command.make("parent", { - * verbose: Flag.boolean("verbose") + * const parent = Command.make("app", { + * verbose: Flag.boolean("verbose"), + * config: Flag.string("config") * }) * - * const childCommand = Command.make("child", {}, () => + * const child = Command.make("deploy", { + * target: Flag.string("target") + * }, (config) => * Effect.gen(function*() { - * // Access parent command's context within subcommand - * const parentConfig = yield* parentCommand - * if (parentConfig.verbose) { - * yield* Console.log("Verbose mode enabled from parent") - * } - * })) - * - * const app = parentCommand.pipe( - * Command.withSubcommands(childCommand) + * // Access parent's config by yielding the parent command + * const parentConfig = yield* parent + * yield* Console.log(`Verbose: ${parentConfig.verbose}`) + * yield* Console.log(`Config: ${parentConfig.config}`) + * yield* Console.log(`Target: ${config.target}`) + * }) * ) + * + * const app = parent.pipe(Command.withSubcommands([child])) + * // Usage: app --verbose --config prod.json deploy --target staging * ``` * * @since 4.0.0 * @category models */ -export interface ParentCommand { +export interface CommandContext { readonly _: unique symbol readonly name: Name } /** - * Represents the raw input parsed from the command-line which is provided to - * the `Command.parse` method. + * Represents the parsed tokens from command-line input before validation. * * @since 4.0.0 * @category models */ -export interface RawInput { +export interface ParsedTokens { readonly flags: Record> readonly arguments: ReadonlyArray readonly errors?: ReadonlyArray readonly subcommand?: { readonly name: string - readonly parsedInput: RawInput + readonly parsedInput: ParsedTokens } } -/** - * Configuration object for defining command flags, arguments, and nested structures. - * - * CommandConfig allows you to specify: - * - Individual flags and arguments using Param types - * - Nested configuration objects for organization - * - Arrays of parameters for repeated elements - * - * @example - * ```ts - * import { Command, Flag, Argument } from "effect/unstable/cli" - * import { Effect, Console } from "effect" - * - * // Simple flat configuration - * const simpleConfig = { - * name: Flag.string("name"), - * age: Flag.integer("age"), - * file: Argument.string("file") - * } - * - * // Nested configuration for organization - * const nestedConfig = { - * user: { - * name: Flag.string("name"), - * email: Flag.string("email") - * }, - * server: { - * host: Flag.string("host"), - * port: Flag.integer("port") - * }, - * files: Argument.string("files").pipe(Argument.variadic) - * } - * - * // Use in command creation - * const command = Command.make("deploy", nestedConfig, (config) => - * Effect.gen(function*() { - * // config.user.name, config.server.host, etc. are all type-safe - * yield* Console.log(`Deploying for ${config.user.name} to ${config.server.host}:${config.server.port}`) - * }) - * ) - * ``` - * - * @since 4.0.0 - * @category models - */ -export interface CommandConfig { - readonly [key: string]: - | Param.Param - | ReadonlyArray | CommandConfig> - | CommandConfig -} - -/** - * Infers the TypeScript type from a CommandConfig structure. - * - * This type utility extracts the final configuration type that handlers will receive, - * preserving the nested structure while converting Param types to their values. - * - * @example - * ```ts - * import { Command, Flag, Argument } from "effect/unstable/cli" - * import { Effect, Console } from "effect" - * - * // Define a configuration structure - * const config = { - * name: Flag.string("name"), - * server: { - * host: Flag.string("host"), - * port: Flag.integer("port") - * }, - * files: Argument.string("files").pipe(Argument.variadic) - * } as const - * - * // InferConfig extracts the final type - * type ConfigType = Command.InferConfig - * // Result: { - * // readonly name: string - * // readonly server: { - * // readonly host: string - * // readonly port: number - * // } - * // readonly files: ReadonlyArray - * // } - * - * const command = Command.make("deploy", config, (config: ConfigType) => - * Effect.gen(function*() { - * // config is fully typed with the inferred structure - * yield* Console.log(`Deploying to ${config.server.host}:${config.server.port}`) - * }) - * ) - * ``` - * - * @since 4.0.0 - * @category models - */ -export type InferConfig = Simplify< - { readonly [Key in keyof A]: InferConfigValue } -> - -/** - * Helper type utility for recursively inferring types from CommandConfig values. - * - * This type handles the different kinds of values that can appear in a CommandConfig: - * - Arrays of params/configs are recursively processed - * - Param types are extracted to their value types - * - Nested CommandConfig objects are recursively inferred - * - * @example - * ```ts - * import { Command, Flag, Argument } from "effect/unstable/cli" - * - * // Single param extraction - * type StringFlag = Command.InferConfigValue - * // Result: string - * - * // Array param extraction - * type StringArgs = readonly string[] - * // Result: ReadonlyArray - * - * // Nested config extraction - * type NestedConfig = Command.InferConfigValue<{ - * host: typeof Flag.string - * port: typeof Flag.integer - * }> - * // Result: { readonly host: string; readonly port: number } - * ``` - * - * @since 4.0.0 - * @category models - */ -export type InferConfigValue = A extends ReadonlyArray ? { readonly [Key in keyof A]: InferConfigValue } - : A extends Param.Param ? _Value - : A extends CommandConfig ? InferConfig - : never - -/** - * Internal tree structure that represents the blueprint for reconstructing parsed configuration. - * - * ConfigTree is used internally during command parsing to maintain the structure - * of nested configuration objects while allowing for flat parameter parsing. - * - * @example - * ```ts - * import { Command } from "effect/unstable/cli" - * - * // Internal structure for config like: - * // { name: Flag.string("name"), db: { host: Flag.string("host") } } - * // - * // Becomes ConfigTree: - * // { - * // name: { _tag: "Param", index: 0 }, - * // db: { - * // _tag: "ParsedConfig", - * // tree: { host: { _tag: "Param", index: 1 } } - * // } - * // } - * ``` - * - * @since 4.0.0 - * @category models - */ -export interface ConfigTree { - [key: string]: ConfigNode -} - -/** - * Individual node in the configuration tree, representing different types of configuration elements. - * - * ConfigNode can be: - * - Param: References a specific parameter by index in the flat parsed array - * - Array: Contains multiple child nodes for array parameters - * - ParsedConfig: Contains a nested configuration tree - * - * @example - * ```ts - * import { Command } from "effect/unstable/cli" - * - * // Different node types: - * - * // Param node (references parsed value at index) - * const paramNode: Command.ConfigNode = { - * _tag: "Param", - * index: 0 - * } - * - * // Array node (contains multiple child nodes) - * const arrayNode: Command.ConfigNode = { - * _tag: "Array", - * children: [ - * { _tag: "Param", index: 1 }, - * { _tag: "Param", index: 2 } - * ] - * } - * - * // ParsedConfig node (contains nested structure) - * const configNode: Command.ConfigNode = { - * _tag: "ParsedConfig", - * tree: { - * host: { _tag: "Param", index: 3 }, - * port: { _tag: "Param", index: 4 } - * } - * } - * ``` - * - * @since 4.0.0 - * @category models - */ -export type ConfigNode = { - readonly _tag: "Param" - readonly index: number -} | { - readonly _tag: "Array" - readonly children: ReadonlyArray -} | { - readonly _tag: "ParsedConfig" - readonly tree: ConfigTree -} - -/** - * Parsed and flattened configuration structure created from a CommandConfig. - * - * ParsedConfig separates parameters by type and maintains both the original - * nested structure (via tree) and the flattened parameter list for parsing. - * - * @example - * ```ts - * import { Command, Flag, Argument } from "effect/unstable/cli" - * - * // Example of what parseConfig produces for: - * const config = { - * name: Flag.string("name"), - * db: { - * host: Flag.string("host"), - * port: Flag.integer("port") - * }, - * files: Argument.string("files").pipe(Argument.variadic) - * } - * - * // Results in ParsedConfig structure with: - * // - flags: All flags extracted and flattened - * // - arguments: All arguments extracted and flattened - * // - orderedParams: All params in declaration order - * // - tree: Blueprint preserving original nested structure - * // - * // Tree structure example: - * // { - * // name: { _tag: "Param", index: 0 }, - * // db: { - * // _tag: "ParsedConfig", - * // tree: { - * // host: { _tag: "Param", index: 1 }, - * // port: { _tag: "Param", index: 2 } - * // } - * // }, - * // files: { _tag: "Param", index: 3 } - * // } - * ``` - * - * @since 4.0.0 - * @category models - */ -export interface ParsedConfig { - readonly [ParsedConfigTypeId]: typeof ParsedConfigTypeId - /** - * The parsed command-line positional arguments. - */ - readonly arguments: ReadonlyArray> - /** - * The parsed command-line flags. - */ - readonly flags: ReadonlyArray> - /** - * Represents parsed parameters in the exact order in which they were declared. - */ - readonly orderedParams: ReadonlyArray> - /** - * The parsed configuration tree. - */ - readonly tree: ConfigTree -} - -/** - * @since 4.0.0 - * @category guards - */ -export const isParsedConfig = (u: unknown): u is ParsedConfig => Predicate.hasProperty(u, ParsedConfigTypeId) - -const Proto = { - ...YieldableProto, - pipe() { - return pipeArguments(this, arguments) - }, - asEffect(this: Command) { - return this.service.asEffect() - } -} +/* ========================================================================== */ +/* Constructors */ +/* ========================================================================== */ /** * Creates a Command from a name, optional config, optional handler function, and optional description. @@ -571,21 +342,6 @@ const Proto = { * yield* Console.log("Deployment completed successfully") * }) * ) - * - * // Command with complex file operations - * const backup = Command.make("backup", { - * source: Argument.string("source"), - * destination: Flag.string("dest").pipe(Flag.withDescription("Backup destination")), - * compress: Flag.boolean("compress").pipe(Flag.withDefault(false)) - * }, (config) => - * Effect.gen(function*() { - * yield* Console.log(`Backing up ${config.source} to ${config.destination}`) - * if (config.compress) { - * yield* Console.log("Compression enabled") - * } - * // File operations would go here - * }) - * ) * ``` * * @since 4.0.0 @@ -594,43 +350,71 @@ const Proto = { export const make: { (name: Name): Command - ( + ( name: Name, config: Config - ): Command, never, never> + ): Command, never, never> - ( + ( name: Name, config: Config, - handler: (config: InferConfig) => Effect.Effect - ): Command, E, R> + handler: (config: Command.Config.Infer) => Effect.Effect + ): Command, E, R> } = (( name: string, - config?: CommandConfig, + config?: Command.Config, handler?: (config: unknown) => Effect.Effect -) => - makeCommand({ +) => { + const parsedConfig = parseConfig(config ?? {}) + return makeCommand({ name, - config: config ?? {} as CommandConfig, + config: parsedConfig, ...(Predicate.isNotUndefined(handler) ? { handle: handler } : {}) - })) as any + }) +}) as any /** + * Creates a command that prompts the user for input using an interactive prompt. + * + * This is useful for commands that need to gather information interactively, + * such as wizards or setup flows. The prompt runs before the handler and its + * result is passed to the handler function. + * + * @example + * ```ts + * import { Command, Prompt } from "effect/unstable/cli" + * import { Effect, Console } from "effect" + * + * const setup = Command.prompt( + * "setup", + * Prompt.text({ message: "Enter your name:" }), + * (name) => Console.log(`Hello, ${name}!`) + * ) + * ``` + * * @since 4.0.0 * @category constructors */ +// TODO: Input type is `A` but parse returns `{}`. The actual `A` comes from +// running the prompt inside the handler. This is a semantic mismatch that +// would break if subcommands tried to access parent config. export const prompt = ( name: Name, - prompt: Prompt.Prompt, + promptDef: Prompt.Prompt, handler: (value: A) => Effect.Effect ): Command => { + const parsedConfig = parseConfig({}) return makeCommand({ name, - config: {}, - handle: () => Effect.flatMap(Prompt.run(prompt), (value) => handler(value)) + config: parsedConfig, + handle: () => Effect.flatMap(Prompt.run(promptDef), (value) => handler(value)) }) } +/* ========================================================================== */ +/* Combinators */ +/* ========================================================================== */ + /** * Adds or replaces the handler for a command. * @@ -639,36 +423,15 @@ export const prompt = ( * import { Command, Flag } from "effect/unstable/cli" * import { Effect, Console } from "effect" * - * // First define subcommands - * const clone = Command.make("clone", { - * repository: Flag.string("repository") - * }, (config) => - * Effect.gen(function*() { - * yield* Console.log(`Cloning ${config.repository}`) - * }) - * ) - * - * const add = Command.make("add", { - * files: Flag.string("files") - * }, (config) => - * Effect.gen(function*() { - * yield* Console.log(`Adding ${config.files}`) - * }) - * ) + * // Command without initial handler + * const greet = Command.make("greet", { + * name: Flag.string("name") + * }) * - * // Create main command with subcommands and handler - * const git = Command.make("git", { - * verbose: Flag.boolean("verbose") - * }).pipe( - * Command.withSubcommands(clone, add), + * // Add handler later + * const greetWithHandler = greet.pipe( * Command.withHandler((config) => - * Effect.gen(function*() { - * // Now config has the subcommand field - * yield* Console.log(`Git verbose: ${config.verbose}`) - * if (config.subcommand) { - * yield* Console.log(`Executed subcommand: ${config.subcommand.name}`) - * } - * }) + * Console.log(`Hello, ${config.name}!`) * ) * ) * ``` @@ -689,119 +452,141 @@ export const withHandler: { } = dual(2, ( self: Command, handler: (value: A) => Effect.Effect -): Command => makeCommand({ ...self, handle: handler })) +): Command => makeCommand({ ...toImpl(self), handle: handler })) /** * Adds subcommands to a command, creating a hierarchical command structure. * + * Subcommands can access their parent's parsed configuration by yielding the parent + * command within their handler. This enables patterns like global flags that affect + * all subcommands. + * * @example * ```ts * import { Command, Flag } from "effect/unstable/cli" * import { Effect, Console } from "effect" * + * // Parent command with global flags + * const git = Command.make("git", { + * verbose: Flag.boolean("verbose") + * }) + * + * // Subcommand that accesses parent config * const clone = Command.make("clone", { - * repository: Flag.string("repository") + * repository: Flag.string("repo") * }, (config) => * Effect.gen(function*() { + * const parent = yield* git // Access parent's parsed config + * if (parent.verbose) { + * yield* Console.log("Verbose mode enabled") + * } * yield* Console.log(`Cloning ${config.repository}`) * }) * ) * - * const add = Command.make("add", { - * files: Flag.string("files") - * }, (config) => - * Effect.gen(function*() { - * yield* Console.log(`Adding ${config.files}`) - * }) - * ) - * - * const git = Command.make("git", {}, () => Effect.void).pipe( - * Command.withSubcommands(clone, add) - * ) + * const app = git.pipe(Command.withSubcommands([clone])) + * // Usage: git --verbose clone --repo github.com/foo/bar * ``` * * @since 4.0.0 * @category combinators */ -export const withSubcommands = >>( - ...subcommands: Subcommands -) => -( - self: Command +export const withSubcommands: { + >>( + subcommands: Subcommands + ): ( + self: Command + ) => Command< + Name, + Input, + E | ExtractSubcommandErrors, + R | Exclude, CommandContext> + > + < + Name extends string, + Input, + E, + R, + const Subcommands extends ReadonlyArray> + >( + self: Command, + subcommands: Subcommands + ): Command< + Name, + Input, + E | ExtractSubcommandErrors, + R | Exclude, CommandContext> + > +} = dual(2, < + Name extends string, + Input, + E, + R, + const Subcommands extends ReadonlyArray> +>( + self: Command, + subcommands: Subcommands ): Command< Name, - Input & { readonly subcommand: ExtractSubcommandInputs | undefined }, - ExtractSubcommandErrors, - R | Exclude, ParentCommand> + Input, + E | ExtractSubcommandErrors, + R | Exclude, CommandContext> > => { checkForDuplicateFlags(self, subcommands) - type NewInput = Input & { readonly subcommand: ExtractSubcommandInputs | undefined } - - // Build a stable name → subcommand index to avoid repeated linear scans - const subcommandIndex = new Map>() - for (const s of subcommands) { - subcommandIndex.set(s.name, s) - } - - const parse: (input: RawInput) => Effect.Effect = Effect.fnUntraced( - function*(input: RawInput) { - const parentResult = yield* self.parse(input) + const impl = toImpl(self) + const byName = new Map(subcommands.map((s) => [s.name, toImpl(s)] as const)) - const subRef = input.subcommand - if (!subRef) { - return { ...parentResult, subcommand: undefined } - } + // Internal type for routing - not exposed in public type + type SubcommandInfo = { readonly name: string; readonly result: unknown } + type InternalInput = Input & { readonly _subcommand?: SubcommandInfo } - const sub = subcommandIndex.get(subRef.name) + const parse = Effect.fnUntraced(function*(raw: ParsedTokens) { + const parent = yield* impl.parse(raw) - // Parser guarantees valid subcommand names, but guard defensively - if (!sub) { - return { ...parentResult, subcommand: undefined } - } + if (!raw.subcommand) { + return parent + } - const subResult = yield* sub.parse(subRef.parsedInput) - const subcommand = { name: sub.name, result: subResult } as ExtractSubcommandInputs - return { ...parentResult, subcommand } + const sub = byName.get(raw.subcommand.name) + if (!sub) { + return parent } - ) - const handle = Effect.fnUntraced(function*(input: NewInput, commandPath: ReadonlyArray) { - const selected = input.subcommand - if (selected !== undefined) { - const child = subcommandIndex.get(selected.name) + const result = yield* sub.parse(raw.subcommand.parsedInput) + // Attach subcommand info internally for routing + return Object.assign({}, parent, { _subcommand: { name: sub.name, result } }) as InternalInput + }) + + const handle = Effect.fnUntraced(function*(input: Input, path: ReadonlyArray) { + const internal = input as InternalInput + if (internal._subcommand) { + const child = byName.get(internal._subcommand.name) if (!child) { - return yield* new CliError.ShowHelp({ commandPath }) + return yield* new CliError.ShowHelp({ commandPath: path }) } return yield* child - .handle(selected.result, [...commandPath, child.name]) - .pipe(Effect.provideService(self.service, input)) + .handle(internal._subcommand.result, [...path, child.name]) + .pipe(Effect.provideService(impl.service, input)) } - return yield* self.handle(input, commandPath) + return yield* impl.handle(input, path) }) - return makeCommand({ ...self, subcommands, parse, handle } as any) -} - -// Errors across a tuple (preferred), falling back to array element type -type ExtractSubcommandErrors> = T extends readonly [] ? never - : T extends readonly [infer H, ...infer R] ? Error | ExtractSubcommandErrors - : T extends ReadonlyArray ? Error - : never - -type ContextOf = C extends Command ? R : never - -type ExtractSubcommandContext> = T extends readonly [] ? never - : T extends readonly [infer H, ...infer R] ? ContextOf | ExtractSubcommandContext - : T extends ReadonlyArray ? ContextOf - : never - -type InputOf = C extends Command ? { readonly name: N; readonly result: I } : never + return makeCommand({ + name: impl.name, + config: impl.config, + description: impl.description, + service: impl.service, + subcommands, + parse, + handle + }) +}) -type ExtractSubcommandInputs> = T extends readonly [] ? never - : T extends readonly [infer H, ...infer R] ? InputOf | ExtractSubcommandInputs - : T extends ReadonlyArray ? InputOf - : never +// Type extractors for subcommand arrays - T[number] gives union of all elements +type ExtractSubcommandErrors>> = Error +type ExtractSubcommandContext>> = T[number] extends + Command ? R : never /** * Sets the description for a command. @@ -839,147 +624,55 @@ export const withDescription: { } = dual(2, ( self: Command, description: string -) => makeCommand({ ...self, description })) +) => makeCommand({ ...toImpl(self), description })) + +/* ========================================================================== */ +/* Providing Services */ +/* ========================================================================== */ + +// Internal helper: transforms a command's handler while preserving other properties +const mapHandler = ( + self: Command, + f: (handler: Effect.Effect, input: Input) => Effect.Effect +) => { + const impl = toImpl(self) + return makeCommand({ ...impl, handle: (input, path) => f(impl.handle(input, path), input) }) +} /** - * Generates a HelpDoc structure from a Command. - * - * This structured data can be formatted for display using HelpFormatter. - * getHelpDoc extracts all relevant information from a command including its - * description, usage pattern, flags, arguments, and subcommands to create - * comprehensive help documentation. + * Provides the handler of a command with the services produced by a layer + * that optionally depends on the command-line input to be created. * * @example * ```ts - * import { Command, Flag, Argument, HelpFormatter } from "effect/unstable/cli" - * import { Effect, Console } from "effect" + * import { Command, Flag } from "effect/unstable/cli" + * import { Effect, Layer } from "effect" + * import { FileSystem, PlatformError } from "effect/platform" * - * // Create a complex command * const deploy = Command.make("deploy", { - * environment: Flag.string("env").pipe(Flag.withDescription("Target environment")), - * force: Flag.boolean("force").pipe(Flag.withDescription("Force deployment")), - * files: Argument.string("files").pipe(Argument.variadic, Argument.withDescription("Files to deploy")) + * env: Flag.string("env") * }, (config) => * Effect.gen(function*() { - * yield* Console.log(`Deploying to ${config.environment}`) + * const fs = yield* FileSystem.FileSystem + * // Use fs... * }) * ).pipe( - * Command.withDescription("Deploy application to specified environment") - * ) - * - * // Generate help documentation - * const helpDoc = Command.getHelpDoc(deploy) - * // Result contains: - * // { - * // description: "Deploy application to specified environment", - * // usage: "deploy [flags] ", // flags: [ // { name: "env", aliases: ["--env"], type: "string", description: "Target environment", required: true }, - * // { name: "force", aliases: ["--force"], type: "boolean", description: "Force deployment", required: false } - * // ], - * // args: [ - * // { name: "files", type: "string", description: "Files to deploy", required: true, variadic: true } - * // ] - * // } - * - * // Format and display help - * const program = Effect.gen(function*() { - * const helpRenderer = yield* HelpFormatter.HelpRenderer - * const helpText = helpRenderer.formatHelpDoc(helpDoc) - * yield* Console.log(helpText) - * }) - * - * // For subcommand help with command path - * const git = Command.make("git", { - * verbose: Flag.boolean("verbose") - * }).pipe( - * Command.withSubcommands(deploy) + * // Provide FileSystem based on the --env flag + * Command.provide((config) => + * config.env === "local" + * ? FileSystem.layerNoop({}) + * : FileSystem.layerNoop({ + * access: () => + * Effect.fail(new PlatformError.BadArgument({ + * module: "FileSystem", + * method: "access" + * })) + * }) + * ) * ) - * const subcommandHelp = Command.getHelpDoc(deploy, ["git", "deploy"]) - * // Usage will show: "git deploy [flags] " * ``` * * @since 4.0.0 - * @category help - */ -export const getHelpDoc = ( - command: Command, - commandPath?: ReadonlyArray -): HelpDoc => { - const args: Array = [] - const flags: Array = [] - - // Extract positional arguments - for (const arg of command.config.arguments) { - const singles = Param.extractSingleParams(arg) - const metadata = Param.getParamMetadata(arg) - - for (const single of singles) { - args.push({ - name: single.name, - type: single.typeName ?? Primitive.getTypeName(single.primitiveType), - description: single.description, - required: !metadata.isOptional, - variadic: metadata.isVariadic - }) - } - } - - // Build usage string with positional arguments - let usage: string - if (commandPath && commandPath.length > 0) { - // Use the full command path if provided - usage = commandPath.join(" ") - } else { - // Fall back to just the command name - usage = command.name - } - - if (command.subcommands.length > 0) { - usage += " " - } - usage += " [flags]" - - // Add positional arguments to usage - for (const arg of args) { - const argName = arg.variadic ? `<${arg.name}...>` : `<${arg.name}>` - usage += ` ${arg.required ? argName : `[${argName}]`}` - } - - // Extract flags from options - for (const option of command.config.flags) { - const singles = Param.extractSingleParams(option) - for (const single of singles) { - const formattedAliases = single.aliases.map((alias) => alias.length === 1 ? `-${alias}` : `--${alias}`) - - flags.push({ - name: single.name, - aliases: formattedAliases, - type: single.typeName ?? Primitive.getTypeName(single.primitiveType), - description: single.description, - required: single.primitiveType._tag !== "Boolean" - }) - } - } - - // Extract subcommand info - const subcommandDocs: Array = command.subcommands.map((sub) => ({ - name: sub.name, - description: sub.description ?? "" - })) - - return { - description: command.description ?? "", - usage, - flags, - ...(args.length > 0 && { args }), - ...(subcommandDocs.length > 0 && { subcommands: subcommandDocs }) - } -} - -/** - * Provides the handler of a command with the services produced by a layer - * that optionally depends on the command-line input to be created. - * - * @since 4.0.0 * @category providing services */ export const provide: { @@ -995,15 +688,7 @@ export const provide: { } = dual(2, ( self: Command, layer: Layer.Layer | ((input: Input) => Layer.Layer) -) => - makeCommand({ - ...self, - handle: (input, commandPath) => - Effect.provide( - self.handle(input, commandPath), - typeof layer === "function" ? layer(input) : layer - ) - })) +) => mapHandler(self, (handler, input) => Effect.provide(handler, typeof layer === "function" ? layer(input) : layer))) /** * Provides the handler of a command with the implementation of a service that @@ -1029,17 +714,12 @@ export const provideSync: { service: ServiceMap.Service, implementation: S | ((input: Input) => S) ) => - makeCommand({ - ...self, - handle: (input, commandPath) => - Effect.provideService( - self.handle(input, commandPath), - service, - typeof implementation === "function" - ? (implementation as any)(input) - : implementation - ) - })) + mapHandler(self, (handler, input) => + Effect.provideService( + handler, + service, + typeof implementation === "function" ? (implementation as (input: Input) => S)(input) : implementation + ))) /** * Provides the handler of a command with the service produced by an effect @@ -1065,15 +745,11 @@ export const provideEffect: { service: ServiceMap.Service, effect: Effect.Effect | ((input: Input) => Effect.Effect) ) => - makeCommand({ - ...self, - handle: (input, commandPath) => - Effect.provideServiceEffect( - self.handle(input, commandPath), - service, - typeof effect === "function" ? effect(input) : effect - ) - })) + mapHandler( + self, + (handler, input) => + Effect.provideServiceEffect(handler, service, typeof effect === "function" ? effect(input) : effect) + )) /** * Allows for execution of an effect, which optionally depends on command-line @@ -1096,14 +772,25 @@ export const provideEffectDiscard: { self: Command, effect: Effect.Effect<_, E2, R2> | ((input: Input) => Effect.Effect<_, E2, R2>) ) => - makeCommand({ - ...self, - handle: (input, commandPath) => - Effect.andThen( - typeof effect === "function" ? effect(input) : effect, - self.handle(input, commandPath) - ) - })) + mapHandler(self, (handler, input) => Effect.andThen(typeof effect === "function" ? effect(input) : effect, handler))) + +/* ========================================================================== */ +/* Execution */ +/* ========================================================================== */ + +const showHelp = ( + command: Command, + commandPath: ReadonlyArray, + errors?: ReadonlyArray +): Effect.Effect => + Effect.gen(function*() { + const formatter = yield* CliOutput.Formatter + const helpDoc = getHelpForCommandPath(command, commandPath) + yield* Console.log(formatter.formatHelpDoc(helpDoc)) + if (errors && errors.length > 0) { + yield* Console.error(formatter.formatErrors(errors)) + } + }) /** * Runs a command with the provided input arguments. @@ -1143,6 +830,8 @@ export const run: { readonly version: string } ) => { + // TODO: process.argv is a Node.js global. For browser/edge runtime support, + // consider accepting an optional args parameter or using a platform service. const input = process.argv.slice(2) return runWith(command, config)(input) } @@ -1151,8 +840,7 @@ export const run: { * Runs a command with explicitly provided arguments instead of using process.argv. * * This function is useful for testing CLI applications or when you want to - * programmatically execute commands with specific arguments. It provides the - * same functionality as `run` but with explicit control over the input arguments. + * programmatically execute commands with specific arguments. * * @example * ```ts @@ -1183,24 +871,6 @@ export const run: { * // Test version display * yield* runCommand(["--version"]) * }) - * - * // Use with different environments - * const deploy = Command.make("deploy", { - * env: Flag.string("env"), - * config: Argument.string("config") - * }, (config) => - * Effect.gen(function*() { - * yield* Console.log(`Deploying to ${config.env} with config ${config.config}`) - * }) - * ) - * - * const deployProgram = Effect.gen(function*() { - * const runDeploy = Command.runWith(deploy, { version: "2.0.0" }) - * - * // Programmatically run with different configurations - * yield* runDeploy(["--env", "staging", "staging.json"]) - * yield* runDeploy(["--env", "production", "prod.json"]) - * }) * ``` * * @since 4.0.0 @@ -1211,374 +881,59 @@ export const runWith = ( config: { readonly version: string } -): (input: ReadonlyArray) => Effect.Effect => - Effect.fnUntraced( - function*(input: ReadonlyArray) { - const args = input +): (input: ReadonlyArray) => Effect.Effect => { + const commandImpl = toImpl(command) + return Effect.fnUntraced( + function*(args: ReadonlyArray) { // Check for dynamic completion request early (before normal parsing) if (isCompletionRequest(args)) { handleCompletionRequest(command) return } - // Parse command arguments (built-ins are extracted automatically) + // Lex and extract built-in flags const { tokens, trailingOperands } = Lexer.lex(args) - const { - completions, - dynamicCompletions, - help, - logLevel, - remainder, - version - } = yield* Parser.extractBuiltInOptions(tokens) + const { completions, help, logLevel, remainder, version } = yield* Parser.extractBuiltInOptions(tokens) const parsedArgs = yield* Parser.parseArgs({ tokens: remainder, trailingOperands }, command) - const helpRenderer = yield* HelpFormatter.HelpRenderer + const commandPath = [command.name, ...Parser.getCommandPath(parsedArgs)] as const + const formatter = yield* CliOutput.Formatter + // Handle built-in flags (early exits) if (help) { - const commandPath = [command.name, ...Parser.getCommandPath(parsedArgs)] - const helpDoc = getHelpForCommandPath(command, commandPath) - const helpText = helpRenderer.formatHelpDoc(helpDoc) - yield* Console.log(helpText) + yield* Console.log(formatter.formatHelpDoc(getHelpForCommandPath(command, commandPath))) return - } else if (completions !== undefined) { - const script = completions === "bash" - ? generateBashCompletions(command, command.name) - : completions === "fish" - ? generateFishCompletions(command, command.name) - : generateZshCompletions(command, command.name) - yield* Console.log(script) - return - } else if (dynamicCompletions !== undefined) { - const script = generateDynamicCompletion(command, command.name, dynamicCompletions) - yield* Console.log(script) + } + if (completions !== undefined) { + yield* Console.log(generateDynamicCompletion(command, command.name, completions)) return - } else if (version && command.subcommands.length === 0) { - const versionText = helpRenderer.formatVersion(command.name, config.version) - yield* Console.log(versionText) + } + if (version) { + yield* Console.log(formatter.formatVersion(command.name, config.version)) return } - // If there are parsing errors and no help was requested, format and display the error + // Handle parsing errors if (parsedArgs.errors && parsedArgs.errors.length > 0) { - const error = parsedArgs.errors[0] - const helpRenderer = yield* HelpFormatter.HelpRenderer - const commandPath = [command.name, ...Parser.getCommandPath(parsedArgs)] - const helpDoc = getHelpForCommandPath(command, commandPath) - - // Show the full help first (to stdout with normal colors) - const helpText = helpRenderer.formatHelpDoc(helpDoc) - yield* Console.log(helpText) - - // Then show the error in a clearly marked ERROR section (to stderr) - yield* Console.error(helpRenderer.formatError(error)) - - return + return yield* showHelp(command, commandPath, parsedArgs.errors) } - - const parseResult = yield* Effect.result(command.parse(parsedArgs)) + const parseResult = yield* Effect.result(commandImpl.parse(parsedArgs)) if (parseResult._tag === "Failure") { - const error = parseResult.failure - const helpRenderer = yield* HelpFormatter.HelpRenderer - const commandPath = [command.name, ...Parser.getCommandPath(parsedArgs)] - const helpDoc = getHelpForCommandPath(command, commandPath) - - // Show the full help first (to stdout with normal colors) - const helpText = helpRenderer.formatHelpDoc(helpDoc) - yield* Console.log(helpText) - - // Then show the error in a clearly marked ERROR section (to stderr) - yield* Console.error(helpRenderer.formatError(error)) - - return + return yield* showHelp(command, commandPath, [parseResult.failure]) } const parsed = parseResult.success - // Create the execution program - const program = command.handle(parsed, [command.name]) - - // Apply log level if provided via built-ins - const finalProgram = logLevel !== undefined + // Create and run the execution program + const program = commandImpl.handle(parsed, [command.name]) + const withLogLevel = logLevel !== undefined ? Effect.provideService(program, References.MinimumLogLevel, logLevel) : program - // Normalize non-CLI errors into CliError.UserError so downstream catchTags - // can rely on CLI-tagged errors only. - const normalized = finalProgram.pipe( - Effect.catch((err) => - CliError.isCliError(err) ? Effect.fail(err) : Effect.fail(new CliError.UserError({ cause: err })) - ) - ) - - yield* normalized + yield* withLogLevel }, - Effect.catchTags({ - ShowHelp: (error: CliError.ShowHelp) => - Effect.gen(function*() { - const helpDoc = getHelpForCommandPath(command, error.commandPath) - const helpRenderer = yield* HelpFormatter.HelpRenderer - const helpText = helpRenderer.formatHelpDoc(helpDoc) - yield* Console.log(helpText) - }) - }), - // Preserve prior public behavior: surface original handler errors - Effect.catchTag("UserError", (error: CliError.UserError) => Effect.fail(error.cause as any)) + Effect.catch((error) => + error instanceof CliError.ShowHelp + ? showHelp(command, error.commandPath) + : Effect.fail(error) + ) ) - -// ============================================================================= -// Command Config -// ============================================================================= - -/** - * Transforms a nested command configuration into a flat structure for parsing. - * - * This function walks through the entire config tree and: - * 1. Extracts all Params into a single flat array (for command-line parsing) - * 2. Creates a "blueprint" tree that remembers the original structure - * 3. Assigns each Param an index to link parsed values back to their position - * - * The separation allows us to: - * - Parse all options using existing flat parsing logic - * - Reconstruct the original nested structure afterward - * - * @example - * Input: { name: Param.string("name"), db: { host: Param.string("host") } } - * Output: { - * options: [Param.string("name"), Param.string("host")], - * tree: { name: {_tag: "Param", index: 0}, db: {_tag: "ParsedConfig", tree: {host: {_tag: "Param", index: 1}}} } - * } - */ -const parseConfig = (config: CommandConfig): ParsedConfig => { - const orderedParams: Array = [] - const flags: Array = [] - const args: Array = [] - - // Recursively walk the config structure, building the blueprint tree - function parse(config: CommandConfig) { - const tree: ConfigTree = {} - for (const key in config) { - tree[key] = parseValue(config[key]) - } - return tree - } - - // Process each value in the config, extracting Params and preserving structure - function parseValue( - value: - | Param.Any - | ReadonlyArray - | CommandConfig - ): ConfigNode { - if (Array.isArray(value)) { - // Array of options/configs - preserve array structure - return { - _tag: "Array", - children: (value as Array).map((value) => parseValue(value)) - } - } else if (Param.isParam(value)) { - // Found a Param - add to appropriate array based on kind and record its index - const index = orderedParams.length - orderedParams.push(value) - - if (value.kind === "argument") { - args.push(value as Param.AnyArgument) - } else { - flags.push(value as Param.AnyFlag) - } - - return { - _tag: "Param", - index - } - } else { - // Nested config object - recursively process - return { - _tag: "ParsedConfig", - tree: parse(value as any) - } - } - } - - return { - [ParsedConfigTypeId]: ParsedConfigTypeId, - flags, - arguments: args, - orderedParams, - tree: parse(config) - } -} - -/** - * Reconstructs the original nested structure using parsed values and the blueprint tree. - * - * This is the inverse operation of parseConfig: - * 1. Takes the flat array of parsed option values - * 2. Uses the blueprint tree to determine where each value belongs - * 3. Rebuilds the original nested object structure - * - * The blueprint tree acts as a "map" showing how to reassemble the flat data - * back into the user's expected nested configuration shape. - * - * @param tree - The blueprint tree created by parseConfig - * @param results - Flat array of parsed values (in the same order as the options array) - * @returns The reconstructed nested configuration object - * - * @example - * Input tree: { name: {_tag: "Param", index: 0}, db: {_tag: "ParsedConfig", tree: {host: {_tag: "Param", index: 1}}} } - * Input results: ["myapp", "localhost"] - * Output: { name: "myapp", db: { host: "localhost" } } - */ -const reconstructConfigTree = ( - tree: ConfigTree, - results: ReadonlyArray -): Record => { - const output: Record = {} - - // Walk through each key in the blueprint tree - for (const key in tree) { - output[key] = nodeValue(tree[key]) - } - - return output - - // Convert a blueprint node back to its corresponding value - function nodeValue(node: ConfigNode): any { - if (node._tag === "Param") { - // Param reference - look up the parsed value by index - return results[node.index] - } else if (node._tag === "Array") { - // Array structure - recursively process each child - return node.children.map((node) => nodeValue(node)) - } else { - // Nested object - recursively reconstruct the subtree - return reconstructConfigTree(node.tree, results) - } - } -} - -// ============================================================================= -// Utilities -// ============================================================================= - -const makeCommand = (options: { - readonly name: Name - readonly config: CommandConfig | ParsedConfig - readonly service?: ServiceMap.Service, Input> | undefined - readonly description?: string | undefined - readonly subcommands?: ReadonlyArray> | undefined - readonly parse?: ((input: RawInput) => Effect.Effect) | undefined - readonly handle?: - | ((input: Input, commandPath: ReadonlyArray) => Effect.Effect) - | undefined -}): Command => { - const service = options.service ?? ServiceMap.Service, Input>(`${TypeId}/${options.name}`) - - const config = isParsedConfig(options.config) ? options.config : parseConfig(options.config) - - const handle = ( - input: Input, - commandPath: ReadonlyArray - ): Effect.Effect => - Predicate.isNotUndefined(options.handle) - ? options.handle(input, commandPath) - : Effect.fail(new CliError.ShowHelp({ commandPath })) - - const parse = options.parse ?? Effect.fnUntraced(function*(input: RawInput) { - const parsedArgs: Param.ParsedArgs = { flags: input.flags, arguments: input.arguments } - const values = yield* parseParams(parsedArgs, config.orderedParams) - return reconstructConfigTree(config.tree, values) as Input - }) - - return Object.assign(Object.create(Proto), { - name: options.name, - subcommands: options.subcommands ?? [], - config, - service, - parse, - handle, - ...(Predicate.isNotUndefined(options.description) - ? { description: options.description } - : {}) - }) -} - -/** - * Parses param values from parsed command arguments into their typed - * representations. - */ -const parseParams: (parsedArgs: Param.ParsedArgs, params: ReadonlyArray) => Effect.Effect< - ReadonlyArray, - CliError.CliError, - Environment -> = Effect.fnUntraced(function*(parsedArgs, params) { - const results: Array = [] - let currentArguments = parsedArgs.arguments - - for (const option of params) { - const [remainingArguments, parsed] = yield* option.parse({ - flags: parsedArgs.flags, - arguments: currentArguments - }) - results.push(parsed) - currentArguments = remainingArguments - } - - return results -}) - -/** - * Checks for duplicate flag names between parent and child commands. - */ -const checkForDuplicateFlags = ( - parent: Command, - subcommands: ReadonlyArray> -): void => { - const parentOptionNames = new Set() - - const extractNames = (options: ReadonlyArray): void => { - for (const option of options) { - const singles = Param.extractSingleParams(option) - for (const single of singles) { - parentOptionNames.add(single.name) - } - } - } - - extractNames(parent.config.flags) - - for (const subcommand of subcommands) { - for (const option of subcommand.config.flags) { - const singles = Param.extractSingleParams(option) - for (const single of singles) { - if (parentOptionNames.has(single.name)) { - throw new CliError.DuplicateOption({ - option: single.name, - parentCommand: parent.name, - childCommand: subcommand.name - }) - } - } - } - } -} - -/** - * Helper function to get help documentation for a specific command path. - * Navigates through the command hierarchy to find the right command. - */ -const getHelpForCommandPath = ( - command: Command, - commandPath: ReadonlyArray -): HelpDoc => { - let currentCommand: Command = command as any - - // Navigate through the command path to find the target command - for (let i = 1; i < commandPath.length; i++) { - const subcommandName = commandPath[i] - const subcommand = currentCommand.subcommands.find((sub) => sub.name === subcommandName) - if (subcommand) { - currentCommand = subcommand - } - } - - return getHelpDoc(currentCommand, commandPath) } diff --git a/packages/effect/src/unstable/cli/Flag.ts b/packages/effect/src/unstable/cli/Flag.ts index 45e78ec09..df30c5796 100644 --- a/packages/effect/src/unstable/cli/Flag.ts +++ b/packages/effect/src/unstable/cli/Flag.ts @@ -13,13 +13,21 @@ import type * as CliError from "./CliError.ts" import * as Param from "./Param.ts" import type * as Primitive from "./Primitive.ts" +// ------------------------------------------------------------------------------------- +// models +// ------------------------------------------------------------------------------------- + /** * Represents a command-line flag. * * @since 4.0.0 * @category models */ -export interface Flag extends Param.Param {} +export interface Flag extends Param.Param {} + +// ------------------------------------------------------------------------------------- +// constructors +// ------------------------------------------------------------------------------------- /** * Creates a string flag that accepts text input. @@ -35,7 +43,7 @@ export interface Flag extends Param.Param {} * @since 4.0.0 * @category constructors */ -export const string = (name: string): Flag => Param.string(Param.Flag, name) +export const string = (name: string): Flag => Param.string(Param.flagKind, name) /** * Creates a boolean flag that can be enabled or disabled. @@ -51,7 +59,7 @@ export const string = (name: string): Flag => Param.string(Param.Flag, n * @since 4.0.0 * @category constructors */ -export const boolean = (name: string): Flag => Param.boolean(Param.Flag, name) +export const boolean = (name: string): Flag => Param.boolean(Param.flagKind, name) /** * Creates an integer flag that accepts whole number input. @@ -67,7 +75,7 @@ export const boolean = (name: string): Flag => Param.boolean(Param.Flag * @since 4.0.0 * @category constructors */ -export const integer = (name: string): Flag => Param.integer(Param.Flag, name) +export const integer = (name: string): Flag => Param.integer(Param.flagKind, name) /** * Creates a float flag that accepts decimal number input. @@ -83,7 +91,7 @@ export const integer = (name: string): Flag => Param.integer(Param.Flag, * @since 4.0.0 * @category constructors */ -export const float = (name: string): Flag => Param.float(Param.Flag, name) +export const float = (name: string): Flag => Param.float(Param.flagKind, name) /** * Creates a date flag that accepts date input in ISO format. @@ -99,7 +107,7 @@ export const float = (name: string): Flag => Param.float(Param.Flag, nam * @since 4.0.0 * @category constructors */ -export const date = (name: string): Flag => Param.date(Param.Flag, name) +export const date = (name: string): Flag => Param.date(Param.flagKind, name) /** * Constructs option parameters that represent a choice between several inputs. @@ -126,7 +134,7 @@ export const date = (name: string): Flag => Param.date(Param.Flag, name) export const choiceWithValue = >( name: string, choices: Choice -): Flag => Param.choiceWithValue(Param.Flag, name, choices) +): Flag => Param.choiceWithValue(Param.flagKind, name, choices) /** * Simpler variant of `choiceWithValue` which maps each string to itself. @@ -137,7 +145,7 @@ export const choiceWithValue = >( name: string, choices: Choices -): Flag => Param.choice(Param.Flag, name, choices) +): Flag => Param.choice(Param.flagKind, name, choices) /** * Creates a path flag that accepts file system path input with validation options. @@ -169,7 +177,7 @@ export const path = (name: string, options?: { readonly pathType?: "file" | "directory" | "either" | undefined readonly mustExist?: boolean | undefined readonly typeName?: string | undefined -}): Flag => Param.path(Param.Flag, name, options) +}): Flag => Param.path(Param.flagKind, name, options) /** * Creates a file path flag that accepts file paths with optional existence validation. @@ -192,7 +200,7 @@ export const path = (name: string, options?: { */ export const file = (name: string, options?: { readonly mustExist?: boolean | undefined -}): Flag => Param.file(Param.Flag, name, options) +}): Flag => Param.file(Param.flagKind, name, options) /** * Creates a directory path flag that accepts directory paths with optional existence validation. @@ -215,7 +223,7 @@ export const file = (name: string, options?: { */ export const directory = (name: string, options?: { readonly mustExist?: boolean | undefined -}): Flag => Param.directory(Param.Flag, name, options) +}): Flag => Param.directory(Param.flagKind, name, options) /** * Creates a redacted flag that securely handles sensitive string input. @@ -241,7 +249,7 @@ export const directory = (name: string, options?: { * @since 4.0.0 * @category constructors */ -export const redacted = (name: string): Flag> => Param.redacted(Param.Flag, name) +export const redacted = (name: string): Flag> => Param.redacted(Param.flagKind, name) /** * Creates a flag that reads and returns file content as a string. @@ -250,14 +258,14 @@ export const redacted = (name: string): Flag> => Param * ```ts * import { Flag } from "effect/unstable/cli" * - * const config = Flag.fileString("config-file") + * const config = Flag.fileText("config-file") * // --config-file ./app.json will read the file content * ``` * * @since 4.0.0 * @category constructors */ -export const fileString = (name: string): Flag => Param.fileString(Param.Flag, name) +export const fileText = (name: string): Flag => Param.fileText(Param.flagKind, name) /** * Creates a flag that reads and parses the content of the specified file. @@ -283,7 +291,7 @@ export const fileString = (name: string): Flag => Param.fileString(Param export const fileParse = ( name: string, options?: Primitive.FileParseOptions | undefined -): Flag => Param.fileParse(Param.Flag, name, options) +): Flag => Param.fileParse(Param.flagKind, name, options) /** * Creates a flag that reads and validates file content using the specified @@ -309,24 +317,27 @@ export const fileSchema = ( name: string, schema: Schema.Codec, options?: Primitive.FileSchemaOptions | undefined -): Flag => Param.fileSchema(Param.Flag, name, schema, options) +): Flag => Param.fileSchema(Param.flagKind, name, schema, options) /** * Creates a flag that parses key=value pairs. * Useful for options that accept configuration values. * + * Note: Requires at least one key=value pair. Multiple pairs are merged + * into a single record. + * * @example * ```ts * import { Flag } from "effect/unstable/cli" * - * const env = Flag.keyValueMap("env") - * // --env FOO=bar will parse to { FOO: "bar" } + * const env = Flag.keyValuePair("env") + * // --env FOO=bar --env BAZ=qux will parse to { FOO: "bar", BAZ: "qux" } * ``` * * @since 4.0.0 * @category constructors */ -export const keyValueMap = (name: string): Flag> => Param.keyValueMap(Param.Flag, name) +export const keyValuePair = (name: string): Flag> => Param.keyValuePair(Param.flagKind, name) /** * Creates an empty sentinel flag that always fails to parse. @@ -343,7 +354,11 @@ export const keyValueMap = (name: string): Flag> => Param * @since 4.0.0 * @category constructors */ -export const none: Flag = Param.none("flag") +export const none: Flag = Param.none(Param.flagKind) + +// ------------------------------------------------------------------------------------- +// combinators +// ------------------------------------------------------------------------------------- /** * Adds an alias to a flag, allowing it to be referenced by multiple names. @@ -364,7 +379,7 @@ export const none: Flag = Param.none("flag") * ) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category aliasing */ export const withAlias: { @@ -388,7 +403,7 @@ export const withAlias: { * ) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category help documentation */ export const withDescription: { @@ -396,32 +411,39 @@ export const withDescription: { (self: Flag, description: string): Flag } = dual(2, (self: Flag, description: string) => Param.withDescription(self, description)) +// ------------------------------------------------------------------------------------- +// metadata +// ------------------------------------------------------------------------------------- + /** - * Adds a pseudo name to a flag for help documentation display. + * Sets a custom metavar (placeholder name) for the flag in help documentation. + * + * The metavar is displayed in usage text to indicate what value the user should provide. + * For example, `--output FILE` shows `FILE` as the metavar. * * @example * ```ts * import { Flag } from "effect/unstable/cli" * * const databaseFlag = Flag.string("database-url").pipe( - * Flag.withPseudoName("URL"), + * Flag.withMetavar("URL"), * Flag.withDescription("Database connection URL") * ) * // In help: --database-url URL * * const timeoutFlag = Flag.integer("timeout").pipe( - * Flag.withPseudoName("SECONDS") + * Flag.withMetavar("SECONDS") * ) * // In help: --timeout SECONDS * ``` * - * @since 1.0.0 - * @category help documentation + * @since 4.0.0 + * @category metadata */ -export const withPseudoName: { - (pseudoName: string): (self: Flag) => Flag - (self: Flag, pseudoName: string): Flag -} = dual(2, (self: Flag, pseudoName: string) => Param.withPseudoName(self, pseudoName)) +export const withMetavar: { + (metavar: string): (self: Flag) => Flag + (self: Flag, metavar: string): Flag +} = dual(2, (self: Flag, metavar: string) => Param.withMetavar(self, metavar)) /** * Makes a flag optional, returning an Option type that can be None if not provided. @@ -447,7 +469,7 @@ export const withPseudoName: { * }) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category optionality */ export const optional = (param: Flag): Flag> => Param.optional(param) @@ -470,7 +492,7 @@ export const optional = (param: Flag): Flag> => Param.opt * // If --host is not provided, defaults to "localhost" * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category optionality */ export const withDefault: { @@ -496,7 +518,7 @@ export const withDefault: { * ) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category mapping */ export const map: { @@ -523,7 +545,7 @@ export const map: { * ) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category mapping */ export const mapEffect: { @@ -563,7 +585,7 @@ export const mapEffect: { * ) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category mapping */ export const mapTryCatch: { @@ -575,27 +597,6 @@ export const mapTryCatch: { onError: (error: unknown) => string ) => Param.mapTryCatch(self, f, onError)) -/** - * Allows a flag to be specified multiple times, collecting all values into an array. - * - * @example - * ```ts - * import { Flag } from "effect/unstable/cli" - * - * const includeFlag = Flag.repeated(Flag.file("include")) - * // Usage: --include file1.ts --include file2.ts --include file3.ts - * // Result: ["file1.ts", "file2.ts", "file3.ts"] - * - * const verbosityFlag = Flag.repeated(Flag.boolean("verbose")) - * // Usage: --verbose --verbose --verbose - * // Result: [true, true, true] - * ``` - * - * @since 1.0.0 - * @category repetition - */ -export const repeated = (flag: Flag): Flag> => Param.repeated(flag) - /** * Requires a flag to be specified at least a minimum number of times. * @@ -613,7 +614,7 @@ export const repeated = (flag: Flag): Flag> => Param.repe * // Requires at least 1 tag * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category repetition */ export const atLeast: { @@ -638,7 +639,7 @@ export const atLeast: { * // Allows at most 1 debug flag * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category repetition */ export const atMost: { @@ -663,7 +664,7 @@ export const atMost: { * // Allows 0-5 exclude patterns * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category repetition */ export const between: { @@ -696,7 +697,7 @@ export const between: { * ) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category filtering */ export const filterMap: { @@ -732,7 +733,7 @@ export const filterMap: { * ) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category filtering */ export const filter: { @@ -764,7 +765,7 @@ export const filter: { * ) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category alternatives */ export const orElse: { @@ -800,7 +801,7 @@ export const orElse: { * }) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category alternatives */ export const orElseResult: { @@ -841,7 +842,7 @@ export const orElseResult: { * ) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category schemas */ export const withSchema: { diff --git a/packages/effect/src/unstable/cli/Param.ts b/packages/effect/src/unstable/cli/Param.ts index d365f608e..8787cd132 100644 --- a/packages/effect/src/unstable/cli/Param.ts +++ b/packages/effect/src/unstable/cli/Param.ts @@ -1,4 +1,13 @@ /** + * @internal + * + * Param is the polymorphic implementation shared by Argument.ts and Flag.ts. + * The `Kind` type parameter ("argument" | "flag") enables type-safe separation + * while sharing parsing logic and combinators. + * + * Users should import from `Argument` and `Flag` modules, not this module directly. + * This module is not exported from the public API. + * * @since 4.0.0 */ import * as Option from "../../data/Option.ts" @@ -35,16 +44,20 @@ export interface Param extends Param.Variance export type ParamKind = "argument" | "flag" /** + * Kind discriminator for positional argument parameters. + * * @since 4.0.0 * @category constants */ -export const Argument: "argument" = "argument" as const +export const argumentKind: "argument" = "argument" as const /** + * Kind discriminator for flag parameters. + * * @since 4.0.0 * @category constants */ -export const Flag: "flag" = "flag" as const +export const flagKind: "flag" = "flag" as const /** * Represents any parameter. @@ -52,7 +65,7 @@ export const Flag: "flag" = "flag" as const * @since 4.0.0 * @category models */ -export type Any = Param +export type Any = Param /** * Represents any positional argument parameter. @@ -60,7 +73,7 @@ export type Any = Param * @since 4.0.0 * @category models */ -export type AnyArgument = Param +export type AnyArgument = Param /** * Represents any flag parameter. @@ -68,7 +81,7 @@ export type AnyArgument = Param * @since 4.0.0 * @category models */ -export type AnyFlag = Param +export type AnyFlag = Param /** * @since 4.0.0 @@ -89,15 +102,9 @@ export declare namespace Param { * @category models */ export interface Variance extends Pipeable { - readonly [TypeId]: VarianceStruct - } - - /** - * @since 4.0.0 - * @category models - */ - export interface VarianceStruct { - readonly _A: Covariant + readonly [TypeId]: { + readonly _A: Covariant + } } } @@ -195,9 +202,11 @@ const Proto = { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * - * const maybeParam = Param.string(Param.Flag, "name") + * const maybeParam = Param.string(Param.flagKind, "name") * * if (Param.isParam(maybeParam)) { * console.log("This is a Param") @@ -214,9 +223,11 @@ export const isParam = (u: unknown): u is Param => Predicate.has * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" * - * const nameParam = Param.string(Param.Flag, "name") + * // @internal - this module is not exported publicly + * + * const nameParam = Param.string(Param.flagKind, "name") * const optionalParam = Param.optional(nameParam) * * console.log(Param.isSingle(nameParam)) // true @@ -230,6 +241,15 @@ export const isSingle = ( param: Param ): param is Single => Predicate.isTagged(param, "Single") +/** + * Type guard to check if a Single param is a flag (not an argument). + * + * @internal + */ +export const isFlagParam = ( + single: Single +): single is Single => single.kind === "flag" + /** * @since 4.0.0 * @category constructors @@ -243,9 +263,9 @@ export const makeSingle = (params: { readonly aliases?: ReadonlyArray | undefined }): Single => { const parse: Parse = (args) => - params.kind === Argument + params.kind === argumentKind ? parsePositional(params.name, params.primitiveType, args) - : parseOption(params.name, params.primitiveType, args) + : parseFlag(params.name, params.primitiveType, args) return Object.assign(Object.create(Proto), { _tag: "Single", ...params, @@ -260,13 +280,15 @@ export const makeSingle = (params: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * // Create a string flag - * const nameFlag = Param.string(Param.Flag, "name") + * const nameFlag = Param.string(Param.flagKind, "name") * * // Create a string argument - * const fileArg = Param.string(Param.Argument, "file") + * const fileArg = Param.string(Param.argumentKind, "file") * * // Usage in CLI: --name "John Doe" or as positional argument * ``` @@ -289,13 +311,15 @@ export const string = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * // Create a boolean flag - * const verboseFlag = Param.boolean(Param.Flag, "verbose") + * const verboseFlag = Param.boolean(Param.flagKind, "verbose") * * // Create a boolean argument - * const enableArg = Param.boolean(Param.Argument, "enable") + * const enableArg = Param.boolean(Param.argumentKind, "enable") * * // Usage in CLI: --verbose (defaults to true when present, false when absent) * // or as positional: true/false @@ -319,13 +343,15 @@ export const boolean = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * // Create an integer flag - * const portFlag = Param.integer(Param.Flag, "port") + * const portFlag = Param.integer(Param.flagKind, "port") * * // Create an integer argument - * const countArg = Param.integer(Param.Argument, "count") + * const countArg = Param.integer(Param.argumentKind, "count") * * // Usage in CLI: --port 8080 or as positional argument: 42 * ``` @@ -348,13 +374,15 @@ export const integer = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * // Create a float flag - * const rateFlag = Param.float(Param.Flag, "rate") + * const rateFlag = Param.float(Param.flagKind, "rate") * * // Create a float argument - * const thresholdArg = Param.float(Param.Argument, "threshold") + * const thresholdArg = Param.float(Param.argumentKind, "threshold") * * // Usage in CLI: --rate 0.95 or as positional argument: 3.14159 * ``` @@ -377,13 +405,15 @@ export const float = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * // Create a date flag - * const startFlag = Param.date(Param.Flag, "start-date") + * const startFlag = Param.date(Param.flagKind, "start-date") * * // Create a date argument - * const dueDateArg = Param.date(Param.Argument, "due-date") + * const dueDateArg = Param.date(Param.argumentKind, "due-date") * * // Usage in CLI: --start-date "2023-12-25" or as positional: "2023-01-01" * // Parses to JavaScript Date object @@ -408,7 +438,9 @@ export const date = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * type Animal = Dog | Cat * @@ -420,7 +452,7 @@ export const date = ( * readonly _tag: "Cat" * } * - * const animal = Param.choiceWithValue(Param.Flag, "animal", [ + * const animal = Param.choiceWithValue(Param.flagKind, "animal", [ * ["dog", { _tag: "Dog" }], * ["cat", { _tag: "Cat" }] * ]) @@ -445,9 +477,11 @@ export const choiceWithValue = < * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * - * const logLevel = Param.choice(Param.Flag, "log-level", [ + * const logLevel = Param.choice(Param.flagKind, "log-level", [ * "debug", * "info", * "warn", @@ -471,16 +505,18 @@ export const choice = < * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * // Basic path parameter - * const outputPath = Param.path(Param.Flag, "output") + * const outputPath = Param.path(Param.flagKind, "output") * * // Path that must exist - * const inputPath = Param.path(Param.Flag, "input", { mustExist: true }) + * const inputPath = Param.path(Param.flagKind, "input", { mustExist: true }) * * // File-only path - * const configFile = Param.path(Param.Flag, "config", { + * const configFile = Param.path(Param.flagKind, "config", { * pathType: "file", * mustExist: true, * typeName: "config-file" @@ -514,13 +550,15 @@ export const path = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * // Basic directory parameter - * const outputDir = Param.directory(Param.Flag, "output-dir") + * const outputDir = Param.directory(Param.flagKind, "output-dir") * * // Directory that must exist - * const sourceDir = Param.directory(Param.Flag, "source", { mustExist: true }) + * const sourceDir = Param.directory(Param.flagKind, "source", { mustExist: true }) * * // Usage: --output-dir /path/to/dir --source /existing/dir * ``` @@ -549,13 +587,15 @@ export const directory = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * // Basic file parameter - * const outputFile = Param.file(Param.Flag, "output") + * const outputFile = Param.file(Param.flagKind, "output") * * // File that must exist - * const inputFile = Param.file(Param.Flag, "input", { mustExist: true }) + * const inputFile = Param.file(Param.flagKind, "input", { mustExist: true }) * * // Usage: --output result.txt --input existing-file.txt * ``` @@ -582,13 +622,15 @@ export const file = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * // Create a password parameter - * const password = Param.redacted(Param.Flag, "password") + * const password = Param.redacted(Param.flagKind, "password") * * // Create an API key argument - * const apiKey = Param.redacted(Param.Argument, "api-key") + * const apiKey = Param.redacted(Param.argumentKind, "api-key") * * // Usage: --password (value will be hidden in help/logs) * ``` @@ -611,13 +653,15 @@ export const redacted = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * // Read a config file as string - * const configContent = Param.fileString(Param.Flag, "config") + * const configContent = Param.fileText(Param.flagKind, "config") * * // Read a template file as argument - * const templateContent = Param.fileString(Param.Argument, "template") + * const templateContent = Param.fileText(Param.argumentKind, "template") * * // Usage: --config config.txt (reads file content into string) * ``` @@ -625,10 +669,10 @@ export const redacted = ( * @since 4.0.0 * @category constructors */ -export const fileString = (kind: Kind, name: string): Param => +export const fileText = (kind: Kind, name: string): Param => makeSingle({ name, - primitiveType: Primitive.fileString, + primitiveType: Primitive.fileText, kind }) @@ -640,14 +684,16 @@ export const fileString = (kind: Kind, name: string): Pa * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * // Will use the extension of the file passed on the command line to determine * // the parser to use - * const config = Param.fileParse(Param.Flag, "config") + * const config = Param.fileParse(Param.flagKind, "config") * * // Will use the JSON parser - * const jsonConfig = Param.fileParse(Param.Flag, "json-config", { format: "json" }) + * const jsonConfig = Param.fileParse(Param.flagKind, "json-config", { format: "json" }) * ``` * * @since 4.0.0 @@ -670,7 +716,8 @@ export const fileParse = ( * @example * ```ts * import { Schema } from "effect/schema" - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * // @internal - this module is not exported publicly * * // Parse JSON config file * const configSchema = Schema.Struct({ @@ -678,12 +725,12 @@ export const fileParse = ( * host: Schema.String * }).pipe(Schema.fromJsonString) * - * const config = Param.fileSchema(Param.Flag, "config", configSchema, { + * const config = Param.fileSchema(Param.flagKind, "config", configSchema, { * format: "json" * }) * * // Parse YAML file - * const yamlConfig = Param.fileSchema(Param.Flag, "config", configSchema, { + * const yamlConfig = Param.fileSchema(Param.flagKind, "config", configSchema, { * format: "yaml" * }) * @@ -709,21 +756,26 @@ export const fileSchema = ( * Creates a param that parses key=value pairs. * Useful for options that accept configuration values. * + * Note: Requires at least one key=value pair. The parsed pairs are merged + * into a single record object. + * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * - * const env = Param.keyValueMap(Param.Flag, "env") + * const env = Param.keyValuePair(Param.flagKind, "env") * // --env FOO=bar --env BAZ=qux will parse to { FOO: "bar", BAZ: "qux" } * - * const props = Param.keyValueMap(Param.Flag, "property") + * const props = Param.keyValuePair(Param.flagKind, "property") * // --property name=value --property debug=true * ``` * * @since 4.0.0 * @category constructors */ -export const keyValueMap = ( +export const keyValuePair = ( kind: Kind, name: string ): Param> => @@ -731,7 +783,7 @@ export const keyValueMap = ( variadic( makeSingle({ name, - primitiveType: Primitive.keyValueMap, + primitiveType: Primitive.keyValuePair, kind }), { min: 1 } @@ -746,15 +798,17 @@ export const keyValueMap = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * // Create a none parameter for composition - * const noneParam = Param.none(Param.Flag) + * const noneParam = Param.none(Param.flagKind) * * // Often used in conditional parameter creation * const conditionalParam = process.env.NODE_ENV === "production" - * ? Param.string(Param.Flag, "my-dev-flag") - * : Param.none(Param.Flag) + * ? Param.string(Param.flagKind, "my-dev-flag") + * : Param.none(Param.flagKind) * ``` * * @since 4.0.0 @@ -780,15 +834,17 @@ const FLAG_DASH_REGEX = /^-+/ * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * - * const force = Param.boolean(Param.Flag, "force").pipe( + * const force = Param.boolean(Param.flagKind, "force").pipe( * Param.withAlias("-f"), * Param.withAlias("--no-prompt") * ) * * // Also works on composed params: - * const count = Param.integer(Param.Flag, "count").pipe( + * const count = Param.integer(Param.flagKind, "count").pipe( * Param.optional, * Param.withAlias("-c") // finds the underlying Single and adds alias * ) @@ -816,9 +872,11 @@ export const withAlias: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * - * const verbose = Param.boolean(Param.Flag, "verbose").pipe( + * const verbose = Param.boolean(Param.flagKind, "verbose").pipe( * Param.withAlias("-v"), * Param.withDescription("Enable verbose output") * ) @@ -843,9 +901,11 @@ export const withDescription: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" * - * const port = Param.integer(Param.Flag, "port").pipe( + * // @internal - this module is not exported publicly + * + * const port = Param.integer(Param.flagKind, "port").pipe( * Param.map(n => ({ port: n, url: `http://localhost:${n}` })) * ) * ``` @@ -876,10 +936,13 @@ export const map: { * * @example * ```ts - * import { Param, CliError } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly + * import { CliError } from "effect/unstable/cli" * import { Effect } from "effect" * - * const validatedEmail = Param.string(Param.Flag, "email").pipe( + * const validatedEmail = Param.string(Param.flagKind, "email").pipe( * Param.mapEffect(email => * email.includes("@") * ? Effect.succeed(email) @@ -927,9 +990,11 @@ export const mapEffect: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" * - * const parsedJson = Param.string(Param.Flag, "config").pipe( + * // @internal - this module is not exported publicly + * + * const parsedJson = Param.string(Param.flagKind, "config").pipe( * Param.mapTryCatch( * str => JSON.parse(str), * error => `Invalid JSON: ${error instanceof Error ? error.message : String(error)}` @@ -955,6 +1020,7 @@ export const mapTryCatch: { f: (a: A) => B, onError: (error: unknown) => string ) => { + const single = getUnderlyingSingleOrThrow(self) const parse: Parse = (args) => Effect.flatMap(self.parse(args), ([leftover, a]) => Effect.try({ @@ -964,7 +1030,7 @@ export const mapTryCatch: { Effect.mapError( (error) => new CliError.InvalidValue({ - option: "unknown", + option: single.name, value: String(a), expected: error }) @@ -994,16 +1060,18 @@ export const mapTryCatch: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * // Create an optional port option * // - When not provided: returns Option.none() * // - When provided: returns Option.some(parsedValue) - * const port = Param.optional(Param.integer(Param.Flag, "port")) + * const port = Param.optional(Param.integer(Param.flagKind, "port")) * ``` * * @since 4.0.0 - * @category constructors + * @category combinators */ export const optional = ( param: Param @@ -1032,15 +1100,17 @@ export const optional = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * // Using the pipe operator to make an option optional - * const port = Param.integer(Param.Flag, "port").pipe( + * const port = Param.integer(Param.flagKind, "port").pipe( * Param.withDefault(8080) * ) * * // Can also be used with other combinators - * const verbose = Param.boolean(Param.Flag, "verbose").pipe( + * const verbose = Param.boolean(Param.flagKind, "verbose").pipe( * Param.withAlias("-v"), * Param.withDescription("Enable verbose output"), * Param.withDefault(false) @@ -1084,19 +1154,21 @@ export type VariadicParamOptions = { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * // Basic variadic parameter (0 to infinity) - * const tags = Param.variadic(Param.string(Param.Flag, "tag")) + * const tags = Param.variadic(Param.string(Param.flagKind, "tag")) * * // Variadic with minimum count * const inputs = Param.variadic( - * Param.string(Param.Flag, "input"), + * Param.string(Param.flagKind, "input"), * { min: 1 } // at least 1 required * ) * * // Variadic with both min and max - * const limited = Param.variadic(Param.string(Param.Flag, "item"), { + * const limited = Param.variadic(Param.string(Param.flagKind, "item"), { * min: 2, // at least 2 times * max: 2, // at most 5 times * }) @@ -1135,10 +1207,12 @@ export const variadic = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * // Allow 1-3 file inputs - * const files = Param.string(Param.Flag, "file").pipe( + * const files = Param.string(Param.flagKind, "file").pipe( * Param.between(1, 3), * Param.withAlias("-f") * ) @@ -1147,7 +1221,7 @@ export const variadic = ( * // Result: ["a.txt", "b.txt"] * * // Allow 0 or more tags - * const tags = Param.string(Param.Flag, "tag").pipe( + * const tags = Param.string(Param.flagKind, "tag").pipe( * Param.between(0, Number.MAX_SAFE_INTEGER) * ) * @@ -1172,33 +1246,6 @@ export const between: { return variadic(self, { min, max }) }) -/** - * Wraps an option to allow it to be specified multiple times without limit. - * - * This combinator transforms an option to accept any number of occurrences - * on the command line, returning an array of all provided values. - * - * @example - * ```ts - * import { Param } from "effect/unstable/cli" - * - * // Allow unlimited file inputs - * const files = Param.string(Param.Flag, "file").pipe( - * Param.repeated, - * Param.withAlias("-f") - * ) - * - * // Parse: --file a.txt --file b.txt --file c.txt --file d.txt - * // Result: ["a.txt", "b.txt", "c.txt", "d.txt"] - * ``` - * - * @since 4.0.0 - * @category combinators - */ -export const repeated = ( - self: Param -): Param> => variadic(self) - /** * Wraps an option to allow it to be specified at most `max` times. * @@ -1207,10 +1254,12 @@ export const repeated = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * // Allow at most 3 warning suppressions - * const suppressions = Param.string(Param.Flag, "suppress").pipe( + * const suppressions = Param.string(Param.flagKind, "suppress").pipe( * Param.atMost(3) * ) * @@ -1239,10 +1288,12 @@ export const atMost: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * * // Require at least 2 input files - * const inputs = Param.string(Param.Flag, "input").pipe( + * const inputs = Param.string(Param.flagKind, "input").pipe( * Param.atLeast(2), * Param.withAlias("-i") * ) @@ -1273,9 +1324,10 @@ export const atLeast: { * @example * ```ts * import { Option } from "effect/data" - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * // @internal - this module is not exported publicly * - * const positiveInt = Param.integer(Param.Flag, "count").pipe( + * const positiveInt = Param.integer(Param.flagKind, "count").pipe( * Param.filterMap( * (n) => n > 0 ? Option.some(n) : Option.none(), * (n) => `Expected positive integer, got ${n}` @@ -1322,9 +1374,11 @@ export const filterMap: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * - * const evenNumber = Param.integer(Param.Flag, "num").pipe( + * const evenNumber = Param.integer(Param.flagKind, "num").pipe( * Param.filter( * n => n % 2 === 0, * n => `Expected even number, got ${n}` @@ -1352,34 +1406,37 @@ export const filter: { ) => filterMap(self, Option.liftPredicate(predicate), onFalse)) /** - * Sets a custom display name for the param type in help documentation. + * Sets a custom metavar (placeholder name) for the param in help documentation. * - * This is useful when you want to override the default type name shown in help text. + * The metavar is displayed in usage text to indicate what value the user should provide. + * For example, `--output FILE` shows `FILE` as the metavar. * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" * - * const port = Param.integer(Param.Flag, "port").pipe( - * Param.withPseudoName("PORT"), + * // @internal - this module is not exported publicly + * + * const port = Param.integer(Param.flagKind, "port").pipe( + * Param.withMetavar("PORT"), * Param.filter(p => p >= 1 && p <= 65535, () => "Port must be between 1 and 65535") * ) * ``` * * @since 4.0.0 - * @category combinators + * @category metadata */ -export const withPseudoName: { - (pseudoName: string): (self: Param) => Param - (self: Param, pseudoName: string): Param +export const withMetavar: { + (metavar: string): (self: Param) => Param + (self: Param, metavar: string): Param } = dual(2, ( self: Param, - pseudoName: string + metavar: string ) => transformSingle(self, (single) => makeSingle({ ...single, - typeName: pseudoName + typeName: metavar }))) /** @@ -1388,7 +1445,8 @@ export const withPseudoName: { * @example * ```ts * import { Schema } from "effect/schema" - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * // @internal - this module is not exported publicly * * const isEmail = Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) * @@ -1396,7 +1454,7 @@ export const withPseudoName: { * Schema.check(isEmail) * ) * - * const email = Param.string(Param.Flag, "email").pipe( + * const email = Param.string(Param.flagKind, "email").pipe( * Param.withSchema(Email) * ) * ``` @@ -1428,10 +1486,12 @@ export const withSchema: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" * - * const config = Param.file(Param.Flag, "config").pipe( - * Param.orElse(() => Param.string(Param.Flag, "config-url")) + * // @internal - this module is not exported publicly + * + * const config = Param.file(Param.flagKind, "config").pipe( + * Param.orElse(() => Param.string(Param.flagKind, "config-url")) * ) * ``` * @@ -1461,10 +1521,12 @@ export const orElse: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * import * as Param from "effect/unstable/cli/Param" + * + * // @internal - this module is not exported publicly * - * const configSource = Param.file(Param.Flag, "config").pipe( - * Param.orElseResult(() => Param.string(Param.Flag, "config-url")) + * const configSource = Param.file(Param.flagKind, "config").pipe( + * Param.orElseResult(() => Param.string(Param.flagKind, "config-url")) * ) * // Returns Result * ``` @@ -1532,7 +1594,7 @@ const parsePositional: ( return [args.arguments.slice(1), value] as const }) -const parseOption: ( +const parseFlag: ( name: string, primitiveType: Primitive.Primitive, args: ParsedArgs @@ -1545,7 +1607,7 @@ const parseOption: ( if (providedValues === undefined || providedValues.length === 0) { // Option not provided (empty array due to initialization) - if (primitiveType._tag === "Boolean") { + if (Primitive.isBoolean(primitiveType)) { // Boolean params default to false when not present return [args.arguments, false as any] as const } else { diff --git a/packages/effect/src/unstable/cli/Primitive.ts b/packages/effect/src/unstable/cli/Primitive.ts index bd441e6cc..f74840bdb 100644 --- a/packages/effect/src/unstable/cli/Primitive.ts +++ b/packages/effect/src/unstable/cli/Primitive.ts @@ -1,4 +1,14 @@ /** + * Primitive types for CLI parameter parsing. + * + * Primitives handle the low-level parsing of string input into typed values. + * Most users should use the higher-level `Argument` and `Flag` modules instead. + * + * This module is primarily useful for: + * - Creating custom primitive types + * - Understanding how CLI parsing works internally + * - Advanced customization of parsing behavior + * * @since 4.0.0 */ import * as Ini from "ini" @@ -60,15 +70,9 @@ export declare namespace Primitive { * @category models */ export interface Variance { - readonly [TypeId]: VarianceStruct - } - - /** - * @since 4.0.0 - * @category models - */ - export interface VarianceStruct { - readonly _A: Covariant + readonly [TypeId]: { + readonly _A: Covariant + } } } @@ -84,6 +88,9 @@ export const isTrueValue = Schema.is(Config.TrueValues) /** @internal */ export const isFalseValue = Schema.is(Config.FalseValues) +/** @internal */ +export const isBoolean = (p: Primitive): p is Primitive => p._tag === "Boolean" + const makePrimitive = ( tag: string, parse: ( @@ -418,7 +425,7 @@ export const redacted: Primitive> = makePrimitive( * import { Effect } from "effect" * * const readConfigFile = Effect.gen(function* () { - * const content = yield* Primitive.fileString.parse("./config.json") + * const content = yield* Primitive.fileText.parse("./config.json") * console.log(content) // File contents as string * * const parsed = JSON.parse(content) @@ -429,8 +436,8 @@ export const redacted: Primitive> = makePrimitive( * @since 4.0.0 * @category constructors */ -export const fileString: Primitive = makePrimitive( - "FileString", +export const fileText: Primitive = makePrimitive( + "FileText", Effect.fnUntraced(function*(filePath) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path @@ -484,7 +491,6 @@ export type FileParseOptions = { const fileParsers: Record unknown> = { ini: (content: string) => Ini.parse(content), json: (content: string) => JSON.parse(content), - tml: (content: string) => Toml.parse(content), toml: (content: string) => Toml.parse(content), yml: (content: string) => Yaml.parse(content), yaml: (content: string) => Yaml.parse(content) @@ -519,7 +525,7 @@ export const fileParse = (options?: FileParseOptions): Primitive => { if (parser === undefined) { return yield* Effect.fail(`Unsupported file format: ${fileFormat}`) } - const content = yield* fileString.parse(filePath) + const content = yield* fileText.parse(filePath) return yield* Effect.try({ try: () => parser(content), catch: (error) => `Failed to parse '.${fileFormat}' file content: ${error}` @@ -588,7 +594,7 @@ export const fileSchema = ( } /** - * Parses `key=value` pairs into a record object. + * Parses a single `key=value` pair into a record object. * * @example * ```ts @@ -596,13 +602,13 @@ export const fileSchema = ( * import { Effect } from "effect" * * const parseKeyValue = Effect.gen(function* () { - * const result1 = yield* Primitive.keyValueMap.parse("name=john") + * const result1 = yield* Primitive.keyValuePair.parse("name=john") * console.log(result1) // { name: "john" } * - * const result2 = yield* Primitive.keyValueMap.parse("port=3000") + * const result2 = yield* Primitive.keyValuePair.parse("port=3000") * console.log(result2) // { port: "3000" } * - * const result3 = yield* Primitive.keyValueMap.parse("debug=true") + * const result3 = yield* Primitive.keyValuePair.parse("debug=true") * console.log(result3) // { debug: "true" } * }) * ``` @@ -610,8 +616,8 @@ export const fileSchema = ( * @since 4.0.0 * @category constructors */ -export const keyValueMap: Primitive> = makePrimitive( - "KeyValueMap", +export const keyValuePair: Primitive> = makePrimitive( + "KeyValuePair", Effect.fnUntraced(function*(value) { const parts = value.split("=") if (parts.length !== 2) { @@ -665,7 +671,7 @@ export const none: Primitive = makePrimitive("None", () => Effect.fail("T * console.log(Primitive.getTypeName(Primitive.integer)) // "integer" * console.log(Primitive.getTypeName(Primitive.boolean)) // "boolean" * console.log(Primitive.getTypeName(Primitive.date)) // "date" - * console.log(Primitive.getTypeName(Primitive.keyValueMap)) // "key=value" + * console.log(Primitive.getTypeName(Primitive.keyValuePair)) // "key=value" * * const logLevelChoice = Primitive.choice([ * ["debug", "debug"], @@ -695,11 +701,13 @@ export const getTypeName = (primitive: Primitive): string => { return "choice" case "Redacted": return "string" - case "FileString": + case "FileText": + return "file" + case "FileParse": return "file" case "FileSchema": return "file" - case "KeyValueMap": + case "KeyValuePair": return "key=value" case "None": return "none" diff --git a/packages/effect/src/unstable/cli/Prompt.ts b/packages/effect/src/unstable/cli/Prompt.ts index 3f3d3a452..94e4a675a 100644 --- a/packages/effect/src/unstable/cli/Prompt.ts +++ b/packages/effect/src/unstable/cli/Prompt.ts @@ -494,7 +494,7 @@ export const platformFigures = Effect.map( * @since 4.0.0 * @category utility types */ -export type Any = Prompt +export type Any = Prompt /** * @since 4.0.0 diff --git a/packages/effect/src/unstable/cli/SEMANTICS.md b/packages/effect/src/unstable/cli/SEMANTICS.md new file mode 100644 index 000000000..4dbeafd9e --- /dev/null +++ b/packages/effect/src/unstable/cli/SEMANTICS.md @@ -0,0 +1,129 @@ +# CLI Semantics (Effect CLI) + +This file records the intended parsing semantics with a short usage example and the test that locks it in. Examples show shell usage, not code. + +- **Parent flags allowed before or after subcommand (npm-style)** + Example: `tool --global install --pkg cowsay` and `tool install --pkg cowsay --global` + Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should accept parent flags before or after a subcommand (npm-style)" + +- **Only the first value token may open a subcommand; later values are operands** + Example: `tool install pkg1 pkg2` → `install` chosen as subcommand; `pkg1 pkg2` are operands + Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should accept parent flags before or after a subcommand (npm-style)" (second invocation covers later operands) + +- **`--` stops option parsing; everything after is an operand (no subcommands/flags)** + Example: `tool -- child --value x` → operands: `child --value x`; subcommand `child` is not entered + Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should treat tokens after -- as operands (no subcommand or flags)" + +- **Options may appear before, after, or between operands (relaxed POSIX Guideline 9)** + Examples: `tool copy --recursive src dest`, `tool copy src dest --recursive`, `tool copy --recursive src dest --force` + Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should support options before, after, or between operands (relaxed POSIX Syntax Guideline No. 9)" + +- **Boolean flags default to true when present; explicit true/false literals are accepted immediately after** + Example: `tool --verbose deploy --target-version 1.0.0` + Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should handle boolean flags before subcommands" + +- **Unknown subcommands emit suggestions** + Example: `tool cpy` → suggests `copy` + Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should suggest similar subcommands for unknown subcommands" + +- **Unknown options emit suggestions (long and short)** + Examples: `tool --debugs copy ...`, `tool -u copy ...` + Tests: `packages/effect/test/unstable/cli/Command.test.ts` – "should suggest similar options for unrecognized options" and "should suggest similar short options for unrecognized short options" + +- **Repeated key=value flags merge into one map** + Example: `tool env --env foo=bar --env cool=dude` → `{ foo: "bar", cool: "dude" }` + Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should merge repeated key=value flags into a single record" + +- **Parent context is accessible inside subcommands** + Example: `tool --global install --pkg cowsay` → subcommand can read `global` from parent context + Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should allow direct accessing parent config in subcommands" + +- **Built-in flags (`--version`, `--help`) take global precedence** + Example: `tool --version copy src dest` → prints version and exits; subcommand is not run + Tests: `packages/effect/test/unstable/cli/Command.test.ts` – "should print version and exit even with subcommands (global precedence)" and "should print version and exit when --version appears before subcommand" + +If you add or change semantics, update this file and reference the exact test that proves the behavior. + +## Semantics Landscape (popular CLIs vs Effect) + +Below each semantic you’ll find: a short description, a usage example, how major CLI libraries handle it (and whether it’s configurable), and where Effect currently lands (with suggestions if any). + +### Parent flags after a subcommand name (npm-style globals) +- **What**: Whether options defined on the parent command can appear *after* the subcommand token. +- **Example**: `tool install --pkg cowsay --global` +- **Commander / yargs / clap**: Allowed by default; commander/clap can tighten with options. +- **Click / argparse / docopt**: Not allowed; options must be before the command they belong to. +- **Cobra**: Persistent flags are available to children; placement is typically before subcommand, but not strictly enforced. +- **Effect (current)**: Allowed (permissive). Backed by test: “should accept parent flags before or after a subcommand (npm-style)”. +- **Suggestion**: Keep permissive default; document clearly (done). If a future app needs strictness, add an opt-in validator rather than changing defaults. + +### Options after operands (relaxed POSIX Guideline 9) +- **What**: Whether flags can appear after or between positional arguments. +- **Example**: `tool copy src dest --recursive` or `tool copy --recursive src dest --force` +- **Commander / yargs / clap**: Allowed by default; clap can be configured to prefer subcommand precedence. +- **Click / argparse / docopt**: Disallow; options must precede positionals for that command. +- **Cobra**: Generally allows mixed order; `--` ends options. +- **Effect (current)**: Allowed. Test: “should support options before, after, or between operands (relaxed POSIX Syntax Guideline No. 9)”. +- **Suggestion**: Keep permissive; no change. + +### Subcommand selection: only the first value may open a subcommand +- **What**: Whether only the first non-option token can be treated as a subcommand name. +- **Example**: `tool install pkg1 pkg2` → `install` is subcommand; `pkg1 pkg2` are operands. +- **Commander / yargs / clap / Click / argparse / docopt / Cobra**: Yes, subcommand chosen at first value (unless special external-subcommand features are enabled). +- **Effect (current)**: Yes. Covered implicitly by subcommand tests. +- **Suggestion**: Keep; this is the least surprising and matches most ecosystems. + +### End-of-options marker `--` +- **What**: Tokens after `--` are treated purely as operands. +- **Example**: `tool -- child --value x` → operands `child --value x`. +- **Commander / yargs / clap / Click / argparse / docopt / Cobra**: Supported. +- **Effect (current)**: Supported. Test: “should treat tokens after -- as operands (no subcommand or flags)”. +- **Suggestion**: Keep; already locked in by test. + +### Boolean flags defaulting to true when present; optional explicit literal +- **What**: Supplying `--flag` implies true; explicit `--flag false` (or 0/no/off) is accepted. +- **Example**: `tool --verbose deploy` and `tool --verbose false deploy`. +- **Commander / yargs / clap**: Yes; boolean options coerce common literals. +- **Click / argparse / docopt**: Typically `--flag/--no-flag` pairs; some accept explicit literals with `type=bool` in argparse. +- **Effect (current)**: Yes. Test: “should handle boolean flags before subcommands”. +- **Suggestion**: Keep; no change. + +### Unknown subcommands/options suggestions +- **What**: Whether unrecognized tokens produce Levenshtein suggestions. +- **Example**: `tool cpy` → suggest `copy`; `tool --debugs` → suggest `--debug`. +- **Commander / yargs / clap**: Suggestions configurable (clap has built-in `color`/`suggestions`); commander can display “Did you mean”. +- **Click / argparse / docopt / Cobra**: Usually fail with an error; some wrappers add suggestions. +- **Effect (current)**: Suggestions enabled; tests cover unknown subcommands and options (short/long). +- **Suggestion**: Keep; already aligned with “friendly” CLIs. + +### Parent context accessible in subcommands +- **What**: Ability for subcommand handlers to read the parsed config of their parent. +- **Example**: `tool --global install --pkg cowsay` → subcommand reads `global`. +- **Commander / yargs / clap / Cobra**: Supported via shared options or persistent flags; Click/argparse require manual plumbing. +- **Effect (current)**: Supported; test “should allow direct accessing parent config in subcommands”. +- **Suggestion**: Keep; this is a strength of our design. + +### Repeated key=value flags merging +- **What**: Multiple `--env KEY=VAL` occurrences merge into a single map. +- **Example**: `tool --env foo=bar --env cool=dude` → `{foo: "bar", cool: "dude"}`. +- **Commander / yargs / clap**: Arrays/maps supported via custom options; behavior differs unless configured. +- **Effect (current)**: Merge; test “should merge repeated key=value flags into a single record”. +- **Suggestion**: Keep; predictable and convenient. + +### Required or mutually exclusive flag sets (validation, not parsing) +- **What**: Constraints like "must provide either --token or --user/--pass", or "--json and --color cannot both be set". +- **Commander / yargs / clap**: Provide APIs for required/conflicts; Click supports required options and `mutually_exclusive` via groups; argparse has mutually exclusive groups. +- **Effect (current)**: Not enforced at parser level. Would live in a validation layer if needed. +- **Suggestion**: Stay out of parsing; add optional validators in configuration if/when a product needs it. + +### Built-in flags (`--version`, `--help`) global precedence +- **What**: Whether `--version` and `--help` take precedence over subcommands and other arguments. +- **Example**: `tool --version install pkg` or `tool install --version pkg` +- **git / cargo (clap) / npm**: `--version` anywhere prints version and exits. Subcommand is ignored. +- **gh (Cobra)**: `--version` only works alone on root command. `gh --version pr` errors. +- **docker / bun**: Subcommand runs; `--version` on root is ignored if subcommand follows. +- **Effect (current)**: Global precedence (git/npm style). `--version` prints version and exits regardless of subcommands or position. +- **Suggestion**: Keep; this is the most user-friendly behavior. Users expect `--version` to work anywhere. + +## Opinionated default +Effect should remain on the permissive, npm/commander-style side: flexible option placement, parent flags usable before or after subcommands, strict `--` handling, and single-shot subcommand selection on the first value. This keeps UX friendly for modern CLIs while remaining predictable via documented rules and tests. diff --git a/packages/effect/src/unstable/cli/index.ts b/packages/effect/src/unstable/cli/index.ts index a85fedeaa..f66324824 100644 --- a/packages/effect/src/unstable/cli/index.ts +++ b/packages/effect/src/unstable/cli/index.ts @@ -30,12 +30,7 @@ export * as HelpDoc from "./HelpDoc.ts" /** * @since 4.0.0 */ -export * as HelpFormatter from "./HelpFormatter.ts" - -/** - * @since 4.0.0 - */ -export * as Param from "./Param.ts" +export * as CliOutput from "./CliOutput.ts" /** * @since 4.0.0 diff --git a/packages/effect/src/unstable/cli/internal/builtInFlags.ts b/packages/effect/src/unstable/cli/internal/builtInFlags.ts index 49e49ac4f..0c9d3e161 100644 --- a/packages/effect/src/unstable/cli/internal/builtInFlags.ts +++ b/packages/effect/src/unstable/cli/internal/builtInFlags.ts @@ -62,6 +62,7 @@ export const versionFlag: Flag.Flag = Flag /** * Built-in --completions option to print shell completion scripts. + * Generates a dynamic completion shim that calls the CLI at runtime. * Accepts one of: bash | zsh | fish | sh (alias of bash). * * @since 4.0.0 @@ -73,21 +74,5 @@ export const completionsFlag: Flag.Flag> Flag.optional, // Map "sh" to "bash" while preserving Option-ness Flag.map((v) => Option.map(v, (s) => (s === "sh" ? "bash" : s))), - Flag.withDescription("Print static shell completion script for the given shell") - ) - -/** - * Built-in --dynamic-completions option to print dynamic shell completion scripts. - * Accepts one of: bash | zsh | fish | sh (alias of bash). - * - * @since 4.0.0 - * @internal - */ -export const dynamicCompletionsFlag: Flag.Flag> = Flag - .choice("dynamic-completions", ["bash", "zsh", "fish", "sh"] as const) - .pipe( - Flag.optional, - // Map "sh" to "bash" while preserving Option-ness - Flag.map((v) => Option.map(v, (s) => (s === "sh" ? "bash" : s))), - Flag.withDescription("Print dynamic shell completion script for the given shell") + Flag.withDescription("Print shell completion script for the given shell") ) diff --git a/packages/effect/src/unstable/cli/internal/command.ts b/packages/effect/src/unstable/cli/internal/command.ts new file mode 100644 index 000000000..c1a6d7eb3 --- /dev/null +++ b/packages/effect/src/unstable/cli/internal/command.ts @@ -0,0 +1,265 @@ +/** + * Command Implementation + * ====================== + * + * Internal implementation details for CLI commands. + * Public API is in ../Command.ts + */ +import * as Predicate from "../../../data/Predicate.ts" +import * as Effect from "../../../Effect.ts" +import { pipeArguments } from "../../../interfaces/Pipeable.ts" +import { YieldableProto } from "../../../internal/core.ts" +import * as ServiceMap from "../../../ServiceMap.ts" +import * as CliError from "../CliError.ts" +import type { ArgDoc, FlagDoc, HelpDoc, SubcommandDoc } from "../HelpDoc.ts" +import * as Param from "../Param.ts" +import * as Primitive from "../Primitive.ts" +import { type ConfigInternal, reconstructTree } from "./config.ts" + +/* ========================================================================== */ +/* Types */ +/* ========================================================================== */ + +import type { Command, CommandContext, Environment, ParsedTokens } from "../Command.ts" + +/** + * Internal implementation interface with all the machinery. + * Use toImpl() to access from internal code. + */ +export interface CommandInternal extends Command { + readonly config: ConfigInternal + readonly service: ServiceMap.Service, Input> + readonly parse: (input: ParsedTokens) => Effect.Effect + readonly handle: ( + input: Input, + commandPath: ReadonlyArray + ) => Effect.Effect + readonly buildHelpDoc: (commandPath: ReadonlyArray) => HelpDoc +} + +/* ========================================================================== */ +/* Type ID */ +/* ========================================================================== */ + +export const TypeId = "~effect/cli/Command" as const + +/* ========================================================================== */ +/* Casting */ +/* ========================================================================== */ + +/** + * Casts a Command to its internal implementation. + * For use by internal modules that need access to config, parse, handle, etc. + */ +export const toImpl = ( + self: Command +): CommandInternal => self as CommandInternal + +/* ========================================================================== */ +/* Proto */ +/* ========================================================================== */ + +export const Proto = { + ...YieldableProto, + pipe() { + return pipeArguments(this, arguments) + }, + asEffect(this: Command) { + return toImpl(this).service.asEffect() + } +} + +/* ========================================================================== */ +/* Constructor */ +/* ========================================================================== */ + +/** + * Internal command constructor. Only accepts already-parsed ConfigInternal. + */ +export const makeCommand = (options: { + readonly name: Name + readonly config: ConfigInternal + readonly service?: ServiceMap.Service, Input> | undefined + readonly description?: string | undefined + readonly subcommands?: ReadonlyArray> | undefined + readonly parse?: ((input: ParsedTokens) => Effect.Effect) | undefined + readonly handle?: + | ((input: Input, commandPath: ReadonlyArray) => Effect.Effect) + | undefined +}): Command => { + const service = options.service ?? ServiceMap.Service, Input>(`${TypeId}/${options.name}`) + const config = options.config + + const handle = ( + input: Input, + commandPath: ReadonlyArray + ): Effect.Effect => + Predicate.isNotUndefined(options.handle) + ? options.handle(input, commandPath) + : Effect.fail(new CliError.ShowHelp({ commandPath })) + + const parse = options.parse ?? Effect.fnUntraced(function*(input: ParsedTokens) { + const parsedArgs: Param.ParsedArgs = { flags: input.flags, arguments: input.arguments } + const values = yield* parseParams(parsedArgs, config.orderedParams) + return reconstructTree(config.tree, values) as Input + }) + + const buildHelpDoc = (commandPath: ReadonlyArray): HelpDoc => { + const args: Array = [] + const flags: Array = [] + + for (const arg of config.arguments) { + const singles = Param.extractSingleParams(arg) + const metadata = Param.getParamMetadata(arg) + for (const single of singles) { + args.push({ + name: single.name, + type: single.typeName ?? Primitive.getTypeName(single.primitiveType), + description: single.description, + required: !metadata.isOptional, + variadic: metadata.isVariadic + }) + } + } + + let usage = commandPath.length > 0 ? commandPath.join(" ") : options.name + const subcommands = options.subcommands ?? [] + if (subcommands.length > 0) { + usage += " " + } + usage += " [flags]" + for (const arg of args) { + const argName = arg.variadic ? `<${arg.name}...>` : `<${arg.name}>` + usage += ` ${arg.required ? argName : `[${argName}]`}` + } + + for (const option of config.flags) { + const singles = Param.extractSingleParams(option) + for (const single of singles) { + const formattedAliases = single.aliases.map((alias) => alias.length === 1 ? `-${alias}` : `--${alias}`) + flags.push({ + name: single.name, + aliases: formattedAliases, + type: single.typeName ?? Primitive.getTypeName(single.primitiveType), + description: single.description, + required: single.primitiveType._tag !== "Boolean" + }) + } + } + + const subcommandDocs: Array = subcommands.map((sub) => ({ + name: sub.name, + description: sub.description ?? "" + })) + + return { + description: options.description ?? "", + usage, + flags, + ...(args.length > 0 && { args }), + ...(subcommandDocs.length > 0 && { subcommands: subcommandDocs }) + } + } + + return Object.assign(Object.create(Proto), { + [TypeId]: TypeId, + name: options.name, + subcommands: options.subcommands ?? [], + config, + service, + parse, + handle, + buildHelpDoc, + ...(Predicate.isNotUndefined(options.description) + ? { description: options.description } + : {}) + }) +} + +/* ========================================================================== */ +/* Helpers */ +/* ========================================================================== */ + +/** + * Parses param values from parsed command arguments into their typed + * representations. + */ +const parseParams: (parsedArgs: Param.ParsedArgs, params: ReadonlyArray) => Effect.Effect< + ReadonlyArray, + CliError.CliError, + Environment +> = Effect.fnUntraced(function*(parsedArgs, params) { + const results: Array = [] + let currentArguments = parsedArgs.arguments + + for (const option of params) { + const [remainingArguments, parsed] = yield* option.parse({ + flags: parsedArgs.flags, + arguments: currentArguments + }) + results.push(parsed) + currentArguments = remainingArguments + } + + return results +}) + +/** + * Checks for duplicate flag names between parent and child commands. + */ +export const checkForDuplicateFlags = ( + parent: Command, + subcommands: ReadonlyArray> +): void => { + const parentImpl = toImpl(parent) + const parentOptionNames = new Set() + + const extractNames = (options: ReadonlyArray): void => { + for (const option of options) { + const singles = Param.extractSingleParams(option) + for (const single of singles) { + parentOptionNames.add(single.name) + } + } + } + + extractNames(parentImpl.config.flags) + + for (const subcommand of subcommands) { + const subImpl = toImpl(subcommand) + for (const option of subImpl.config.flags) { + const singles = Param.extractSingleParams(option) + for (const single of singles) { + if (parentOptionNames.has(single.name)) { + throw new CliError.DuplicateOption({ + option: single.name, + parentCommand: parent.name, + childCommand: subcommand.name + }) + } + } + } + } +} + +/** + * Helper function to get help documentation for a specific command path. + * Navigates through the command hierarchy to find the right command. + */ +export const getHelpForCommandPath = ( + command: Command, + commandPath: ReadonlyArray +): HelpDoc => { + let currentCommand: Command.Any = command + + // Navigate through the command path to find the target command + for (let i = 1; i < commandPath.length; i++) { + const subcommandName = commandPath[i] + const subcommand = currentCommand.subcommands.find((sub) => sub.name === subcommandName) + if (subcommand) { + currentCommand = subcommand + } + } + + return toImpl(currentCommand).buildHelpDoc(commandPath) +} diff --git a/packages/effect/src/unstable/cli/internal/completions/bash.ts b/packages/effect/src/unstable/cli/internal/completions/bash.ts deleted file mode 100644 index f301a6e7f..000000000 --- a/packages/effect/src/unstable/cli/internal/completions/bash.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { Command } from "../../Command.ts" -import { flattenCommand, getSingles } from "./shared.ts" -import { isDirType, isEitherPath, isFileType, optionRequiresValue } from "./types.ts" - -const optionTokens = (singles: ReadonlyArray): Array => { - const out: Array = [] - for (const s of singles) { - for (const a of s.aliases) { - out.push(a.length === 1 ? `-${a}` : `--${a}`) - } - out.push(`--${s.name}`) - } - return out -} - -/** @internal */ -export const generateBashCompletions = ( - rootCmd: Command, - executableName: string -): string => { - type AnyCommand = Command - - const rows = flattenCommand(rootCmd as AnyCommand) - const funcCases: Array = [] - const cmdCases: Array = [] - - for (const { cmd, trail } of rows) { - const singles = getSingles(cmd.config.flags) - const words = [ - ...optionTokens(singles), - ...cmd.subcommands.map((s) => s.name) - ] - const wordList = words.join(" ") - - const optionCases: Array = [] - for (const s of singles) { - if (!optionRequiresValue(s)) continue - const prevs = [ - ...s.aliases.map((a) => (a.length === 1 ? `-${a}` : `--${a}`)), - `--${s.name}` - ] - const comp = isDirType(s) - ? "$(compgen -d \"${cur}\")" - : (isFileType(s) || isEitherPath(s)) - ? "$(compgen -f \"${cur}\")" - : "\"${cur}\"" - for (const p of prevs) optionCases.push(`"${p}") COMPREPLY=( ${comp} ); return 0 ;;`) - } - - if (trail.length > 1) { - const funcName = `__${executableName}_${trail.join("_")}_opts` - funcCases.push( - ` ,${trail.join(" ")})`, - ` cmd="${funcName}"`, - " ;;" - ) - } - - const funcName = `__${executableName}_${trail.join("_")}_opts` - cmdCases.push( - `${funcName})`, - ` opts="${wordList}"`, - ` if [[ \${cur} == -* || \${COMP_CWORD} -eq ${trail.length} ]] ; then`, - ` COMPREPLY=( $(compgen -W "${wordList}" -- "\${cur}") )`, - " return 0", - " fi", - " case \\\"${prev}\\\" in" - ) - for (const l of optionCases) { - cmdCases.push(` ${l}`) - } - cmdCases.push( - " *)", - " COMPREPLY=()", - " ;;", - " esac", - ` COMPREPLY=( $(compgen -W "${wordList}" -- "\${cur}") )`, - " return 0", - " ;;" - ) - } - - const scriptName = `_${executableName}_bash_completions` - const lines = [ - `function ${scriptName}() {`, - " local i cur prev opts cmd", - " COMPREPLY=()", - " cur=\\\"${COMP_WORDS[COMP_CWORD]}\\\"", - " prev=\\\"${COMP_WORDS[COMP_CWORD-1]}\\\"", - " cmd=\\\"\\\"", - " opts=\\\"\\\"", - " for i in \"${COMP_WORDS[@]}\"; do", - " case \"${cmd},${i}\" in", - ` ,${executableName})`, - ` cmd="__${executableName}_${executableName}_opts"`, - " ;;", - ...funcCases, - " *)", - " ;;", - " esac", - " done", - " case \\\"${cmd}\\\" in", - ...cmdCases.map((l) => ` ${l}`), - " esac", - "}", - `complete -F ${scriptName} -o nosort -o bashdefault -o default ${executableName}` - ] - return lines.join("\n") -} diff --git a/packages/effect/src/unstable/cli/internal/completions/dynamic/handler.ts b/packages/effect/src/unstable/cli/internal/completions/dynamic/handler.ts index f580ebd62..435929c99 100644 --- a/packages/effect/src/unstable/cli/internal/completions/dynamic/handler.ts +++ b/packages/effect/src/unstable/cli/internal/completions/dynamic/handler.ts @@ -4,9 +4,10 @@ */ import type { Command } from "../../../Command.ts" +import { toImpl } from "../../command.ts" import { getSingles } from "../shared.ts" import { optionRequiresValue } from "../types.ts" -import type { SingleFlagMeta } from "../types.ts" +import type { FlagDescriptor } from "../types.ts" interface CompletionContext { readonly words: ReadonlyArray @@ -64,6 +65,15 @@ interface CompletionItem { readonly description?: string } +const lookupFlag = ( + token: string, + flags: ReadonlyArray +): FlagDescriptor | undefined => + flags.find((flag) => + token === `--${flag.name}` || + flag.aliases.some((a) => token === (a.length === 1 ? `-${a}` : `--${a}`)) + ) + const formatAlias = (alias: string): string => { if (alias.startsWith("-")) { return alias @@ -71,7 +81,7 @@ const formatAlias = (alias: string): string => { return alias.length === 1 ? `-${alias}` : `--${alias}` } -const getTypeLabel = (flag: SingleFlagMeta): string | undefined => { +const getTypeLabel = (flag: FlagDescriptor): string | undefined => { if (flag.typeName) { switch (flag.typeName) { case "directory": @@ -102,7 +112,7 @@ const getTypeLabel = (flag: SingleFlagMeta): string | undefined => { } } -const buildFlagDescription = (flag: SingleFlagMeta): string => { +const buildFlagDescription = (flag: FlagDescriptor): string => { const parts: Array = [] const aliasParts = flag.aliases .map(formatAlias) @@ -126,7 +136,7 @@ const buildFlagDescription = (flag: SingleFlagMeta): string => { const addFlagCandidates = ( addItem: (item: CompletionItem) => void, - flag: SingleFlagMeta, + flag: FlagDescriptor, query: string, includeAliases: boolean ) => { @@ -182,7 +192,7 @@ export const generateDynamicCompletions = ( } // Find the current command context by walking through the words - let currentCmd: Command = rootCmd as any + let currentCmd: Command.Any = rootCmd let wordIndex = 1 // Skip executable name // Walk through words to find the current command context @@ -200,24 +210,21 @@ export const generateDynamicCompletions = ( continue } - const singles = getSingles(currentCmd.config.flags) - const matchingOption = singles.find((s) => - optionToken === `--${s.name}` || - s.aliases.some((a) => optionToken === (a.length === 1 ? `-${a}` : `--${a}`)) - ) + const singles = getSingles(toImpl(currentCmd).config.flags) + const matchingFlag = lookupFlag(optionToken, singles) wordIndex++ // Move past the option if ( - matchingOption && optionRequiresValue(matchingOption) && !hasInlineValue && wordIndex < context.currentIndex + matchingFlag && optionRequiresValue(matchingFlag) && !hasInlineValue && wordIndex < context.currentIndex ) { wordIndex++ // Skip the option value } } else { // Check if it's a subcommand - const subCmd = currentCmd.subcommands.find((c: any) => c.name === word) + const subCmd = currentCmd.subcommands.find((c) => c.name === word) if (subCmd) { - currentCmd = subCmd as any + currentCmd = subCmd wordIndex++ } else { // Unknown word in command path - return empty completions @@ -230,17 +237,14 @@ export const generateDynamicCompletions = ( // Generate completions based on current context const currentWord = context.currentWord - const singles = getSingles(currentCmd.config.flags) + const singles = getSingles(toImpl(currentCmd).config.flags) const equalIndex = currentWord.indexOf("=") if (currentWord.startsWith("-") && equalIndex !== -1) { const optionToken = currentWord.slice(0, equalIndex) - const matchingOption = singles.find((s) => - optionToken === `--${s.name}` || - s.aliases.some((a) => optionToken === (a.length === 1 ? `-${a}` : `--${a}`)) - ) + const matchingFlag = lookupFlag(optionToken, singles) - if (matchingOption && optionRequiresValue(matchingOption)) { - const candidateKind = matchingOption.typeName ?? (matchingOption.primitiveTag === "Path" ? "path" : undefined) + if (matchingFlag && optionRequiresValue(matchingFlag)) { + const candidateKind = matchingFlag.typeName ?? (matchingFlag.primitiveTag === "Path" ? "path" : undefined) const fileKind = candidateKind === "directory" || candidateKind === "file" || candidateKind === "either" || candidateKind === "path" ? candidateKind @@ -267,13 +271,10 @@ export const generateDynamicCompletions = ( if (prevWord && prevWord.startsWith("-")) { const prevEqIndex = prevWord.indexOf("=") const prevToken = prevEqIndex === -1 ? prevWord : prevWord.slice(0, prevEqIndex) - const matchingOption = singles.find((s) => - prevToken === `--${s.name}` || - s.aliases.some((a) => prevToken === (a.length === 1 ? `-${a}` : `--${a}`)) - ) + const matchingFlag = lookupFlag(prevToken, singles) - if (matchingOption && optionRequiresValue(matchingOption)) { - const candidateKind = matchingOption.typeName ?? (matchingOption.primitiveTag === "Path" ? "path" : undefined) + if (matchingFlag && optionRequiresValue(matchingFlag)) { + const candidateKind = matchingFlag.typeName ?? (matchingFlag.primitiveTag === "Path" ? "path" : undefined) const fileKind = candidateKind === "directory" || candidateKind === "file" || candidateKind === "either" || candidateKind === "path" ? candidateKind diff --git a/packages/effect/src/unstable/cli/internal/completions/fish.ts b/packages/effect/src/unstable/cli/internal/completions/fish.ts deleted file mode 100644 index 6f0a41e6b..000000000 --- a/packages/effect/src/unstable/cli/internal/completions/fish.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { Command } from "../../Command.ts" -import { getSingles } from "./shared.ts" -import { isDirType, isEitherPath, isFileType, optionRequiresValue } from "./types.ts" - -/** @internal */ -export const generateFishCompletions = ( - rootCmd: Command, - executableName: string -): string => { - type AnyCommand = Command - const lines: Array = [] - - const dfs = (cmd: AnyCommand, parents: Array = []) => { - const trail = [...parents, cmd.name] - const singles = getSingles(cmd.config.flags) - - for (const sub of cmd.subcommands) { - const parts = [ - "complete", - `-c ${executableName}`, - ...(trail.length === 1 - ? ["-n \"__fish_use_subcommand\""] - : [`-n "__fish_seen_subcommand_from ${trail[trail.length - 1]}"`]), - "-f", - `-a "${sub.name}"` - ] - lines.push(parts.join(" ")) - } - - for (const s of singles) { - const tokens: Array = [] - if (s.name) tokens.push(`-l ${s.name}`) - for (const a of s.aliases) tokens.push(`-s ${a}`) - if (optionRequiresValue(s)) tokens.push("-r") - const parts = [ - "complete", - `-c ${executableName}`, - ...(trail.length === 1 - ? ["-n \"__fish_use_subcommand\""] - : [`-n "__fish_seen_subcommand_from ${trail[trail.length - 1]}"`]), - ...tokens - ] - if (optionRequiresValue(s)) { - if (isDirType(s)) parts.push("-f -a \"(__fish_complete_directories (commandline -ct))\"") - else if (isFileType(s) || isEitherPath(s)) parts.push("-f -a \"(__fish_complete_path (commandline -ct))\"") - } else { - parts.push("-f") - } - lines.push(parts.join(" ")) - } - - for (const sub of cmd.subcommands) dfs(sub as AnyCommand, trail) - } - - dfs(rootCmd as AnyCommand) - return lines.join("\n") -} diff --git a/packages/effect/src/unstable/cli/internal/completions/index.ts b/packages/effect/src/unstable/cli/internal/completions/index.ts index 243ec533d..5fe6023e4 100644 --- a/packages/effect/src/unstable/cli/internal/completions/index.ts +++ b/packages/effect/src/unstable/cli/internal/completions/index.ts @@ -1,58 +1,19 @@ -import type { Command } from "../../Command.ts" -import { generateBashCompletions } from "./bash.ts" -import { generateFishCompletions } from "./fish.ts" -import type { Shell } from "./types.ts" -import { generateZshCompletions } from "./zsh/index.ts" - -/** @internal */ -export const generateCompletions = ( - rootCmd: Command, - executableName: string, - shell: Shell -): string => { - /* - * TODO(completions) - * - Add a `completion` subcommand with `show|install|uninstall ` UX; keep `--completions` as hidden alias - * - Include descriptions for flags/subcommands (zsh `_arguments[...]`, fish `--description`) - * - Support positional argument completions (type hints, choices) - * - Auto-complete `choice` values and add dynamic `withCompletions(ctx)` hooks - * - Consider dynamic completion mode (`__complete`) that consults real parser - * - Unify parent/child flag visibility policy across shells - * - Add PowerShell support if needed - */ - switch (shell) { - case "bash": - return generateBashCompletions(rootCmd, executableName) - case "fish": - return generateFishCompletions(rootCmd, executableName) - case "zsh": - return generateZshCompletions(rootCmd, executableName) - } -} - -// Export the individual generators for testing/advanced usage -export { - /** @internal */ - generateBashCompletions -} from "./bash.ts" - -export { - /** @internal */ - generateFishCompletions -} from "./fish.ts" +/** + * Dynamic completion system. + * + * Generates lightweight shell shims that call the CLI binary at runtime + * to get completions. This approach is simpler to maintain and always + * stays in sync with the actual parser. + */ -export { - /** @internal */ - generateZshCompletions -} from "./zsh/index.ts" - -// Export dynamic completion functions export { /** @internal */ generateDynamicBashCompletion, /** @internal */ generateDynamicCompletion, /** @internal */ + generateDynamicFishCompletion, + /** @internal */ generateDynamicZshCompletion, /** @internal */ handleCompletionRequest, @@ -61,11 +22,4 @@ export { } from "./dynamic/index.ts" /** @internal */ -export type { - /** @internal */ - CommandRow, - /** @internal */ - Shell, - /** @internal */ - SingleFlagMeta -} from "./types.ts" +export type { FlagDescriptor, Shell } from "./types.ts" diff --git a/packages/effect/src/unstable/cli/internal/completions/shared.ts b/packages/effect/src/unstable/cli/internal/completions/shared.ts index fb937d5fa..5200ec202 100644 --- a/packages/effect/src/unstable/cli/internal/completions/shared.ts +++ b/packages/effect/src/unstable/cli/internal/completions/shared.ts @@ -1,9 +1,8 @@ -import type { Command } from "../../Command.ts" import * as Param from "../../Param.ts" -import type { CommandRow, SingleFlagMeta } from "./types.ts" +import type { FlagDescriptor } from "./types.ts" /** @internal */ -export const getSingles = (flags: ReadonlyArray): ReadonlyArray => +export const getSingles = (flags: ReadonlyArray): ReadonlyArray => flags .flatMap(Param.extractSingleParams) .filter((s) => s.kind === "flag") @@ -18,24 +17,3 @@ export const getSingles = (flags: ReadonlyArray): ReadonlyArray( - cmd: Command, - parents: Array = [] -): Array> => { - const here = [...parents, cmd.name] - const rows = [{ trail: here, cmd }] - for (const c of cmd.subcommands) { - const nested = flattenCommand(c as Command, here) - for (const row of nested) rows.push(row) - } - return rows -} - -/** @internal */ -export const idFromTrail = (trail: Array): string => trail.map((p) => p.replace(/-/g, "_")).join("_") - -/** @internal */ -export const handlerName = (trail: Array, executableName: string): string => - `_${executableName}_${idFromTrail(trail)}_handler` diff --git a/packages/effect/src/unstable/cli/internal/completions/types.ts b/packages/effect/src/unstable/cli/internal/completions/types.ts index 98c421ca1..7e60d4be8 100644 --- a/packages/effect/src/unstable/cli/internal/completions/types.ts +++ b/packages/effect/src/unstable/cli/internal/completions/types.ts @@ -1,10 +1,8 @@ -import type { Command } from "../../Command.ts" - /** @internal */ export type Shell = "bash" | "zsh" | "fish" /** @internal */ -export interface SingleFlagMeta { +export interface FlagDescriptor { readonly name: string readonly aliases: ReadonlyArray readonly primitiveTag: string @@ -13,25 +11,4 @@ export interface SingleFlagMeta { } /** @internal */ -export interface CommandRow< - Name extends string = string, - I = any, - E = any, - R = any -> { - readonly trail: Array - readonly cmd: Command -} - -/** @internal */ -export const isDirType = (s: SingleFlagMeta): boolean => s.typeName === "directory" - -/** @internal */ -export const isFileType = (s: SingleFlagMeta): boolean => s.typeName === "file" - -/** @internal */ -export const isEitherPath = (s: SingleFlagMeta): boolean => - s.typeName === "path" || s.typeName === "either" || s.primitiveTag === "Path" - -/** @internal */ -export const optionRequiresValue = (s: SingleFlagMeta): boolean => s.primitiveTag !== "Boolean" +export const optionRequiresValue = (s: FlagDescriptor): boolean => s.primitiveTag !== "Boolean" diff --git a/packages/effect/src/unstable/cli/internal/completions/zsh/handlers.ts b/packages/effect/src/unstable/cli/internal/completions/zsh/handlers.ts deleted file mode 100644 index 0aa313f2f..000000000 --- a/packages/effect/src/unstable/cli/internal/completions/zsh/handlers.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { getSingles, handlerName } from "../shared.ts" -import type { CommandRow } from "../types.ts" -import { buildArgumentSpec, buildSubcommandState, generateSubcommandCompletion } from "./utils.ts" - -/** @internal */ -export const generateHandlers = (rows: ReadonlyArray, executableName: string): Array => { - const handlers: Array = [] - - for (const { cmd, trail } of rows) { - const funcName = handlerName(trail, executableName) - const flagParams = (cmd.config.flags as ReadonlyArray).filter( - (f: any) => f.kind === "flag" - ) - const singles = getSingles(flagParams) - - const specs: Array = [] - for (const s of singles) { - const parts = buildArgumentSpec(s) - for (const part of parts) { - specs.push(part) - } - } - - if (cmd.subcommands.length > 0) { - specs.push(buildSubcommandState(trail)) - } - - handlers.push( - `function ${funcName}() {`, - " local ret=1", - " local context state line", - " typeset -A opt_args" - ) - - if (specs.length > 0) { - const args = specs.join(" ") - handlers.push(` _arguments -C -s -S ${args}`) - } else { - handlers.push(" _arguments -C -s -S") - } - - handlers.push( - " ret=$?", - " case $state in" - ) - - if (cmd.subcommands.length > 0) { - const lines = generateSubcommandCompletion(cmd.subcommands, trail) - for (const line of lines) { - handlers.push(line) - } - } - - handlers.push( - " esac", - " return ret", - "}", - "" - ) - } - - return handlers -} diff --git a/packages/effect/src/unstable/cli/internal/completions/zsh/index.ts b/packages/effect/src/unstable/cli/internal/completions/zsh/index.ts deleted file mode 100644 index 09155bd2d..000000000 --- a/packages/effect/src/unstable/cli/internal/completions/zsh/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Command } from "../../../Command.ts" -import { flattenCommand } from "../shared.ts" -import { generateHandlers } from "./handlers.ts" -import { generateRouter } from "./router.ts" - -/** @internal */ -export const generateZshCompletions = ( - rootCmd: Command, - executableName: string -): string => { - type AnyCommand = Command - - const rows = flattenCommand(rootCmd as AnyCommand) - const handlers = generateHandlers(rows, executableName) - const routerLines = generateRouter(rows, executableName, rootCmd) - - const scriptName = `_${executableName}_zsh_completions` - - const lines: Array = [ - `#compdef ${executableName}`, - "", - `function ${scriptName}() {`, - ` _${executableName}_zsh_route`, - "}", - "", - ...routerLines, - "", - ...handlers, - "", - `if [ "$funcstack[1]" = "${scriptName}" ]; then`, - ` ${scriptName} "$@"`, - "else", - ` compdef ${scriptName} ${executableName}`, - "fi" - ] - - return lines.join("\n") -} diff --git a/packages/effect/src/unstable/cli/internal/completions/zsh/router.ts b/packages/effect/src/unstable/cli/internal/completions/zsh/router.ts deleted file mode 100644 index a91e803c8..000000000 --- a/packages/effect/src/unstable/cli/internal/completions/zsh/router.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { getSingles, handlerName, idFromTrail } from "../shared.ts" -import type { CommandRow } from "../types.ts" -import { optionRequiresValue } from "../types.ts" - -/** @internal */ -export const generateRouter = ( - rows: ReadonlyArray, - executableName: string, - rootCmd: any -): Array => { - const routerName = `_${executableName}_zsh_route` - const rootCtx = idFromTrail([rootCmd.name]) - - // Precompute value-taking tokens and subcommands by context - const valueTakingTokensByCtx: Record> = {} - const subcommandsByCtx: Record> = {} - - for (const { cmd, trail } of rows) { - const ctx = idFromTrail(trail) - const singles = getSingles( - (cmd.config.flags as ReadonlyArray).filter((f: any) => f.kind === "flag") - ) - const tokens: Array = [] - for (const s of singles) { - if (!optionRequiresValue(s)) continue - tokens.push(`--${s.name}`) - for (const a of s.aliases) tokens.push(a.length === 1 ? `-${a}` : `--${a}`) - } - valueTakingTokensByCtx[ctx] = tokens - subcommandsByCtx[ctx] = cmd.subcommands.map((c: any) => c.name) - } - - // Build case blocks for value-taking options per context - const optionSkipCases: Array = [] - for (const [ctx, toks] of Object.entries(valueTakingTokensByCtx)) { - if (toks.length === 0) continue - optionSkipCases.push( - ` ${ctx})`, - ` case "$w" in`, - ` ${toks.map((t) => `"${t}"`).join("|")}) ((i++));;`, - " esac", - " ;;" - ) - } - - // Build dispatch cases for subcommand recognition - const subDispatchCases: Array = [] - for (const { cmd, trail } of rows) { - const ctx = idFromTrail(trail) - for (const sc of cmd.subcommands) { - const nextCtx = idFromTrail([...trail, sc.name]) - subDispatchCases.push( - ` "${ctx}:${sc.name}")`, - ` ctx="${nextCtx}"`, - ` handler="${handlerName([...trail, sc.name], executableName)}"`, - ` shift_index=$i`, - " ;;" - ) - } - } - - return [ - `function ${routerName}() {`, - " local -i i=2", - " local w", - ` local ctx="${rootCtx}"`, - ` local handler="${handlerName([rootCmd.name], executableName)}"`, - " local -i shift_index=0", - "", - " # Walk through words to find the deepest subcommand,", - " # skipping option values for the current context.", - " while (( i <= $#words )); do", - " w=${words[i]}", - " if [[ $w == -* ]]; then", - " case \"$ctx\" in", - ...optionSkipCases, - " esac", - " else", - " case \"$ctx:$w\" in", - ...subDispatchCases, - " esac", - " fi", - " (( i++ ))", - " done", - "", - " # If we matched a subcommand, drop everything up to it so the child", - " # handler sees only its own argv (avoids parent options confusing _arguments).", - " if (( shift_index > 0 )); then", - " (( CURRENT -= shift_index - 1 ))", // Fix: subtract 1 to keep the subcommand name - " if (( CURRENT < 1 )); then CURRENT=1; fi", - " shift $(( shift_index - 1 )) words", // Fix: subtract 1 to keep the subcommand name - " fi", - "", - " # Call the most specific handler for the current context", - " $handler", - "}" - ] -} diff --git a/packages/effect/src/unstable/cli/internal/completions/zsh/utils.ts b/packages/effect/src/unstable/cli/internal/completions/zsh/utils.ts deleted file mode 100644 index 27618f9cc..000000000 --- a/packages/effect/src/unstable/cli/internal/completions/zsh/utils.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { idFromTrail } from "../shared.ts" -import { isDirType, isEitherPath, isFileType, optionRequiresValue, type SingleFlagMeta } from "../types.ts" - -/** @internal */ -export const getValueCompleter = (s: SingleFlagMeta): string => { - if (isDirType(s)) return "_path_files -/" - if (isFileType(s) || isEitherPath(s)) return "_files" - return "_message 'value'" -} - -/** @internal */ -export const buildArgumentSpec = (s: SingleFlagMeta): Array => { - const specs: Array = [] - const desc = s.name - - if (optionRequiresValue(s)) { - const completer = getValueCompleter(s) - specs.push(`"--${s.name}=[${desc}]:${s.name.replace(/-/g, "_")}:${completer}"`) - for (const alias of s.aliases) { - const prefix = alias.length === 1 ? "-" : "--" - specs.push(`"${prefix}${alias}=[${desc}]:${s.name.replace(/-/g, "_")}:${completer}"`) - } - } else { - specs.push(`"--${s.name}[${desc}]"`) - for (const alias of s.aliases) { - const prefix = alias.length === 1 ? "-" : "--" - specs.push(`"${prefix}${alias}[${desc}]"`) - } - } - return specs -} - -/** @internal */ -export const buildSubcommandState = (trail: Array): string => `"*::subcommand:->sub_${idFromTrail(trail)}"` - -/** @internal */ -export const generateSubcommandCompletion = (subcommands: ReadonlyArray, trail: Array): Array => { - const items = subcommands.map((c) => ` '${c.name}:${c.name} command'`) - return [ - ` sub_${idFromTrail(trail)})`, - " local -a subcmds", - " subcmds=(", - ...items, - " )", - " _describe -t commands 'subcommand' subcmds && ret=0", - " ;;" - ] -} diff --git a/packages/effect/src/unstable/cli/internal/config.ts b/packages/effect/src/unstable/cli/internal/config.ts new file mode 100644 index 000000000..05ebb36a0 --- /dev/null +++ b/packages/effect/src/unstable/cli/internal/config.ts @@ -0,0 +1,220 @@ +/** + * Config Internal + * ================ + * + * The processed internal representation of a Command.Config declaration. + * Separates the user's declared config shape from the flat parsing representation. + * + * Key concepts: + * - ConfigInternal: The full processed form (flags, arguments, tree) + * - ConfigInternal.Tree: Maps declaration keys to nodes + * - ConfigInternal.Node: Param reference, Array, or Nested subtree + * + * Example transformation from Command.Config to Command.Config.Internal: + * + * ```ts + * // User declares: + * const config = { + * verbose: Flag.boolean("verbose"), + * server: { + * host: Flag.string("host"), + * port: Flag.integer("port") + * }, + * files: Argument.string("files").pipe(Argument.variadic) + * } + * + * // Becomes Config.Internal: + * { + * arguments: [filesParam], // Flat array of arguments + * flags: [verboseParam, hostParam, portParam], // Flat array of flags + * orderedParams: [verboseParam, hostParam, portParam, filesParam], + * tree: { // Preserves nested structure + * verbose: { _tag: "Param", index: 0 }, + * server: { + * _tag: "Nested", + * tree: { + * host: { _tag: "Param", index: 1 }, + * port: { _tag: "Param", index: 2 } + * } + * }, + * files: { _tag: "Param", index: 3 } + * } + * } + * ``` + * + * This separation allows: + * 1. Flat iteration over all params for parsing/validation + * 2. Reconstruction of original nested shape for handler input + */ +import * as Predicate from "../../../data/Predicate.ts" +import * as Param from "../Param.ts" + +/* ========================================================================== */ +/* Type ID */ +/* ========================================================================== */ + +const ConfigInternalTypeId = "~effect/cli/Command/Config/Internal" as const + +/* ========================================================================== */ +/* Types */ +/* ========================================================================== */ + +/** + * The processed internal representation of a Command.Config declaration. + * + * Created by parsing the user's config. Separates parameters by type + * while preserving the original nested structure via the tree. + */ +export interface ConfigInternal { + readonly [ConfigInternalTypeId]: typeof ConfigInternalTypeId + /** The command's positional argument parameters. */ + readonly arguments: ReadonlyArray + /** The command's flag parameters. */ + readonly flags: ReadonlyArray + /** All parameters in declaration order. */ + readonly orderedParams: ReadonlyArray + /** Tree structure for reconstructing nested config. */ + readonly tree: ConfigInternal.Tree +} + +/** + * @since 4.0.0 + */ +export declare namespace ConfigInternal { + /** + * Maps declaration keys to their node representations. + * Preserves the shape of the user's config object. + */ + export interface Tree { + [key: string]: Node + } + + /** + * A node in the config tree. + * + * - Param: References a parameter by index in orderedParams + * - Array: Contains child nodes for tuple/array declarations + * - Nested: Contains a subtree for nested config objects + */ + export type Node = + | { readonly _tag: "Param"; readonly index: number } + | { readonly _tag: "Array"; readonly children: ReadonlyArray } + | { readonly _tag: "Nested"; readonly tree: Tree } +} + +/* ========================================================================== */ +/* Guards */ +/* ========================================================================== */ + +/** @internal */ +export const isConfigInternal = (u: unknown): u is ConfigInternal => Predicate.hasProperty(u, ConfigInternalTypeId) + +/* ========================================================================== */ +/* Parsing */ +/* ========================================================================== */ + +/** + * Config interface matching Command.Config (duplicated to avoid circular import). + * @internal + */ +interface Config { + readonly [key: string]: + | Param.Param + | ReadonlyArray | Config> + | Config +} + +/** + * Parses a Command.Config into a ConfigInternal. + * + * Walks the config structure and: + * 1. Extracts all Params into flat arrays (flags, arguments, orderedParams) + * 2. Builds a tree that remembers the original nested structure + * 3. Assigns each Param an index to link parsed values back + * + * @internal + */ +export const parseConfig = (config: Config): ConfigInternal => { + const orderedParams: Array = [] + const flags: Array = [] + const args: Array = [] + + function parse(config: Config): ConfigInternal.Tree { + const tree: ConfigInternal.Tree = {} + for (const key in config) { + tree[key] = parseValue(config[key]) + } + return tree + } + + function parseValue( + value: Param.Any | ReadonlyArray | Config + ): ConfigInternal.Node { + if (Array.isArray(value)) { + return { + _tag: "Array", + children: (value as Array).map((v) => parseValue(v)) + } + } else if (Param.isParam(value)) { + const index = orderedParams.length + orderedParams.push(value) + + if (value.kind === "argument") { + args.push(value as Param.AnyArgument) + } else { + flags.push(value as Param.AnyFlag) + } + + return { _tag: "Param", index } + } else { + return { + _tag: "Nested", + tree: parse(value as Config) + } + } + } + + return { + [ConfigInternalTypeId]: ConfigInternalTypeId, + flags, + arguments: args, + orderedParams, + tree: parse(config) + } +} + +/* ========================================================================== */ +/* Reconstruction */ +/* ========================================================================== */ + +/** + * Reconstructs the original nested config shape from parsed values. + * + * Uses the tree as a blueprint to place each parsed value back into + * its original position in the nested structure. + * + * @internal + */ +export const reconstructTree = ( + tree: ConfigInternal.Tree, + results: ReadonlyArray +): Record => { + const output: Record = {} + + for (const key in tree) { + output[key] = nodeValue(tree[key]) + } + + return output + + function nodeValue(node: ConfigInternal.Node): any { + switch (node._tag) { + case "Param": + return results[node.index] + case "Array": + return node.children.map((child) => nodeValue(child)) + case "Nested": + return reconstructTree(node.tree, results) + } + } +} diff --git a/packages/effect/src/unstable/cli/internal/parser.ts b/packages/effect/src/unstable/cli/internal/parser.ts index 497737c23..b4867689a 100644 --- a/packages/effect/src/unstable/cli/internal/parser.ts +++ b/packages/effect/src/unstable/cli/internal/parser.ts @@ -1,29 +1,237 @@ +/** + * Parsing Pipeline for CLI Commands + * ================================== + * + * The parser transforms raw argv tokens into structured command input through + * three main phases: + * + * 1. **Lexer** (external): Converts argv strings into typed tokens + * - LongOption: --name or --name=value + * - ShortOption: -n or -n=value (also handles -abc as three flags) + * - Value: positional arguments + * + * 2. **Built-in Extraction**: Peels off built-in flags (help/version/completions) + * before command-specific parsing begins. + * + * 3. **Command Parsing**: Recursively processes command levels: + * - Collects flags defined at this level + * - Detects subcommand from first value token + * - Forwards remaining tokens to child command + * + * Key Behaviors + * ------------- + * - Parent flags may appear before OR after the subcommand name (npm-style) + * - Only the first Value token can open a subcommand + * - Errors accumulate rather than throwing exceptions + */ import * as Option from "../../../data/Option.ts" import * as Effect from "../../../Effect.ts" import type { LogLevel } from "../../../LogLevel.ts" import type { FileSystem } from "../../../platform/FileSystem.ts" import type { Path } from "../../../platform/Path.ts" import * as CliError from "../CliError.ts" -import type { Command, RawInput } from "../Command.ts" +import type { Command, ParsedTokens } from "../Command.ts" import * as Param from "../Param.ts" -import { isFalseValue, isTrueValue } from "../Primitive.ts" +import * as Primitive from "../Primitive.ts" import { suggest } from "./auto-suggest.ts" -import { completionsFlag, dynamicCompletionsFlag, helpFlag, logLevelFlag, versionFlag } from "./builtInFlags.ts" +import { completionsFlag, helpFlag, logLevelFlag, versionFlag } from "./builtInFlags.ts" +import { toImpl } from "./command.ts" import { type LexResult, type Token } from "./lexer.ts" +/* ========================================================================== */ +/* Public API */ +/* ========================================================================== */ + /** @internal */ -export const getCommandPath = (parsedInput: RawInput): ReadonlyArray => +export const getCommandPath = (parsedInput: ParsedTokens): ReadonlyArray => parsedInput.subcommand ? [parsedInput.subcommand.name, ...getCommandPath(parsedInput.subcommand.parsedInput)] : [] -type FlagParam = Param.Single -type FlagMap = Record> -type MutableFlagMap = Record> +/** @internal */ +export const extractBuiltInOptions = ( + tokens: ReadonlyArray +): Effect.Effect< + { + help: boolean + logLevel: LogLevel | undefined + version: boolean + completions: "bash" | "zsh" | "fish" | undefined + remainder: ReadonlyArray + }, + CliError.CliError, + FileSystem | Path +> => + Effect.gen(function*() { + const { flagMap, remainder } = consumeKnownFlags(tokens, builtInFlagRegistry) + const emptyArgs: Param.ParsedArgs = { flags: flagMap, arguments: [] } + const [, help] = yield* helpFlag.parse(emptyArgs) + const [, logLevel] = yield* logLevelFlag.parse(emptyArgs) + const [, version] = yield* versionFlag.parse(emptyArgs) + const [, completions] = yield* completionsFlag.parse(emptyArgs) + return { + help, + logLevel: Option.getOrUndefined(logLevel), + version, + completions: Option.getOrUndefined(completions), + remainder + } + }) +/** @internal */ +export const parseArgs = ( + lexResult: LexResult, + command: Command.Any, + commandPath: ReadonlyArray = [] +): Effect.Effect => + Effect.gen(function*() { + const { tokens, trailingOperands: afterEndOfOptions } = lexResult + const newCommandPath = [...commandPath, command.name] + + const commandImpl = toImpl(command) + const singles = commandImpl.config.flags.flatMap(Param.extractSingleParams) + const flagParams = singles.filter(Param.isFlagParam) + const flagRegistry = createFlagRegistry(flagParams) + + const context: CommandContext = { + command, + commandPath: newCommandPath, + flagRegistry + } + + const result = scanCommandLevel(tokens, context) + + if (result._tag === "Leaf") { + return { + flags: result.flags, + arguments: [...result.arguments, ...afterEndOfOptions], + ...(result.errors.length > 0 && { errors: result.errors }) + } + } + + const subLex: LexResult = { tokens: result.childTokens, trailingOperands: [] } + const subParsed = yield* parseArgs( + subLex, + result.sub, + newCommandPath + ) + + const allErrors = [...result.errors, ...(subParsed.errors ?? [])] + return { + flags: result.flags, + arguments: afterEndOfOptions, + subcommand: { name: result.sub.name, parsedInput: subParsed }, + ...(allErrors.length > 0 && { errors: allErrors }) + } + }) + +/* ========================================================================== */ +/* Types */ +/* ========================================================================== */ + +type FlagParam = Param.Single + +/** + * Mutable map of flag names to their collected string values. + * Used internally during parsing; converted to readonly at boundaries. + */ +type FlagMap = Record> + +/** + * Registry of known flags for a command level. + * Enables O(1) lookup of flag definitions by name or alias. + */ +type FlagRegistry = { + /** All flag params declared at this command level */ + readonly params: ReadonlyArray + /** Maps canonical names and aliases → flag param */ + readonly index: Map +} + +/** + * Mutable accumulator for collecting flag values during parsing. + * Provides methods to add values and merge results from sub-scans. + */ +type FlagAccumulator = { + /** Record a value for a flag. No-op if raw is undefined (for boolean flags). */ + readonly add: (name: string, raw: string | undefined) => void + /** Merge values from another flag map into this accumulator */ + readonly merge: (from: FlagMap) => void + /** Return immutable snapshot of accumulated values */ + readonly snapshot: () => Readonly +} + +/** + * Context for parsing a command level. + */ +type CommandContext = { + readonly command: Command.Any + readonly commandPath: ReadonlyArray + readonly flagRegistry: FlagRegistry +} + +/** + * Parsing mode during command-level scanning. + * Determines how value tokens are interpreted. + */ +type ParseMode = + | { readonly _tag: "AwaitingFirstValue" } + | { readonly _tag: "CollectingArguments" } + +/** + * Mutable state accumulated during command-level parsing. + */ +type ParseState = { + readonly flags: FlagAccumulator + readonly arguments: Array + readonly errors: Array + mode: ParseMode +} + +/** + * Result when first value token is processed. + */ +type FirstValueResult = + | { readonly _tag: "Subcommand"; readonly result: SubcommandResult } + | { readonly _tag: "Argument" } + +/** + * Terminal result: command level has no subcommand. + */ +type LeafResult = { + readonly _tag: "Leaf" + readonly flags: Readonly + readonly arguments: ReadonlyArray + readonly errors: ReadonlyArray +} + +/** + * Continuation result: subcommand detected, child parsing needed. + */ +type SubcommandResult = { + readonly _tag: "Sub" + readonly flags: Readonly + readonly sub: Command + readonly childTokens: ReadonlyArray + readonly errors: ReadonlyArray +} + +type LevelResult = LeafResult | SubcommandResult + +/* ========================================================================== */ +/* Token Cursor */ +/* ========================================================================== */ + +/** + * Stateful cursor for navigating a token stream. + * Provides peek/take semantics for single-pass parsing. + */ interface TokenCursor { + /** View next token without consuming */ readonly peek: () => Token | undefined + /** Consume and return next token */ readonly take: () => Token | undefined + /** Get all remaining tokens (does not consume) */ readonly rest: () => ReadonlyArray } @@ -36,118 +244,141 @@ const makeCursor = (tokens: ReadonlyArray): TokenCursor => { } } -/** Map canonicalized names/aliases → Single (O(1) lookup). */ -const buildFlagIndex = ( - singles: ReadonlyArray> -): Map> => { - const lookup = new Map>() - for (const single of singles) { - if (lookup.has(single.name)) throw new Error(`Duplicate option name: ${single.name}`) - lookup.set(single.name, single) - for (const alias of single.aliases) { - if (lookup.has(alias)) throw new Error(`Duplicate option/alias: ${alias}`) - lookup.set(alias, single) +/* ========================================================================== */ +/* Flag Registry */ +/* ========================================================================== */ + +/** + * Creates a registry for O(1) flag lookup by name or alias. + * @throws Error if duplicate names or aliases are detected (developer error) + */ +const createFlagRegistry = (params: ReadonlyArray): FlagRegistry => { + const index = new Map() + + for (const param of params) { + if (index.has(param.name)) { + throw new Error(`Duplicate flag name "${param.name}" in command definition`) + } + index.set(param.name, param) + + for (const alias of param.aliases) { + if (index.has(alias)) { + throw new Error( + `Duplicate flag/alias "${alias}" in command definition (conflicts with "${index.get(alias)!.name}")` + ) + } + index.set(alias, param) } } - return lookup -} -const isFlagToken = (t: Token): t is Extract => - t._tag === "LongOption" || t._tag === "ShortOption" + return { params, index } +} -const flagName = (t: Extract) => - t._tag === "LongOption" ? t.name : t.flag +const buildSubcommandIndex = ( + subcommands: ReadonlyArray> +): Map> => new Map(subcommands.map((sub) => [sub.name, sub])) -/** true/false/1/0/yes/no/on/off – if the next token is a boolean literal, return it. */ -const peekBooleanLiteral = (next: Token | undefined): string | undefined => - next?._tag === "Value" && (isTrueValue(next.value) || isFalseValue(next.value)) ? next.value : undefined +/* ========================================================================== */ +/* Flag Accumulator */ +/* ========================================================================== */ -const makeFlagMap = (params: ReadonlyArray): MutableFlagMap => - Object.fromEntries(params.map((p) => [p.name, [] as Array])) as MutableFlagMap +/** Creates an empty flag map with all known flag names initialized to []. */ +const createEmptyFlagMap = (params: ReadonlyArray): FlagMap => + Object.fromEntries(params.map((p) => [p.name, []])) -const appendFlagValue = (bag: MutableFlagMap, name: string, raw: string | undefined): void => { - if (raw !== undefined) bag[name].push(raw) -} +/** + * Creates a mutable accumulator for collecting flag values. + * Pre-initializes empty arrays for all known flags. + */ +const createFlagAccumulator = (params: ReadonlyArray): FlagAccumulator => { + const map = createEmptyFlagMap(params) -const mergeIntoFlagMap = (into: MutableFlagMap, from: FlagMap | MutableFlagMap): void => { - for (const k in from) { - const src = from[k] - if (src && src.length) { - for (let i = 0; i < src.length; i++) { - into[k].push(src[i]) + return { + add: (name, raw) => { + if (raw !== undefined) map[name].push(raw) + }, + merge: (from) => { + for (const key in from) { + const values = from[key] + if (values?.length) { + for (let i = 0; i < values.length; i++) { + map[key].push(values[i]) + } + } } - } + }, + snapshot: () => map } } -const toReadonlyFlagMap = (map: MutableFlagMap): FlagMap => map +/* ========================================================================== */ +/* Token Classification */ +/* ========================================================================== */ + +type FlagToken = Extract + +const isFlagToken = (t: Token): t is FlagToken => t._tag === "LongOption" || t._tag === "ShortOption" + +const getFlagName = (t: FlagToken): string => t._tag === "LongOption" ? t.name : t.flag /** - * Consume a recognized flag's value from the cursor: - * - Inline: --flag=value / -f=value - * - Boolean: implicit "true" or explicit next literal - * - Other: consume the next Value token if present + * Checks if a token is a boolean literal value. + * Recognizes: true/false, yes/no, on/off, 1/0 */ -const readFlagValue = ( +const asBooleanLiteral = (token: Token | undefined): string | undefined => + token?._tag === "Value" && (Primitive.isTrueValue(token.value) || Primitive.isFalseValue(token.value)) + ? token.value + : undefined + +/* ========================================================================== */ +/* Flag Value Consumption */ +/* ========================================================================== */ + +/** + * Reads a flag's value from the token stream. + * + * Value resolution order: + * 1. Inline value: --flag=value or -f=value + * 2. Boolean special case: implicit "true" or explicit boolean literal + * 3. Next token: consume following Value token if present + */ +const consumeFlagValue = ( cursor: TokenCursor, - tok: Extract, + token: FlagToken, spec: FlagParam ): string | undefined => { - if (tok.value !== undefined) return tok.value - if (spec.primitiveType._tag === "Boolean") { - const explicit = peekBooleanLiteral(cursor.peek()) - if (explicit !== undefined) cursor.take() // consume the literal - return explicit ?? "true" + // Inline value has highest priority + if (token.value !== undefined) { + return token.value + } + + // Boolean flags: check for explicit literal or default to "true" + if (Primitive.isBoolean(spec.primitiveType)) { + const literal = asBooleanLiteral(cursor.peek()) + if (literal !== undefined) cursor.take() + return literal ?? "true" } + + // Non-boolean: try to consume next Value token const next = cursor.peek() - if (next && next._tag === "Value") { + if (next?._tag === "Value") { cursor.take() return next.value } - return undefined -} -const unrecognizedFlagError = ( - token: Token, - singles: ReadonlyArray, - commandPath?: ReadonlyArray -): CliError.UnrecognizedOption | undefined => { - if (!isFlagToken(token)) return undefined - const printable = token._tag === "LongOption" ? `--${token.name}` : `-${token.flag}` - const valid: Array = [] - for (const s of singles) { - valid.push(s.name) - for (const alias of s.aliases) { - valid.push(alias) - } - } - const suggestions = suggest(flagName(token), valid).map((n) => (n.length === 1 ? `-${n}` : `--${n}`)) - return new CliError.UnrecognizedOption({ - option: printable, - suggestions, - ...(commandPath && { command: commandPath }) - }) + return undefined } -/* ====================================================================== */ -/* Built-ins peeling – uses the same primitives */ -/* ====================================================================== */ - -const builtInFlagParams: ReadonlyArray = [ - ...Param.extractSingleParams(logLevelFlag), - ...Param.extractSingleParams(helpFlag), - ...Param.extractSingleParams(versionFlag), - ...Param.extractSingleParams(completionsFlag), - ...Param.extractSingleParams(dynamicCompletionsFlag) -] - -/** Collect only the provided flags; leave everything else untouched as remainder. */ -const collectFlagValues = ( +/** + * Consumes known flags from a token stream. + * Unrecognized tokens are passed through to remainder. + * Used for both built-in extraction and npm-style parent flag collection. + */ +const consumeKnownFlags = ( tokens: ReadonlyArray, - flags: ReadonlyArray + registry: FlagRegistry ): { flagMap: FlagMap; remainder: ReadonlyArray } => { - const lookup = buildFlagIndex(flags) - const flagMap = makeFlagMap(flags) + const flagMap = createEmptyFlagMap(registry.params) const remainder: Array = [] const cursor = makeCursor(tokens) @@ -156,183 +387,228 @@ const collectFlagValues = ( remainder.push(t) continue } - const spec = lookup.get(flagName(t)) - if (!spec) { - // Not one of the target flags → don't consume a following value + + const param = registry.index.get(getFlagName(t)) + if (!param) { remainder.push(t) continue } - appendFlagValue(flagMap, spec.name, readFlagValue(cursor, t, spec)) + + const value = consumeFlagValue(cursor, t, param) + if (value !== undefined) { + flagMap[param.name].push(value) + } } - return { flagMap: toReadonlyFlagMap(flagMap), remainder } + return { flagMap, remainder } } -/** - * Extract built-in flags using the same machinery. - * - * @internal - */ -export const extractBuiltInOptions = ( - tokens: ReadonlyArray -): Effect.Effect< - { - help: boolean - logLevel: LogLevel | undefined - version: boolean - completions: "bash" | "zsh" | "fish" | undefined - dynamicCompletions: "bash" | "zsh" | "fish" | undefined - remainder: ReadonlyArray - }, - CliError.CliError, - FileSystem | Path -> => - Effect.gen(function*() { - const { flagMap, remainder } = collectFlagValues(tokens, builtInFlagParams) - const emptyArgs: Param.ParsedArgs = { flags: flagMap, arguments: [] } - const [, help] = yield* helpFlag.parse(emptyArgs) - const [, logLevel] = yield* logLevelFlag.parse(emptyArgs) - const [, version] = yield* versionFlag.parse(emptyArgs) - const [, completions] = yield* completionsFlag.parse(emptyArgs) - const [, dynamicCompletions] = yield* dynamicCompletionsFlag.parse(emptyArgs) - return { - help, - logLevel: Option.getOrUndefined(logLevel), - version, - completions: Option.getOrUndefined(completions), - dynamicCompletions: Option.getOrUndefined(dynamicCompletions), - remainder +/* ========================================================================== */ +/* Built-in Flags */ +/* ========================================================================== */ + +const builtInFlagParams: ReadonlyArray = [ + ...Param.extractSingleParams(logLevelFlag), + ...Param.extractSingleParams(helpFlag), + ...Param.extractSingleParams(versionFlag), + ...Param.extractSingleParams(completionsFlag) +] + +const builtInFlagRegistry = createFlagRegistry(builtInFlagParams) + +/* ========================================================================== */ +/* Error Creation */ +/* ========================================================================== */ + +const createUnrecognizedFlagError = ( + token: FlagToken, + params: ReadonlyArray, + commandPath: ReadonlyArray +): CliError.UnrecognizedOption => { + const printable = token._tag === "LongOption" ? `--${token.name}` : `-${token.flag}` + const validNames: Array = [] + + for (const p of params) { + validNames.push(p.name) + for (const alias of p.aliases) { + validNames.push(alias) } - }) + } -/* ====================================================================== */ -/* One-level scan */ -/* ====================================================================== */ + const suggestions = suggest(getFlagName(token), validNames) + .map((n) => (n.length === 1 ? `-${n}` : `--${n}`)) -type LevelLeaf = { - readonly type: "leaf" - readonly flags: FlagMap - readonly arguments: ReadonlyArray - readonly errors: ReadonlyArray + return new CliError.UnrecognizedOption({ + option: printable, + suggestions, + command: commandPath + }) } -type LevelSubcommand = { - readonly type: "sub" - readonly flags: FlagMap - readonly leadingArguments: ReadonlyArray - readonly sub: Command - readonly childTokens: ReadonlyArray - readonly errors: ReadonlyArray -} +/* ========================================================================== */ +/* Parse State */ +/* ========================================================================== */ -type LevelResult = LevelLeaf | LevelSubcommand +const createParseState = (registry: FlagRegistry): ParseState => ({ + flags: createFlagAccumulator(registry.params), + arguments: [], + errors: [], + mode: { _tag: "AwaitingFirstValue" } +}) -const isFlagParam = (s: Param.Single): s is Param.Single => - s.kind === "flag" +const toLeafResult = (state: ParseState): LeafResult => ({ + _tag: "Leaf", + flags: state.flags.snapshot(), + arguments: state.arguments, + errors: state.errors +}) -const scanCommandLevel = ( - tokens: ReadonlyArray, - command: Command, - flags: ReadonlyArray, - commandPath: ReadonlyArray -): LevelResult => { - const index = buildFlagIndex(flags) - const bag = makeFlagMap(flags) - const operands: Array = [] - const errors: Array = [] - let seenFirstValue = false - const expectsArgs = command.config.arguments.length > 0 +/* ========================================================================== */ +/* First Value Resolution */ +/* ========================================================================== */ - const cursor = makeCursor(tokens) +/** + * Determines how to handle the first value token. + * + * If it matches a known subcommand: + * - Collect any parent flags from remaining tokens (npm-style) + * - Return SubcommandResult with child tokens + * + * Otherwise: + * - Return Argument to treat it as a positional argument + * - Report error if command expects subcommand but got unknown value + */ +const resolveFirstValue = ( + value: string, + cursor: TokenCursor, + context: CommandContext, + state: ParseState +): FirstValueResult => { + const { command, commandPath, flagRegistry } = context + const subIndex = buildSubcommandIndex(command.subcommands) + const sub = subIndex.get(value) + + if (sub) { + // npm-style: parent flags can appear after subcommand name + const tail = consumeKnownFlags(cursor.rest(), flagRegistry) + state.flags.merge(tail.flagMap) - for (let t = cursor.take(); t; t = cursor.take()) { - if (isFlagToken(t)) { - const spec = index.get(flagName(t)) - if (!spec) { - const err = unrecognizedFlagError(t, flags, commandPath) - if (err) errors.push(err) - // Do not consume a following value; it may be a subcommand or operand - continue + return { + _tag: "Subcommand", + result: { + _tag: "Sub", + flags: state.flags.snapshot(), + sub, + childTokens: tail.remainder, + errors: state.errors } - appendFlagValue(bag, spec.name, readFlagValue(cursor, t, spec)) - continue } + } - // Value → only the FIRST value may be a subcommand boundary; others are arguments - if (t._tag === "Value") { - if (!seenFirstValue) { - seenFirstValue = true - const sub = command.subcommands.find((s) => s.name === t.value) - if (sub) { - // Allow parent flags to appear after the subcommand name (npm-style) - const tail = collectFlagValues(cursor.rest(), flags) - mergeIntoFlagMap(bag, tail.flagMap) - return { - type: "sub", - flags: toReadonlyFlagMap(bag), - leadingArguments: [], - sub, - childTokens: tail.remainder, - errors - } - } else { - if (!expectsArgs && command.subcommands.length > 0) { - const suggestions = suggest(t.value, command.subcommands.map((s) => s.name)) - errors.push(new CliError.UnknownSubcommand({ subcommand: t.value, parent: commandPath, suggestions })) - } - } - } - operands.push(t.value) - } + // Not a subcommand. Check if this looks like a typo. + const expectsArgs = toImpl(command).config.arguments.length > 0 + if (!expectsArgs && subIndex.size > 0) { + const suggestions = suggest(value, command.subcommands.map((s) => s.name)) + state.errors.push( + new CliError.UnknownSubcommand({ + subcommand: value, + parent: commandPath, + suggestions + }) + ) } - // Unknown subcommand validation handled inline on first value; remaining checks - // are deferred to argument parsing. + return { _tag: "Argument" } +} + +/* ========================================================================== */ +/* Token Processing */ +/* ========================================================================== */ + +/** + * Processes a flag token: looks up in registry, consumes value, records it. + * Reports unrecognized flags as errors. + */ +const processFlag = ( + token: FlagToken, + cursor: TokenCursor, + context: CommandContext, + state: ParseState +): void => { + const { commandPath, flagRegistry } = context + const name = getFlagName(token) + const spec = flagRegistry.index.get(name) + + if (!spec) { + state.errors.push(createUnrecognizedFlagError(token, flagRegistry.params, commandPath)) + return + } - return { type: "leaf", flags: toReadonlyFlagMap(bag), arguments: operands, errors } + state.flags.add(spec.name, consumeFlagValue(cursor, token, spec)) } -/* ====================================================================== */ -/* Public API */ -/* ====================================================================== */ +/** + * Processes a value token based on current parsing mode. + * + * In AwaitingFirstValue mode: + * - Check if value is a subcommand + * - If so, return SubcommandResult to exit scanning + * - If not, switch to CollectingArguments mode + * + * In CollectingArguments mode: + * - Simply add value to arguments list + */ +const processValue = ( + value: string, + cursor: TokenCursor, + context: CommandContext, + state: ParseState +): SubcommandResult | undefined => { + if (state.mode._tag === "AwaitingFirstValue") { + const result = resolveFirstValue(value, cursor, context, state) + + if (result._tag === "Subcommand") { + return result.result + } -/** @internal */ -export const parseArgs = ( - lexResult: LexResult, - command: Command, - commandPath: ReadonlyArray = [] -): Effect.Effect => - Effect.gen(function*() { - const { tokens, trailingOperands: afterEndOfOptions } = lexResult - const newCommandPath = [...commandPath, command.name] + state.mode = { _tag: "CollectingArguments" } + } - // Flags available at this level (ignore arguments) - const singles = command.config.flags.flatMap(Param.extractSingleParams) - const flags = singles.filter(isFlagParam) + state.arguments.push(value) + return undefined +} - const result = scanCommandLevel(tokens, command, flags, newCommandPath) +/* ========================================================================== */ +/* Command Level Scanning */ +/* ========================================================================== */ - if (result.type === "leaf") { - return { - flags: result.flags, - arguments: [...result.arguments, ...afterEndOfOptions], - ...(result.errors.length > 0 && { errors: result.errors }) - } - } +/** + * Scans a single command level, processing all tokens. + * + * For each token: + * - Flags: Look up, consume value, record in accumulator + * - Values: Check for subcommand (first value only), then collect as arguments + * + * Returns LeafResult if no subcommand detected, SubcommandResult otherwise. + */ +const scanCommandLevel = ( + tokens: ReadonlyArray, + context: CommandContext +): LevelResult => { + const cursor = makeCursor(tokens) + const state = createParseState(context.flagRegistry) - // Subcommand recursion - const subLex: LexResult = { tokens: result.childTokens, trailingOperands: [] } - const subParsed = yield* parseArgs( - subLex, - result.sub as unknown as Command, - newCommandPath - ) + for (let token = cursor.take(); token; token = cursor.take()) { + if (isFlagToken(token)) { + processFlag(token, cursor, context, state) + continue + } - const allErrors = [...result.errors, ...(subParsed.errors || [])] - return { - flags: result.flags, - arguments: [...result.leadingArguments, ...afterEndOfOptions], - subcommand: { name: result.sub.name, parsedInput: subParsed }, - ...(allErrors.length > 0 && { errors: allErrors }) + if (token._tag === "Value") { + const subResult = processValue(token.value, cursor, context, state) + if (subResult) return subResult } - }) + } + + return toLeafResult(state) +} diff --git a/packages/effect/test/unstable/cli/Arguments.test.ts b/packages/effect/test/unstable/cli/Arguments.test.ts index 6c6761283..47f8087be 100644 --- a/packages/effect/test/unstable/cli/Arguments.test.ts +++ b/packages/effect/test/unstable/cli/Arguments.test.ts @@ -1,8 +1,9 @@ import { assert, describe, expect, it } from "@effect/vitest" import { Effect, Layer, Ref } from "effect" +import { Option, Result } from "effect/data" import { FileSystem, Path, PlatformError } from "effect/platform" import { TestConsole } from "effect/testing" -import { Argument, Command, Flag, HelpFormatter } from "effect/unstable/cli" +import { Argument, CliOutput, Command, Flag } from "effect/unstable/cli" import * as MockTerminal from "./services/MockTerminal.ts" const ConsoleLayer = TestConsole.layer @@ -27,8 +28,8 @@ const FileSystemLayer = FileSystem.layerNoop({ }) const PathLayer = Path.layer const TerminalLayer = MockTerminal.layer -const HelpFormatterLayer = HelpFormatter.layer( - HelpFormatter.defaultHelpRenderer({ +const CliOutputLayer = CliOutput.layer( + CliOutput.defaultFormatter({ colors: false }) ) @@ -38,7 +39,7 @@ const TestLayer = Layer.mergeAll( FileSystemLayer, PathLayer, TerminalLayer, - HelpFormatterLayer + CliOutputLayer ) describe("Command arguments", () => { @@ -158,10 +159,10 @@ describe("Command arguments", () => { let result: { readonly files: ReadonlyArray } | undefined const testCommand = Command.make("test", { - files: Argument.string("files").pipe(Argument.repeated) - }, (config) => + files: Argument.variadic(Argument.string("files")) + }, (parsedConfig) => Effect.sync(() => { - result = config + result = parsedConfig })) yield* Command.runWith(testCommand, { version: "1.0.0" })([ @@ -173,4 +174,154 @@ describe("Command arguments", () => { assert.isDefined(result) assert.deepStrictEqual(result.files, ["file1.txt", "file2.txt", "file3.txt"]) }).pipe(Effect.provide(TestLayer))) + + it("should handle choiceWithValue", () => + Effect.gen(function*() { + const resultRef = yield* Ref.make(null) + + const testCommand = Command.make("test", { + level: Argument.choiceWithValue( + "level", + [ + ["debug", 0], + ["info", 1], + ["error", 2] + ] as const + ) + }, (config) => Ref.set(resultRef, config)) + + yield* Command.runWith(testCommand, { version: "1.0.0" })(["info"]) + const result = yield* Ref.get(resultRef) + assert.strictEqual(result.level, 1) + }).pipe(Effect.provide(TestLayer))) + + it("should handle filter combinator - valid", () => + Effect.gen(function*() { + const resultRef = yield* Ref.make(null) + + const testCommand = Command.make("test", { + port: Argument.integer("port").pipe( + Argument.filter( + (n) => n >= 1 && n <= 65535, + (n) => `Port ${n} out of range (1-65535)` + ) + ) + }, (config) => Ref.set(resultRef, config)) + + yield* Command.runWith(testCommand, { version: "1.0.0" })(["8080"]) + const result = yield* Ref.get(resultRef) + assert.strictEqual(result.port, 8080) + }).pipe(Effect.provide(TestLayer))) + + it("should handle filter combinator - invalid", () => + Effect.gen(function*() { + const testCommand = Command.make("test", { + port: Argument.integer("port").pipe( + Argument.filter( + (n) => n >= 1 && n <= 65535, + (n) => `Port ${n} out of range (1-65535)` + ) + ) + }, () => Effect.void) + + yield* Command.runWith(testCommand, { version: "1.0.0" })(["99999"]) + const stderr = yield* TestConsole.errorLines + assert.isTrue(stderr.some((line) => String(line).includes("out of range"))) + }).pipe(Effect.provide(TestLayer))) + + it("should handle filterMap combinator - valid", () => + Effect.gen(function*() { + const resultRef = yield* Ref.make(null) + + const testCommand = Command.make("test", { + positiveInt: Argument.integer("num").pipe( + Argument.filterMap( + (n) => n > 0 ? Option.some(n) : Option.none(), + (n) => `Expected positive integer, got ${n}` + ) + ) + }, (config) => Ref.set(resultRef, config)) + + yield* Command.runWith(testCommand, { version: "1.0.0" })(["42"]) + const result = yield* Ref.get(resultRef) + assert.strictEqual(result.positiveInt, 42) + }).pipe(Effect.provide(TestLayer))) + + it("should handle filterMap combinator - invalid", () => + Effect.gen(function*() { + const testCommand = Command.make("test", { + positiveInt: Argument.integer("num").pipe( + Argument.filterMap( + (n) => n > 0 ? Option.some(n) : Option.none(), + (n) => `Expected positive integer, got ${n}` + ) + ) + }, () => Effect.void) + + yield* Command.runWith(testCommand, { version: "1.0.0" })(["0"]) + const stderr = yield* TestConsole.errorLines + assert.isTrue(stderr.some((line) => String(line).includes("Expected positive integer"))) + }).pipe(Effect.provide(TestLayer))) + + it("should handle orElse combinator", () => + Effect.gen(function*() { + const resultRef = yield* Ref.make(null) + + // Try parsing as integer first, fallback to 0 + const testCommand = Command.make("test", { + value: Argument.integer("value").pipe( + Argument.orElse(() => Argument.string("value").pipe(Argument.map(() => -1))) + ) + }, (config) => Ref.set(resultRef, config)) + + // Valid integer + yield* Command.runWith(testCommand, { version: "1.0.0" })(["42"]) + let result = yield* Ref.get(resultRef) + assert.strictEqual(result.value, 42) + + // Invalid integer, falls back to string path + yield* Ref.set(resultRef, null) + yield* Command.runWith(testCommand, { version: "1.0.0" })(["abc"]) + result = yield* Ref.get(resultRef) + assert.strictEqual(result.value, -1) + }).pipe(Effect.provide(TestLayer))) + + it("should handle orElseResult combinator", () => + Effect.gen(function*() { + const resultRef = yield* Ref.make(null) + + const testCommand = Command.make("test", { + value: Argument.integer("value").pipe( + Argument.orElseResult(() => Argument.string("value")) + ) + }, (config) => Ref.set(resultRef, config)) + + // Valid integer - returns Success + yield* Command.runWith(testCommand, { version: "1.0.0" })(["42"]) + let result = yield* Ref.get(resultRef) + assert.isTrue(Result.isSuccess(result.value)) + assert.strictEqual(result.value.value, 42) + + // Invalid integer - returns Failure with string + yield* Ref.set(resultRef, null) + yield* Command.runWith(testCommand, { version: "1.0.0" })(["abc"]) + result = yield* Ref.get(resultRef) + assert.isTrue(Result.isFailure(result.value)) + assert.strictEqual(result.value.value, "abc") + }).pipe(Effect.provide(TestLayer))) + + it("should handle withMetavar combinator", () => + Effect.gen(function*() { + const testCommand = Command.make("test", { + file: Argument.string("file").pipe( + Argument.withMetavar("FILE_PATH") + ) + }, () => Effect.void) + + // Run with help flag to check pseudo name appears in help + yield* Command.runWith(testCommand, { version: "1.0.0" })(["--help"]) + const stdout = yield* TestConsole.logLines + const helpText = stdout.join("\n") + assert.isTrue(helpText.includes("FILE_PATH")) + }).pipe(Effect.provide(TestLayer))) }) diff --git a/packages/effect/test/unstable/cli/Command.test.ts b/packages/effect/test/unstable/cli/Command.test.ts index 76ae54799..af022c025 100644 --- a/packages/effect/test/unstable/cli/Command.test.ts +++ b/packages/effect/test/unstable/cli/Command.test.ts @@ -3,7 +3,7 @@ import { Effect, Layer } from "effect" import { Option } from "effect/data" import { FileSystem, Path } from "effect/platform" import { TestConsole } from "effect/testing" -import { Command, Flag, HelpFormatter } from "effect/unstable/cli" +import { Argument, CliOutput, Command, Flag } from "effect/unstable/cli" import * as Cli from "./fixtures/ComprehensiveCli.ts" import * as MockTerminal from "./services/MockTerminal.ts" import * as TestActions from "./services/TestActions.ts" @@ -13,8 +13,8 @@ const ConsoleLayer = TestConsole.layer const FileSystemLayer = FileSystem.layerNoop({}) const PathLayer = Path.layer const TerminalLayer = MockTerminal.layer -const HelpFormatterLayer = HelpFormatter.layer( - HelpFormatter.defaultHelpRenderer({ +const CliOutputLayer = CliOutput.layer( + CliOutput.defaultFormatter({ colors: false }) ) @@ -25,7 +25,7 @@ const TestLayer = Layer.mergeAll( FileSystemLayer, PathLayer, TerminalLayer, - HelpFormatterLayer + CliOutputLayer ) describe("Command", () => { @@ -107,7 +107,7 @@ describe("Command", () => { const captured: Array> = [] const command = Command.make("env", { - env: Flag.keyValueMap("env") + env: Flag.keyValuePair("env") }, (config) => Effect.sync(() => { captured.push(config.env) @@ -132,7 +132,7 @@ describe("Command", () => { const captured: Array> = [] const command = Command.make("env", { - env: Flag.keyValueMap("env"), + env: Flag.keyValuePair("env"), verbose: Flag.boolean("verbose"), profile: Flag.string("profile") }, (config) => @@ -168,7 +168,7 @@ describe("Command", () => { let invoked = false const command = Command.make("env", { - env: Flag.keyValueMap("env") + env: Flag.keyValuePair("env") }, () => Effect.sync(() => { invoked = true @@ -341,8 +341,8 @@ describe("Command", () => { // Create subcommand that accesses parent context const child = Command.make("child", { action: Flag.string("action") }, (config) => Effect.gen(function*() { - // Access parent config via the auto-generated tag - const parentConfig = yield* parent.service + // Access parent config by yielding the parent command + const parentConfig = yield* parent messages.push(`child: parent.verbose=${parentConfig.verbose}`) messages.push(`child: parent.config=${parentConfig.config}`) messages.push(`child: action=${config.action}`) @@ -350,7 +350,7 @@ describe("Command", () => { // Combine parent and child const combined = parent.pipe( - Command.withSubcommands(child) + Command.withSubcommands([child]) ) const runCommand = Command.runWith(combined, { @@ -386,11 +386,11 @@ describe("Command", () => { pkg: Flag.string("pkg") }, (config) => Effect.gen(function*() { - const parentConfig = yield* root.service + const parentConfig = yield* root messages.push(`install: global=${parentConfig.global}, pkg=${config.pkg}`) })) - const npm = root.pipe(Command.withSubcommands(install)) + const npm = root.pipe(Command.withSubcommands([install])) const runNpm = Command.runWith(npm, { version: "1.0.0" }) @@ -423,7 +423,7 @@ describe("Command", () => { messages.push(`install: global=${parentConfig.global}, pkg=${config.pkg}`) })) - const npm = root.pipe(Command.withSubcommands(install)) + const npm = root.pipe(Command.withSubcommands([install])) const runNpm = Command.runWith(npm, { version: "1.0.0" }) @@ -452,7 +452,7 @@ describe("Command", () => { name: Flag.string("name") }, (config) => Effect.gen(function*() { - const rootConfig = yield* root.service + const rootConfig = yield* root messages.push(`service: root.env=${rootConfig.env}`) messages.push(`service: name=${config.name}`) })) @@ -462,8 +462,8 @@ describe("Command", () => { targetVersion: Flag.string("target-version") }, (config) => Effect.gen(function*() { - const rootConfig = yield* root.service - const serviceConfig = yield* service.service + const rootConfig = yield* root + const serviceConfig = yield* service messages.push(`deploy: root.env=${rootConfig.env}`) messages.push(`deploy: service.name=${serviceConfig.name}`) messages.push(`deploy: target-version=${config.targetVersion}`) @@ -471,11 +471,11 @@ describe("Command", () => { // Build the nested command structure const serviceWithDeploy = service.pipe( - Command.withSubcommands(deploy) + Command.withSubcommands([deploy]) ) const appWithService = root.pipe( - Command.withSubcommands(serviceWithDeploy) + Command.withSubcommands([serviceWithDeploy]) ) const runCommand = Command.runWith(appWithService, { version: "1.0.0" }) @@ -515,14 +515,14 @@ describe("Command", () => { targetVersion: Flag.string("target-version") }, (config) => Effect.gen(function*() { - const parentConfig = yield* parent.service + const parentConfig = yield* parent messages.push(`deploy: parent.verbose=${parentConfig.verbose}`) messages.push(`deploy: target-version=${config.targetVersion}`) })) // Combine commands const combined = parent.pipe( - Command.withSubcommands(deploy) + Command.withSubcommands([deploy]) ) const runCommand = Command.runWith(combined, { version: "1.0.0" }) @@ -541,6 +541,143 @@ describe("Command", () => { ]) }).pipe(Effect.provide(TestLayer))) + it.effect("should treat tokens after -- as operands (no subcommand or flags)", () => + Effect.gen(function*() { + const captured: Array> = [] + let childInvoked = false + + const root = Command.make("tool", { + rest: Argument.string("rest").pipe(Argument.variadic()) + }, (config) => + Effect.sync(() => { + captured.push(config.rest) + })) + + const child = Command.make("child", { + value: Flag.string("value") + }, () => + Effect.sync(() => { + childInvoked = true + })) + + const cli = root.pipe(Command.withSubcommands([child])) + const runCli = Command.runWith(cli, { version: "1.0.0" }) + + yield* runCli(["--", "child", "--value", "x"]) + + assert.isFalse(childInvoked) + assert.deepStrictEqual(captured, [["child", "--value", "x"]]) + }).pipe(Effect.provide(TestLayer))) + + it.effect("should coerce boolean flags to false when given falsey literals", () => + Effect.gen(function*() { + const captured: Array = [] + + const cmd = Command.make("tool", { + verbose: Flag.boolean("verbose") + }, (config) => Effect.sync(() => captured.push(config.verbose))) + + const runCmd = Command.runWith(cmd, { version: "1.0.0" }) + + yield* runCmd(["--verbose", "false"]) + yield* runCmd(["--verbose", "0"]) + + assert.deepStrictEqual(captured, [false, false]) + }).pipe(Effect.provide(TestLayer))) + + it.effect("should fail when a required flag value is missing", () => + Effect.gen(function*() { + let invoked = false + + const cmd = Command.make("tool", { + pkg: Flag.string("pkg") + }, () => + Effect.sync(() => { + invoked = true + })) + + const runCmd = Command.runWith(cmd, { version: "1.0.0" }) + + yield* runCmd(["--pkg"]) + + assert.isFalse(invoked) + const stderr = yield* TestConsole.errorLines + assert.isAbove(stderr.length, 0) + assert.isTrue(stderr.join("\n").includes("--pkg")) + }).pipe(Effect.provide(TestLayer))) + + it.effect("should parse combined short flags including one that expects a value", () => + Effect.gen(function*() { + const captured: Array<{ all: boolean; verbose: boolean; pkg: string }> = [] + + const cmd = Command.make("tool", { + all: Flag.boolean("all").pipe(Flag.withAlias("a")), + verbose: Flag.boolean("verbose").pipe(Flag.withAlias("v")), + pkg: Flag.string("pkg").pipe(Flag.withAlias("p")) + }, (config) => + Effect.sync(() => { + captured.push(config) + })) + + const runCmd = Command.runWith(cmd, { version: "1.0.0" }) + + yield* runCmd(["-avp", "cowsay"]) + + assert.deepStrictEqual(captured, [{ all: true, verbose: true, pkg: "cowsay" }]) + }).pipe(Effect.provide(TestLayer))) + + it.effect("should honor -- while still applying parent flags", () => + Effect.gen(function*() { + const captured: Array<{ global: boolean; rest: ReadonlyArray }> = [] + + const root = Command.make("tool", { + global: Flag.boolean("global"), + rest: Argument.string("rest").pipe(Argument.variadic()) + }, (config) => Effect.sync(() => captured.push({ global: config.global, rest: config.rest }))) + + const child = Command.make("child", { + value: Flag.string("value") + }) + + const cli = root.pipe(Command.withSubcommands([child])) + const runCli = Command.runWith(cli, { version: "1.0.0" }) + + yield* runCli(["--global", "--", "child", "--value", "x"]) + + assert.deepStrictEqual(captured, [{ global: true, rest: ["child", "--value", "x"] }]) + }).pipe(Effect.provide(TestLayer))) + + it.effect("should report unknown flag even when subcommand is unknown", () => + Effect.gen(function*() { + const root = Command.make("root", {}) + const known = Command.make("known", {}) + const cli = root.pipe(Command.withSubcommands([known])) + const runCli = Command.runWith(cli, { version: "1.0.0" }) + + yield* runCli(["--unknown", "bogus"]) + + const stderr = yield* TestConsole.errorLines + const text = stderr.join("\n") + assert.isTrue(text.includes("Unrecognized flag: --unknown")) + // Parser may also surface the unknown subcommand; ensure at least one error is emitted. + }).pipe(Effect.provide(TestLayer))) + + it.effect("should keep variadic argument order when options are interleaved", () => + Effect.gen(function*() { + const captured: Array<{ files: ReadonlyArray; verbose: boolean }> = [] + + const cmd = Command.make("copy", { + verbose: Flag.boolean("verbose"), + files: Argument.string("file").pipe(Argument.variadic()) + }, (config) => Effect.sync(() => captured.push({ files: config.files, verbose: config.verbose }))) + + const runCmd = Command.runWith(cmd, { version: "1.0.0" }) + + yield* runCmd(["--verbose", "a.txt", "b.txt", "--verbose", "c.txt"]) + + assert.deepStrictEqual(captured, [{ files: ["a.txt", "b.txt", "c.txt"], verbose: true }]) + }).pipe(Effect.provide(TestLayer))) + it.effect("should support options before, after, or between operands (relaxed POSIX Syntax Guideline No. 9)", () => Effect.gen(function*() { // Test both orderings work: POSIX (options before operands) and modern (mixed) @@ -653,5 +790,29 @@ describe("Command", () => { -q" `) }).pipe(Effect.provide(TestLayer))) + + it.effect("should print version and exit even with subcommands (global precedence)", () => + Effect.gen(function*() { + // --version should work on a command with subcommands + yield* Cli.run(["--version"]) + + const output = yield* TestConsole.logLines + const outputText = output.join("\n") + expect(outputText).toContain("1.0.0") + }).pipe(Effect.provide(TestLayer))) + + it.effect("should print version and exit when --version appears before subcommand", () => + Effect.gen(function*() { + // --version should take precedence over subcommand + yield* Cli.run(["--version", "copy", "src.txt", "dest.txt"]) + + const output = yield* TestConsole.logLines + const outputText = output.join("\n") + expect(outputText).toContain("1.0.0") + + // Subcommand should NOT have run + const actions = yield* TestActions.getActions + assert.strictEqual(actions.length, 0) + }).pipe(Effect.provide(TestLayer))) }) }) diff --git a/packages/effect/test/unstable/cli/Completions.test.ts b/packages/effect/test/unstable/cli/Completions.test.ts deleted file mode 100644 index b0231f1db..000000000 --- a/packages/effect/test/unstable/cli/Completions.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Command, Flag } from "effect/unstable/cli" -import { - generateBashCompletions, - generateFishCompletions, - generateZshCompletions -} from "effect/unstable/cli/internal/completions" -import { describe, expect, it } from "vitest" - -const buildSampleCli = () => { - const build = Command.make("build", { - "out-dir": Flag.directory("out-dir").pipe(Flag.withDescription("Output directory")), - target: Flag.string("target").pipe(Flag.withDescription("Target name")) - }) - - const deploy = Command.make("deploy", { - env: Flag.choice("env", ["staging", "prod"]).pipe(Flag.withDescription("Environment")), - file: Flag.file("file").pipe(Flag.withDescription("Artifact file")) - }) - - const root = Command - .make("forge", { - verbose: Flag.boolean("verbose").pipe(Flag.withAlias("v"), Flag.withDescription("Verbose output")), - "log-level": Flag.choice("log-level", ["debug", "info", "warn", "error"]).pipe( - Flag.withDescription("Set log level") - ) - }) - .pipe(Command.withSubcommands(build, deploy)) - - return root -} - -describe("completions", () => { - const cli = buildSampleCli() - const exe = "forge" - - it("bash", () => { - const script = generateBashCompletions(cli, exe) - expect(script).toMatchSnapshot() - }) - - it("fish", () => { - const script = generateFishCompletions(cli, exe) - expect(script).toMatchSnapshot() - }) - - it("zsh", () => { - const script = generateZshCompletions(cli, exe) - expect(script).toMatchSnapshot() - }) -}) diff --git a/packages/effect/test/unstable/cli/Errors.test.ts b/packages/effect/test/unstable/cli/Errors.test.ts index 54de908a5..bf93f7d13 100644 --- a/packages/effect/test/unstable/cli/Errors.test.ts +++ b/packages/effect/test/unstable/cli/Errors.test.ts @@ -1,7 +1,8 @@ import { assert, describe, it } from "@effect/vitest" import { Effect, Layer } from "effect" import { FileSystem, Path } from "effect/platform" -import { CliError, Command, Flag } from "effect/unstable/cli" +import { CliError, CliOutput, Command, Flag } from "effect/unstable/cli" +import { toImpl } from "effect/unstable/cli/internal/command" import * as Lexer from "effect/unstable/cli/internal/lexer" import * as Parser from "effect/unstable/cli/internal/parser" import * as MockTerminal from "./services/MockTerminal.ts" @@ -25,7 +26,7 @@ describe("Command errors", () => { }) const parsedInput = yield* Parser.parseArgs(Lexer.lex([]), command) - const error = yield* Effect.flip(command.parse(parsedInput)) + const error = yield* Effect.flip(toImpl(command).parse(parsedInput)) assert.instanceOf(error, CliError.MissingOption) assert.strictEqual(error.option, "value") }).pipe(Effect.provide(TestLayer))) @@ -40,7 +41,7 @@ describe("Command errors", () => { }) try { - parent.pipe(Command.withSubcommands(child)) + parent.pipe(Command.withSubcommands([child])) assert.fail("expected DuplicateOption to be thrown") } catch (error) { assert.instanceOf(error, CliError.DuplicateOption) @@ -50,5 +51,75 @@ describe("Command errors", () => { assert.strictEqual(duplicate.childCommand, "child") } }) + + it.effect("accumulates multiple UnrecognizedOption errors", () => + Effect.gen(function*() { + const command = Command.make("test", { + verbose: Flag.boolean("verbose") + }) + + const parsedInput = yield* Parser.parseArgs( + Lexer.lex(["--unknown1", "--unknown2"]), + command + ) + + assert.isDefined(parsedInput.errors) + assert.strictEqual(parsedInput.errors!.length, 2) + assert.instanceOf(parsedInput.errors![0], CliError.UnrecognizedOption) + assert.instanceOf(parsedInput.errors![1], CliError.UnrecognizedOption) + }).pipe(Effect.provide(TestLayer))) + + it.effect("accumulates UnknownSubcommand error", () => + Effect.gen(function*() { + const sub = Command.make("deploy") + const command = Command.make("app").pipe( + Command.withSubcommands([sub]) + ) + + const parsedInput = yield* Parser.parseArgs( + Lexer.lex(["deplyo"]), + command + ) + + assert.isDefined(parsedInput.errors) + assert.strictEqual(parsedInput.errors!.length, 1) + assert.instanceOf(parsedInput.errors![0], CliError.UnknownSubcommand) + + const error = parsedInput.errors![0] as CliError.UnknownSubcommand + assert.strictEqual(error.subcommand, "deplyo") + assert.isTrue(error.suggestions.includes("deploy")) + }).pipe(Effect.provide(TestLayer))) + }) + + describe("formatErrors", () => { + it("formats single error with ERROR header", () => { + const formatter = CliOutput.defaultFormatter({ colors: false }) + const error = new CliError.MissingOption({ option: "value" }) + + const output = formatter.formatErrors([error]) + + assert.isTrue(output.includes("ERROR")) + assert.isTrue(output.includes("Missing required flag")) + }) + + it("formats multiple errors with ERRORS header", () => { + const formatter = CliOutput.defaultFormatter({ colors: false }) + const errors = [ + new CliError.UnrecognizedOption({ option: "--foo", suggestions: [] }), + new CliError.UnrecognizedOption({ option: "--bar", suggestions: [] }) + ] + + const output = formatter.formatErrors(errors) + + assert.isTrue(output.includes("ERRORS")) + assert.isTrue(output.includes("--foo")) + assert.isTrue(output.includes("--bar")) + }) + + it("returns empty string for empty array", () => { + const formatter = CliOutput.defaultFormatter({ colors: false }) + const output = formatter.formatErrors([]) + assert.strictEqual(output, "") + }) }) }) diff --git a/packages/effect/test/unstable/cli/Help.test.ts b/packages/effect/test/unstable/cli/Help.test.ts index bd197dcb1..aacede0db 100644 --- a/packages/effect/test/unstable/cli/Help.test.ts +++ b/packages/effect/test/unstable/cli/Help.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest" import { Effect, Layer } from "effect" import { FileSystem, Path } from "effect/platform" import { TestConsole } from "effect/testing" -import { HelpFormatter } from "effect/unstable/cli" +import { CliOutput } from "effect/unstable/cli" import * as Cli from "./fixtures/ComprehensiveCli.ts" import * as MockTerminal from "./services/MockTerminal.ts" import * as TestActions from "./services/TestActions.ts" @@ -12,8 +12,8 @@ const ConsoleLayer = TestConsole.layer const FileSystemLayer = FileSystem.layerNoop({}) const PathLayer = Path.layer const TerminalLayer = MockTerminal.layer -const HelpFormatterLayer = HelpFormatter.layer( - HelpFormatter.defaultHelpRenderer({ +const CliOutputLayer = CliOutput.layer( + CliOutput.defaultFormatter({ colors: false }) ) @@ -24,7 +24,7 @@ const TestLayer = Layer.mergeAll( FileSystemLayer, PathLayer, TerminalLayer, - HelpFormatterLayer + CliOutputLayer ) const runCommand = Effect.fnUntraced( diff --git a/packages/effect/test/unstable/cli/LogLevel.test.ts b/packages/effect/test/unstable/cli/LogLevel.test.ts index 45807f297..4d3df1612 100644 --- a/packages/effect/test/unstable/cli/LogLevel.test.ts +++ b/packages/effect/test/unstable/cli/LogLevel.test.ts @@ -1,7 +1,7 @@ import { assert, describe, it } from "@effect/vitest" import { Effect, Layer, Logger } from "effect" import { FileSystem, Path } from "effect/platform" -import { Command, Flag, HelpFormatter } from "effect/unstable/cli" +import { CliOutput, Command, Flag } from "effect/unstable/cli" import * as MockTerminal from "./services/MockTerminal.ts" // Create a test logger that captures log messages @@ -31,8 +31,8 @@ const makeTestLogger = () => { const FileSystemLayer = FileSystem.layerNoop({}) const PathLayer = Path.layer const TerminalLayer = MockTerminal.layer -const HelpFormatterLayer = HelpFormatter.layer( - HelpFormatter.defaultHelpRenderer({ +const CliOutputLayer = CliOutput.layer( + CliOutput.defaultFormatter({ colors: false }) ) @@ -42,7 +42,7 @@ const makeTestLayer = (testLogger: Logger.Logger) => FileSystemLayer, PathLayer, TerminalLayer, - HelpFormatterLayer, + CliOutputLayer, Logger.layer([testLogger]) ) @@ -144,7 +144,7 @@ describe("LogLevel", () => { yield* Effect.logError("error from child") })) - const combined = parentCommand.pipe(Command.withSubcommands(childCommand)) + const combined = parentCommand.pipe(Command.withSubcommands([childCommand])) const runCommand = Command.runWith(combined, { version: "1.0.0" }) yield* runCommand(["--log-level", "info", "child"]).pipe(Effect.provide(TestLayer)) diff --git a/packages/effect/test/unstable/cli/__snapshots__/Completions.test.ts.snap b/packages/effect/test/unstable/cli/__snapshots__/Completions.test.ts.snap deleted file mode 100644 index 73050a590..000000000 --- a/packages/effect/test/unstable/cli/__snapshots__/Completions.test.ts.snap +++ /dev/null @@ -1,202 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`completions > bash 1`] = ` -"function _forge_bash_completions() { - local i cur prev opts cmd - COMPREPLY=() - cur=\\"\${COMP_WORDS[COMP_CWORD]}\\" - prev=\\"\${COMP_WORDS[COMP_CWORD-1]}\\" - cmd=\\"\\" - opts=\\"\\" - for i in "\${COMP_WORDS[@]}"; do - case "\${cmd},\${i}" in - ,forge) - cmd="__forge_forge_opts" - ;; - ,forge build) - cmd="__forge_forge_build_opts" - ;; - ,forge deploy) - cmd="__forge_forge_deploy_opts" - ;; - *) - ;; - esac - done - case \\"\${cmd}\\" in - __forge_forge_opts) - opts="-v --verbose --log-level build deploy" - if [[ \${cur} == -* || \${COMP_CWORD} -eq 1 ]] ; then - COMPREPLY=( $(compgen -W "-v --verbose --log-level build deploy" -- "\${cur}") ) - return 0 - fi - case \\"\${prev}\\" in - "--log-level") COMPREPLY=( "\${cur}" ); return 0 ;; - *) - COMPREPLY=() - ;; - esac - COMPREPLY=( $(compgen -W "-v --verbose --log-level build deploy" -- "\${cur}") ) - return 0 - ;; - __forge_forge_build_opts) - opts="--out-dir --target" - if [[ \${cur} == -* || \${COMP_CWORD} -eq 2 ]] ; then - COMPREPLY=( $(compgen -W "--out-dir --target" -- "\${cur}") ) - return 0 - fi - case \\"\${prev}\\" in - "--out-dir") COMPREPLY=( $(compgen -d "\${cur}") ); return 0 ;; - "--target") COMPREPLY=( "\${cur}" ); return 0 ;; - *) - COMPREPLY=() - ;; - esac - COMPREPLY=( $(compgen -W "--out-dir --target" -- "\${cur}") ) - return 0 - ;; - __forge_forge_deploy_opts) - opts="--env --file" - if [[ \${cur} == -* || \${COMP_CWORD} -eq 2 ]] ; then - COMPREPLY=( $(compgen -W "--env --file" -- "\${cur}") ) - return 0 - fi - case \\"\${prev}\\" in - "--env") COMPREPLY=( "\${cur}" ); return 0 ;; - "--file") COMPREPLY=( $(compgen -f "\${cur}") ); return 0 ;; - *) - COMPREPLY=() - ;; - esac - COMPREPLY=( $(compgen -W "--env --file" -- "\${cur}") ) - return 0 - ;; - esac -} -complete -F _forge_bash_completions -o nosort -o bashdefault -o default forge" -`; - -exports[`completions > fish 1`] = ` -"complete -c forge -n "__fish_use_subcommand" -f -a "build" -complete -c forge -n "__fish_use_subcommand" -f -a "deploy" -complete -c forge -n "__fish_use_subcommand" -l verbose -s v -f -complete -c forge -n "__fish_use_subcommand" -l log-level -r -complete -c forge -n "__fish_seen_subcommand_from build" -l out-dir -r -f -a "(__fish_complete_directories (commandline -ct))" -complete -c forge -n "__fish_seen_subcommand_from build" -l target -r -complete -c forge -n "__fish_seen_subcommand_from deploy" -l env -r -complete -c forge -n "__fish_seen_subcommand_from deploy" -l file -r -f -a "(__fish_complete_path (commandline -ct))"" -`; - -exports[`completions > zsh 1`] = ` -"#compdef forge - -function _forge_zsh_completions() { - _forge_zsh_route -} - -function _forge_zsh_route() { - local -i i=2 - local w - local ctx="forge" - local handler="_forge_forge_handler" - local -i shift_index=0 - - # Walk through words to find the deepest subcommand, - # skipping option values for the current context. - while (( i <= $#words )); do - w=\${words[i]} - if [[ $w == -* ]]; then - case "$ctx" in - forge) - case "$w" in - "--log-level") ((i++));; - esac - ;; - forge_build) - case "$w" in - "--out-dir"|"--target") ((i++));; - esac - ;; - forge_deploy) - case "$w" in - "--env"|"--file") ((i++));; - esac - ;; - esac - else - case "$ctx:$w" in - "forge:build") - ctx="forge_build" - handler="_forge_forge_build_handler" - shift_index=$i - ;; - "forge:deploy") - ctx="forge_deploy" - handler="_forge_forge_deploy_handler" - shift_index=$i - ;; - esac - fi - (( i++ )) - done - - # If we matched a subcommand, drop everything up to it so the child - # handler sees only its own argv (avoids parent options confusing _arguments). - if (( shift_index > 0 )); then - (( CURRENT -= shift_index - 1 )) - if (( CURRENT < 1 )); then CURRENT=1; fi - shift $(( shift_index - 1 )) words - fi - - # Call the most specific handler for the current context - $handler -} - -function _forge_forge_handler() { - local ret=1 - local context state line - typeset -A opt_args - _arguments -C -s -S "--verbose[verbose]" "-v[verbose]" "--log-level=[log-level]:log_level:_message 'value'" "*::subcommand:->sub_forge" - ret=$? - case $state in - sub_forge) - local -a subcmds - subcmds=( - 'build:build command' - 'deploy:deploy command' - ) - _describe -t commands 'subcommand' subcmds && ret=0 - ;; - esac - return ret -} - -function _forge_forge_build_handler() { - local ret=1 - local context state line - typeset -A opt_args - _arguments -C -s -S "--out-dir=[out-dir]:out_dir:_path_files -/" "--target=[target]:target:_message 'value'" - ret=$? - case $state in - esac - return ret -} - -function _forge_forge_deploy_handler() { - local ret=1 - local context state line - typeset -A opt_args - _arguments -C -s -S "--env=[env]:env:_message 'value'" "--file=[file]:file:_files" - ret=$? - case $state in - esac - return ret -} - - -if [ "$funcstack[1]" = "_forge_zsh_completions" ]; then - _forge_zsh_completions "$@" -else - compdef _forge_zsh_completions forge -fi" -`; diff --git a/packages/effect/test/unstable/cli/completions/dynamic.test.ts b/packages/effect/test/unstable/cli/completions/dynamic.test.ts index 48bb7a10d..85fd189b8 100644 --- a/packages/effect/test/unstable/cli/completions/dynamic.test.ts +++ b/packages/effect/test/unstable/cli/completions/dynamic.test.ts @@ -113,21 +113,21 @@ describe("Dynamic Completion Handler", () => { dryRun: Flag.boolean("dry-run") }).pipe( Command.withDescription("Deploy the application"), - Command.withSubcommands( + Command.withSubcommands([ Command.make("staging", { force: Flag.boolean("force") }, () => Effect.void), Command.make("production", { confirm: Flag.boolean("confirm") }, () => Effect.void) - ) + ]) ) return Command.make("myapp", { verbose: Flag.boolean("verbose").pipe(Flag.withAlias("v")), config: Flag.file("config").pipe(Flag.withAlias("c")) }).pipe( - Command.withSubcommands(build, deploy) + Command.withSubcommands([build, deploy]) ) } @@ -470,10 +470,10 @@ describe("Dynamic Completion Handler", () => { it.effect("should output completions to console", () => Effect.gen(function*() { const cmd = Command.make("test", {}).pipe( - Command.withSubcommands( + Command.withSubcommands([ Command.make("sub1", {}, () => Effect.void), Command.make("sub2", {}, () => Effect.void) - ) + ]) ) const originalEnv = { ...process.env } diff --git a/packages/effect/test/unstable/cli/fixtures/ComprehensiveCli.ts b/packages/effect/test/unstable/cli/fixtures/ComprehensiveCli.ts index 20880ba48..a5fabb855 100644 --- a/packages/effect/test/unstable/cli/fixtures/ComprehensiveCli.ts +++ b/packages/effect/test/unstable/cli/fixtures/ComprehensiveCli.ts @@ -60,7 +60,7 @@ const usersCreate = Command.make("create", { const users = Command.make("users").pipe( Command.withDescription("User management commands"), - Command.withSubcommands(usersList, usersCreate) + Command.withSubcommands([usersList, usersCreate]) ) const configSet = Command.make("set", { @@ -110,7 +110,7 @@ const config = Command.make("config", { ) }).pipe( Command.withDescription("Manage application configuration"), - Command.withSubcommands(configSet, configGet) + Command.withSubcommands([configSet, configGet]) ) const admin = Command.make("admin", { @@ -120,7 +120,7 @@ const admin = Command.make("admin", { ) }).pipe( Command.withDescription("Administrative commands"), - Command.withSubcommands(users, config) + Command.withSubcommands([users, config]) ) // File operations commands @@ -281,7 +281,7 @@ const git = Command.make("git", { verbose: config.verbose })).pipe( Command.withDescription("Git version control"), - Command.withSubcommands(gitClone, gitAdd, gitStatus) + Command.withSubcommands([gitClone, gitAdd, gitStatus]) ) // Commands for testing error handling @@ -352,7 +352,7 @@ const app = Command.make("app", { env: config.env })).pipe( Command.withDescription("Application management"), - Command.withSubcommands(deployCommand) + Command.withSubcommands([deployCommand]) ) // Service command for nested context sharing tests @@ -365,7 +365,7 @@ const serviceCommand = Command.make("service", { name: config.name })).pipe( Command.withDescription("Service management"), - Command.withSubcommands(deployCommand) + Command.withSubcommands([deployCommand]) ) const appWithService = Command.make("app-nested", { @@ -377,7 +377,7 @@ const appWithService = Command.make("app-nested", { env: config.env })).pipe( Command.withDescription("Application with nested services"), - Command.withSubcommands(serviceCommand) + Command.withSubcommands([serviceCommand]) ) // Main command with global options @@ -403,7 +403,7 @@ export const ComprehensiveCli = Command.make("mycli", { quiet: config.quiet })).pipe( Command.withDescription("A comprehensive CLI tool demonstrating all features"), - Command.withSubcommands(admin, copy, move, remove, build, git, testRequired, testFailing, app, appWithService) + Command.withSubcommands([admin, copy, move, remove, build, git, testRequired, testFailing, app, appWithService]) ) export const run = Command.runWith(ComprehensiveCli, {