From 3aedaba2537b47d046d183fdabfdba1dd0fc769e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 1 Jul 2026 12:04:03 +0200 Subject: [PATCH 1/4] fix(cli): suggest subcommand placement for misplaced flags --- apps/cli/src/shared/cli/run.ts | 24 +- .../shared/cli/subcommand-flag-suggestions.ts | 211 ++++++++++++++++++ .../subcommand-flag-suggestions.unit.test.ts | 71 ++++++ apps/cli/src/shared/output/json-formatter.ts | 9 +- apps/cli/src/shared/output/text-formatter.ts | 28 ++- 5 files changed, 335 insertions(+), 8 deletions(-) create mode 100644 apps/cli/src/shared/cli/subcommand-flag-suggestions.ts create mode 100644 apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index be9eb45706..0575b93a50 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -78,10 +78,24 @@ export function shouldUseGlobalSignalInterrupt(args: ReadonlyArray): boo ); } -function formatterLayerFor(format: OutputFormat) { +function formatterLayerFor( + rootCommand: Command.Command.Any, + args: ReadonlyArray, + format: OutputFormat, +) { + const context = { rootCommand, args }; return format === "json" || format === "stream-json" - ? CliOutput.layer(jsonCliOutputFormatter()) - : CliOutput.layer(textCliOutputFormatter()); + ? CliOutput.layer(jsonCliOutputFormatter(context)) + : CliOutput.layer(textCliOutputFormatter(context)); +} + +function isErrorRecord(error: unknown): error is Record { + return typeof error === "object" && error !== null; +} + +function isShowHelpCause(cause: Cause.Cause): boolean { + const error = Cause.findErrorOption(cause); + return error._tag === "Some" && isErrorRecord(error.value) && error.value["_tag"] === "ShowHelp"; } function projectContextLayerFor(runtimeLayer: Layer.Layer) { @@ -141,7 +155,7 @@ function cliProgramFor( ), ); return Command.runWith(rootCommand, { version: CLI_VERSION })(args).pipe( - Effect.provide(formatterLayerFor(outputFormat)), + Effect.provide(formatterLayerFor(rootCommand, args, outputFormat)), Effect.provide(options.analyticsLayer), Effect.provide(tracingLayer), Effect.provide(telemetryRuntimeLayer), @@ -211,7 +225,7 @@ export async function runCli(rootCommand: Command.Command.Any, options: RunCliOp const exit = yield* program.pipe(Effect.exit); if (Exit.isFailure(exit)) { const interrupted = Cause.hasInterruptsOnly(exit.cause); - if (!interrupted) { + if (!interrupted && !isShowHelpCause(exit.cause)) { yield* output.fail(normalizeCause(exit.cause)); } return yield* processControl.exit(interrupted ? 130 : 1); diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts new file mode 100644 index 0000000000..386a5ce573 --- /dev/null +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts @@ -0,0 +1,211 @@ +import type { CliError, Command, HelpDoc } from "effect/unstable/cli"; + +export interface CliErrorSuggestionContext { + readonly rootCommand: Command.Command.Any; + readonly args: ReadonlyArray; +} + +export interface FormattedCliError { + readonly _tag: string; + readonly message: string; +} + +interface CommandWithHelpDoc extends Command.Command.Any { + readonly buildHelpDoc: (path: ReadonlyArray) => HelpDoc.HelpDoc; +} + +interface MatchingCommand { + readonly command: Command.Command.Any; + readonly commandPath: ReadonlyArray; + readonly flag: HelpDoc.FlagDoc; +} + +function hasHelpDoc(command: Command.Command.Any): command is CommandWithHelpDoc { + return "buildHelpDoc" in command && typeof command.buildHelpDoc === "function"; +} + +function helpDocFor( + command: Command.Command.Any, + commandPath: ReadonlyArray, +): HelpDoc.HelpDoc | undefined { + return hasHelpDoc(command) ? command.buildHelpDoc(commandPath) : undefined; +} + +function findCommand( + root: Command.Command.Any, + pathWithoutRoot: ReadonlyArray, +): Command.Command.Any | undefined { + let current = root; + for (const segment of pathWithoutRoot) { + let next: Command.Command.Any | undefined; + for (const group of current.subcommands) { + next = group.commands.find( + (command) => command.name === segment || command.alias === segment, + ); + if (next) break; + } + if (!next) return undefined; + current = next; + } + return current; +} + +function collectDescendants( + command: Command.Command.Any, + commandPath: ReadonlyArray, +): ReadonlyArray { + const matches: Array = []; + const visit = (current: Command.Command.Any, path: ReadonlyArray) => { + for (const group of current.subcommands) { + for (const child of group.commands) { + const childPath = [...path, child.name]; + const helpDoc = helpDocFor(child, childPath); + if (helpDoc) { + for (const flag of helpDoc.flags) { + matches.push({ command: child, commandPath: childPath, flag }); + } + } + visit(child, childPath); + } + } + }; + visit(command, commandPath); + return matches; +} + +function normalizeOption(option: string): string { + const withoutValue = option.split("=", 1)[0] ?? option; + if (withoutValue.startsWith("--")) return withoutValue.slice(2); + if (withoutValue.startsWith("-")) return withoutValue.slice(1); + return withoutValue; +} + +function flagMatchesOption(flag: HelpDoc.FlagDoc, option: string): boolean { + const optionName = normalizeOption(option); + if (flag.name === optionName) return true; + if (flag.type === "boolean" && optionName === `no-${flag.name}`) return true; + return flag.aliases.includes(option); +} + +function findPathEndIndex( + args: ReadonlyArray, + pathWithoutRoot: ReadonlyArray, +): number | undefined { + if (pathWithoutRoot.length === 0) return 0; + for (let start = 0; start <= args.length - pathWithoutRoot.length; start++) { + let matches = true; + for (let offset = 0; offset < pathWithoutRoot.length; offset++) { + if (args[start + offset] !== pathWithoutRoot[offset]) { + matches = false; + break; + } + } + if (matches) return start + pathWithoutRoot.length; + } + return undefined; +} + +function inferAttemptedCommand( + args: ReadonlyArray, + currentPath: ReadonlyArray, + matches: ReadonlyArray, +): MatchingCommand | undefined { + const pathEnd = findPathEndIndex(args, currentPath.slice(1)); + const searchArgs = pathEnd === undefined ? args : args.slice(pathEnd); + for (const arg of searchArgs) { + if (arg.startsWith("-")) continue; + const match = matches.find((candidate) => { + const leaf = candidate.commandPath[candidate.commandPath.length - 1]; + return leaf === arg || candidate.command.alias === arg; + }); + if (match) return match; + } + return matches.length === 1 ? matches[0] : undefined; +} + +function formatCommandList(matches: ReadonlyArray): string { + const commands = [...new Set(matches.map((match) => `\`${match.commandPath.join(" ")}\``))]; + if (commands.length === 1) return commands[0] ?? ""; + if (commands.length === 2) return `${commands[0]} and ${commands[1]}`; + return `${commands.slice(0, -1).join(", ")}, and ${commands[commands.length - 1]}`; +} + +function formatFlagUsage(option: string, flag: HelpDoc.FlagDoc): string { + return flag.type === "boolean" ? option : `${option} `; +} + +function findValueAfterOption(args: ReadonlyArray, option: string): string | undefined { + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (!arg) continue; + if (arg === option) { + const next = args[index + 1]; + return next && !next.startsWith("-") ? next : undefined; + } + if (arg.startsWith(`${option}=`)) return undefined; + } + return undefined; +} + +function buildSubcommandFlagHint( + error: CliError.UnrecognizedOption, + context: CliErrorSuggestionContext, +): { readonly hint: string; readonly consumedValue?: string } | undefined { + if (!error.command || error.command.length === 0) return undefined; + + const current = findCommand(context.rootCommand, error.command.slice(1)); + if (!current || current.subcommands.length === 0) return undefined; + + const matches = collectDescendants(current, error.command).filter((match) => + flagMatchesOption(match.flag, error.option), + ); + if (matches.length === 0) return undefined; + + const attempted = inferAttemptedCommand(context.args, error.command, matches); + const availableOn = + matches.length === 1 + ? `a flag for ${formatCommandList(matches)}` + : `available on ${formatCommandList(matches)}`; + const example = attempted + ? `, for example:\n ${attempted.commandPath.join(" ")} ${formatFlagUsage(error.option, attempted.flag)}` + : "."; + const consumedValue = + attempted && attempted.flag.type !== "boolean" + ? findValueAfterOption(context.args, error.option) + : undefined; + + return { + hint: `${error.option} is ${availableOn}. Pass it after the subcommand${example}`, + ...(consumedValue ? { consumedValue } : {}), + }; +} + +export function formatCliErrorsForDisplay( + errors: ReadonlyArray, + context?: CliErrorSuggestionContext, +): ReadonlyArray { + const suppressedUnknownSubcommands = new Set(); + const formatted: Array = []; + + for (const error of errors) { + if (error._tag === "UnrecognizedOption" && context) { + const hint = buildSubcommandFlagHint(error, context); + if (hint) { + if (hint.consumedValue) suppressedUnknownSubcommands.add(hint.consumedValue); + formatted.push({ + _tag: error._tag, + message: `${error.message}\n\n Hint: ${hint.hint}`, + }); + continue; + } + } + + if (error._tag === "UnknownSubcommand" && suppressedUnknownSubcommands.has(error.subcommand)) { + continue; + } + + formatted.push({ _tag: error._tag, message: error.message }); + } + + return formatted; +} diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts new file mode 100644 index 0000000000..4be183fa9d --- /dev/null +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts @@ -0,0 +1,71 @@ +import { CliError, Command } from "effect/unstable/cli"; +import { describe, expect, it } from "vitest"; +import { legacyNetworkRestrictionsCommand } from "../../legacy/commands/network-restrictions/network-restrictions.command.ts"; +import { formatCliErrorsForDisplay } from "./subcommand-flag-suggestions.ts"; + +const testRoot = Command.make("supabase").pipe( + Command.withSubcommands([legacyNetworkRestrictionsCommand]), +); + +describe("subcommand flag placement suggestions", () => { + it("suggests moving a subcommand flag after the attempted subcommand", () => { + const errors = formatCliErrorsForDisplay( + [ + new CliError.UnrecognizedOption({ + option: "--project-ref", + command: ["supabase", "network-restrictions"], + suggestions: [], + }), + new CliError.UnknownSubcommand({ + subcommand: "jacraenyzrorgjhsdvvf", + parent: ["supabase", "network-restrictions"], + suggestions: [], + }), + ], + { + rootCommand: testRoot, + args: [ + "network-restrictions", + "--project-ref", + "jacraenyzrorgjhsdvvf", + "get", + "--experimental", + ], + }, + ); + + expect(errors).toHaveLength(1); + expect(errors[0]?.message).toContain( + "Unrecognized flag: --project-ref in command supabase network-restrictions", + ); + expect(errors[0]?.message).toContain( + "Hint: --project-ref is available on `supabase network-restrictions get` and `supabase network-restrictions update`.", + ); + expect(errors[0]?.message).toContain("supabase network-restrictions get --project-ref "); + expect(errors[0]?.message).not.toContain("Unknown subcommand"); + }); + + it("leaves unrelated unrecognized flags unchanged", () => { + const errors = formatCliErrorsForDisplay( + [ + new CliError.UnrecognizedOption({ + option: "--definitely-not-a-child-flag", + command: ["supabase", "network-restrictions"], + suggestions: [], + }), + ], + { + rootCommand: testRoot, + args: ["network-restrictions", "--definitely-not-a-child-flag", "get"], + }, + ); + + expect(errors).toEqual([ + { + _tag: "UnrecognizedOption", + message: + "Unrecognized flag: --definitely-not-a-child-flag in command supabase network-restrictions", + }, + ]); + }); +}); diff --git a/apps/cli/src/shared/output/json-formatter.ts b/apps/cli/src/shared/output/json-formatter.ts index e5fb754aa9..0819496346 100644 --- a/apps/cli/src/shared/output/json-formatter.ts +++ b/apps/cli/src/shared/output/json-formatter.ts @@ -1,6 +1,8 @@ import type { CliOutput, HelpDoc } from "effect/unstable/cli"; +import type { CliErrorSuggestionContext } from "../cli/subcommand-flag-suggestions.ts"; +import { formatCliErrorsForDisplay } from "../cli/subcommand-flag-suggestions.ts"; -export function jsonCliOutputFormatter(): CliOutput.Formatter { +export function jsonCliOutputFormatter(context?: CliErrorSuggestionContext): CliOutput.Formatter { return { formatHelpDoc: (doc: HelpDoc.HelpDoc) => JSON.stringify({ _tag: "Help", doc }), formatCliError: (error) => @@ -11,7 +13,10 @@ export function jsonCliOutputFormatter(): CliOutput.Formatter { formatErrors: (errors) => JSON.stringify({ _tag: "Errors", - errors: errors.map((e) => ({ code: e._tag, message: e.message })), + errors: formatCliErrorsForDisplay(errors, context).map((e) => ({ + code: e._tag, + message: e.message, + })), }), }; } diff --git a/apps/cli/src/shared/output/text-formatter.ts b/apps/cli/src/shared/output/text-formatter.ts index 4b497742ed..c5b5791b84 100644 --- a/apps/cli/src/shared/output/text-formatter.ts +++ b/apps/cli/src/shared/output/text-formatter.ts @@ -1,9 +1,35 @@ import { CliOutput } from "effect/unstable/cli"; +import type { + CliErrorSuggestionContext, + FormattedCliError, +} from "../cli/subcommand-flag-suggestions.ts"; +import { formatCliErrorsForDisplay } from "../cli/subcommand-flag-suggestions.ts"; -export function textCliOutputFormatter(): CliOutput.Formatter { +export function textCliOutputFormatter(context?: CliErrorSuggestionContext): CliOutput.Formatter { const base = CliOutput.defaultFormatter({ colors: false }); return { ...base, + formatErrors: (errors) => { + const formatted = formatCliErrorsForDisplay(errors, context); + if (formatted.length === 0) return ""; + if (formatted.length === 1) { + return `\nERROR\n ${formatted[0]?.message}`; + } + + const sections = ["\nERRORS"]; + const grouped = new Map>(); + for (const error of formatted) { + const group = grouped.get(error._tag) ?? []; + group.push(error); + grouped.set(error._tag, group); + } + for (const group of grouped.values()) { + for (const error of group) { + sections.push(` ${error.message}`); + } + } + return sections.join("\n"); + }, formatVersion: (_name, version) => version, }; } From a4809db93caf5558db26ee29c08962656a944510 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 1 Jul 2026 14:22:02 +0200 Subject: [PATCH 2/4] fix(cli): preserve parser error formatting --- apps/cli/src/shared/cli/run.ts | 10 +++++-- .../shared/cli/subcommand-flag-suggestions.ts | 12 ++++++-- .../subcommand-flag-suggestions.unit.test.ts | 30 +++++++++++-------- apps/cli/src/shared/output/json-formatter.ts | 2 +- apps/cli/src/shared/output/text-formatter.ts | 9 +++--- .../shared/output/text-formatter.unit.test.ts | 21 +++++++++++++ 6 files changed, 62 insertions(+), 22 deletions(-) create mode 100644 apps/cli/src/shared/output/text-formatter.unit.test.ts diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 0575b93a50..1b29aba459 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -93,9 +93,13 @@ function isErrorRecord(error: unknown): error is Record { return typeof error === "object" && error !== null; } -function isShowHelpCause(cause: Cause.Cause): boolean { +function isExplicitHelpCause(cause: Cause.Cause): boolean { const error = Cause.findErrorOption(cause); - return error._tag === "Some" && isErrorRecord(error.value) && error.value["_tag"] === "ShowHelp"; + if (error._tag !== "Some" || !isErrorRecord(error.value)) return false; + if (error.value["_tag"] !== "ShowHelp") return false; + + const errors = error.value["errors"]; + return !Array.isArray(errors) || errors.length === 0; } function projectContextLayerFor(runtimeLayer: Layer.Layer) { @@ -225,7 +229,7 @@ export async function runCli(rootCommand: Command.Command.Any, options: RunCliOp const exit = yield* program.pipe(Effect.exit); if (Exit.isFailure(exit)) { const interrupted = Cause.hasInterruptsOnly(exit.cause); - if (!interrupted && !isShowHelpCause(exit.cause)) { + if (!interrupted && !isExplicitHelpCause(exit.cause)) { yield* output.fail(normalizeCause(exit.cause)); } return yield* processControl.exit(interrupted ? 130 : 1); diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts index 386a5ce573..7a7e9bbbcd 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts @@ -10,6 +10,11 @@ export interface FormattedCliError { readonly message: string; } +export interface FormattedCliErrors { + readonly errors: ReadonlyArray; + readonly changed: boolean; +} + interface CommandWithHelpDoc extends Command.Command.Any { readonly buildHelpDoc: (path: ReadonlyArray) => HelpDoc.HelpDoc; } @@ -183,15 +188,17 @@ function buildSubcommandFlagHint( export function formatCliErrorsForDisplay( errors: ReadonlyArray, context?: CliErrorSuggestionContext, -): ReadonlyArray { +): FormattedCliErrors { const suppressedUnknownSubcommands = new Set(); const formatted: Array = []; + let changed = false; for (const error of errors) { if (error._tag === "UnrecognizedOption" && context) { const hint = buildSubcommandFlagHint(error, context); if (hint) { if (hint.consumedValue) suppressedUnknownSubcommands.add(hint.consumedValue); + changed = true; formatted.push({ _tag: error._tag, message: `${error.message}\n\n Hint: ${hint.hint}`, @@ -201,11 +208,12 @@ export function formatCliErrorsForDisplay( } if (error._tag === "UnknownSubcommand" && suppressedUnknownSubcommands.has(error.subcommand)) { + changed = true; continue; } formatted.push({ _tag: error._tag, message: error.message }); } - return formatted; + return { errors: formatted, changed }; } diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts index 4be183fa9d..16daeca601 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts @@ -34,15 +34,18 @@ describe("subcommand flag placement suggestions", () => { }, ); - expect(errors).toHaveLength(1); - expect(errors[0]?.message).toContain( + expect(errors.changed).toBe(true); + expect(errors.errors).toHaveLength(1); + expect(errors.errors[0]?.message).toContain( "Unrecognized flag: --project-ref in command supabase network-restrictions", ); - expect(errors[0]?.message).toContain( + expect(errors.errors[0]?.message).toContain( "Hint: --project-ref is available on `supabase network-restrictions get` and `supabase network-restrictions update`.", ); - expect(errors[0]?.message).toContain("supabase network-restrictions get --project-ref "); - expect(errors[0]?.message).not.toContain("Unknown subcommand"); + expect(errors.errors[0]?.message).toContain( + "supabase network-restrictions get --project-ref ", + ); + expect(errors.errors[0]?.message).not.toContain("Unknown subcommand"); }); it("leaves unrelated unrecognized flags unchanged", () => { @@ -60,12 +63,15 @@ describe("subcommand flag placement suggestions", () => { }, ); - expect(errors).toEqual([ - { - _tag: "UnrecognizedOption", - message: - "Unrecognized flag: --definitely-not-a-child-flag in command supabase network-restrictions", - }, - ]); + expect(errors).toEqual({ + changed: false, + errors: [ + { + _tag: "UnrecognizedOption", + message: + "Unrecognized flag: --definitely-not-a-child-flag in command supabase network-restrictions", + }, + ], + }); }); }); diff --git a/apps/cli/src/shared/output/json-formatter.ts b/apps/cli/src/shared/output/json-formatter.ts index 0819496346..f0c127f5dd 100644 --- a/apps/cli/src/shared/output/json-formatter.ts +++ b/apps/cli/src/shared/output/json-formatter.ts @@ -13,7 +13,7 @@ export function jsonCliOutputFormatter(context?: CliErrorSuggestionContext): Cli formatErrors: (errors) => JSON.stringify({ _tag: "Errors", - errors: formatCliErrorsForDisplay(errors, context).map((e) => ({ + errors: formatCliErrorsForDisplay(errors, context).errors.map((e) => ({ code: e._tag, message: e.message, })), diff --git a/apps/cli/src/shared/output/text-formatter.ts b/apps/cli/src/shared/output/text-formatter.ts index c5b5791b84..57b08e8e69 100644 --- a/apps/cli/src/shared/output/text-formatter.ts +++ b/apps/cli/src/shared/output/text-formatter.ts @@ -11,14 +11,15 @@ export function textCliOutputFormatter(context?: CliErrorSuggestionContext): Cli ...base, formatErrors: (errors) => { const formatted = formatCliErrorsForDisplay(errors, context); - if (formatted.length === 0) return ""; - if (formatted.length === 1) { - return `\nERROR\n ${formatted[0]?.message}`; + if (!formatted.changed) return base.formatErrors(errors); + if (formatted.errors.length === 0) return ""; + if (formatted.errors.length === 1) { + return `\nERROR\n ${formatted.errors[0]?.message}`; } const sections = ["\nERRORS"]; const grouped = new Map>(); - for (const error of formatted) { + for (const error of formatted.errors) { const group = grouped.get(error._tag) ?? []; group.push(error); grouped.set(error._tag, group); diff --git a/apps/cli/src/shared/output/text-formatter.unit.test.ts b/apps/cli/src/shared/output/text-formatter.unit.test.ts new file mode 100644 index 0000000000..50bfda54ed --- /dev/null +++ b/apps/cli/src/shared/output/text-formatter.unit.test.ts @@ -0,0 +1,21 @@ +import { CliError } from "effect/unstable/cli"; +import { describe, expect, it } from "vitest"; +import { textCliOutputFormatter } from "./text-formatter.ts"; + +describe("textCliOutputFormatter", () => { + it("preserves default parser suggestions for unchanged errors", () => { + const formatter = textCliOutputFormatter(); + + const text = formatter.formatErrors([ + new CliError.UnrecognizedOption({ + option: "--pla", + command: ["supabase", "projects", "create"], + suggestions: ["--plan"], + }), + ]); + + expect(text).toContain("Unrecognized flag: --pla in command supabase projects create"); + expect(text).toContain("Did you mean this?"); + expect(text).toContain("--plan"); + }); +}); From c071759825151b1b0da0ae818b3af44e3fdd7a42 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 1 Jul 2026 15:21:39 +0200 Subject: [PATCH 3/4] fix(cli): hide hidden commands from flag hints --- .../shared/cli/subcommand-flag-suggestions.ts | 2 ++ .../subcommand-flag-suggestions.unit.test.ts | 29 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts index 7a7e9bbbcd..d8aa793598 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts @@ -63,6 +63,8 @@ function collectDescendants( const visit = (current: Command.Command.Any, path: ReadonlyArray) => { for (const group of current.subcommands) { for (const child of group.commands) { + if (child.hidden) continue; + const childPath = [...path, child.name]; const helpDoc = helpDocFor(child, childPath); if (helpDoc) { diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts index 16daeca601..fd844313dc 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts @@ -1,10 +1,11 @@ import { CliError, Command } from "effect/unstable/cli"; import { describe, expect, it } from "vitest"; +import { legacyBranchesCommand } from "../../legacy/commands/branches/branches.command.ts"; import { legacyNetworkRestrictionsCommand } from "../../legacy/commands/network-restrictions/network-restrictions.command.ts"; import { formatCliErrorsForDisplay } from "./subcommand-flag-suggestions.ts"; const testRoot = Command.make("supabase").pipe( - Command.withSubcommands([legacyNetworkRestrictionsCommand]), + Command.withSubcommands([legacyBranchesCommand, legacyNetworkRestrictionsCommand]), ); describe("subcommand flag placement suggestions", () => { @@ -74,4 +75,30 @@ describe("subcommand flag placement suggestions", () => { ], }); }); + + it("omits hidden subcommands from placement hints", () => { + const errors = formatCliErrorsForDisplay( + [ + new CliError.UnrecognizedOption({ + option: "--project-ref", + command: ["supabase", "branches"], + suggestions: [], + }), + new CliError.UnknownSubcommand({ + subcommand: "abcdefghijklmnopqrst", + parent: ["supabase", "branches"], + suggestions: [], + }), + ], + { + rootCommand: testRoot, + args: ["branches", "--project-ref", "abcdefghijklmnopqrst", "get"], + }, + ); + + expect(errors.changed).toBe(true); + expect(errors.errors).toHaveLength(1); + expect(errors.errors[0]?.message).toContain("`supabase branches get`"); + expect(errors.errors[0]?.message).not.toContain("branches disable"); + }); }); From a287f2b26eb4057f9f186dc9f0cb2cba79d14f70 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 1 Jul 2026 22:53:34 +0200 Subject: [PATCH 4/4] fix(cli): preserve mixed parser suggestions --- .../shared/cli/subcommand-flag-suggestions.ts | 26 +++++++++--- .../subcommand-flag-suggestions.unit.test.ts | 41 ++++++++++++++----- apps/cli/src/shared/output/text-formatter.ts | 14 ++++++- .../shared/output/text-formatter.unit.test.ts | 37 ++++++++++++++++- 4 files changed, 99 insertions(+), 19 deletions(-) diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts index d8aa793598..1cc5124f98 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts @@ -8,6 +8,8 @@ export interface CliErrorSuggestionContext { export interface FormattedCliError { readonly _tag: string; readonly message: string; + readonly source: CliError.CliError; + readonly changed: boolean; } export interface FormattedCliErrors { @@ -80,8 +82,13 @@ function collectDescendants( return matches; } -function normalizeOption(option: string): string { +function optionToken(option: string): string { const withoutValue = option.split("=", 1)[0] ?? option; + return withoutValue; +} + +function normalizeOption(option: string): string { + const withoutValue = optionToken(option); if (withoutValue.startsWith("--")) return withoutValue.slice(2); if (withoutValue.startsWith("-")) return withoutValue.slice(1); return withoutValue; @@ -138,18 +145,22 @@ function formatCommandList(matches: ReadonlyArray): string { } function formatFlagUsage(option: string, flag: HelpDoc.FlagDoc): string { - return flag.type === "boolean" ? option : `${option} `; + const flagToken = optionToken(option); + return flag.type === "boolean" ? flagToken : `${flagToken} `; } function findValueAfterOption(args: ReadonlyArray, option: string): string | undefined { + const flagToken = optionToken(option); + if (option !== flagToken) return undefined; + for (let index = 0; index < args.length; index++) { const arg = args[index]; if (!arg) continue; - if (arg === option) { + if (arg === flagToken) { const next = args[index + 1]; return next && !next.startsWith("-") ? next : undefined; } - if (arg.startsWith(`${option}=`)) return undefined; + if (arg.startsWith(`${flagToken}=`)) return undefined; } return undefined; } @@ -169,6 +180,7 @@ function buildSubcommandFlagHint( if (matches.length === 0) return undefined; const attempted = inferAttemptedCommand(context.args, error.command, matches); + const flagToken = optionToken(error.option); const availableOn = matches.length === 1 ? `a flag for ${formatCommandList(matches)}` @@ -182,7 +194,7 @@ function buildSubcommandFlagHint( : undefined; return { - hint: `${error.option} is ${availableOn}. Pass it after the subcommand${example}`, + hint: `${flagToken} is ${availableOn}. Pass it after the subcommand${example}`, ...(consumedValue ? { consumedValue } : {}), }; } @@ -204,6 +216,8 @@ export function formatCliErrorsForDisplay( formatted.push({ _tag: error._tag, message: `${error.message}\n\n Hint: ${hint.hint}`, + source: error, + changed: true, }); continue; } @@ -214,7 +228,7 @@ export function formatCliErrorsForDisplay( continue; } - formatted.push({ _tag: error._tag, message: error.message }); + formatted.push({ _tag: error._tag, message: error.message, source: error, changed: false }); } return { errors: formatted, changed }; diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts index fd844313dc..079256d354 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts @@ -64,16 +64,12 @@ describe("subcommand flag placement suggestions", () => { }, ); - expect(errors).toEqual({ - changed: false, - errors: [ - { - _tag: "UnrecognizedOption", - message: - "Unrecognized flag: --definitely-not-a-child-flag in command supabase network-restrictions", - }, - ], - }); + expect(errors.changed).toBe(false); + expect(errors.errors).toHaveLength(1); + expect(errors.errors[0]?.changed).toBe(false); + expect(errors.errors[0]?.message).toBe( + "Unrecognized flag: --definitely-not-a-child-flag in command supabase network-restrictions", + ); }); it("omits hidden subcommands from placement hints", () => { @@ -101,4 +97,29 @@ describe("subcommand flag placement suggestions", () => { expect(errors.errors[0]?.message).toContain("`supabase branches get`"); expect(errors.errors[0]?.message).not.toContain("branches disable"); }); + + it("normalizes assigned flags in placement examples", () => { + const errors = formatCliErrorsForDisplay( + [ + new CliError.UnrecognizedOption({ + option: "--project-ref=jacraenyzrorgjhsdvvf", + command: ["supabase", "network-restrictions"], + suggestions: [], + }), + ], + { + rootCommand: testRoot, + args: ["network-restrictions", "--project-ref=jacraenyzrorgjhsdvvf", "get"], + }, + ); + + expect(errors.changed).toBe(true); + expect(errors.errors[0]?.message).toContain( + "Hint: --project-ref is available on `supabase network-restrictions get` and `supabase network-restrictions update`.", + ); + expect(errors.errors[0]?.message).toContain( + "supabase network-restrictions get --project-ref ", + ); + expect(errors.errors[0]?.message).not.toContain("--project-ref=jacraenyzrorgjhsdvvf "); + }); }); diff --git a/apps/cli/src/shared/output/text-formatter.ts b/apps/cli/src/shared/output/text-formatter.ts index 57b08e8e69..4829507963 100644 --- a/apps/cli/src/shared/output/text-formatter.ts +++ b/apps/cli/src/shared/output/text-formatter.ts @@ -5,8 +5,16 @@ import type { } from "../cli/subcommand-flag-suggestions.ts"; import { formatCliErrorsForDisplay } from "../cli/subcommand-flag-suggestions.ts"; +function stripSingleErrorHeader(text: string): string { + const prefix = "\nERROR\n "; + return text.startsWith(prefix) ? text.slice(prefix.length) : text.trimStart(); +} + export function textCliOutputFormatter(context?: CliErrorSuggestionContext): CliOutput.Formatter { const base = CliOutput.defaultFormatter({ colors: false }); + const formatErrorMessage = (error: FormattedCliError) => + error.changed ? error.message : stripSingleErrorHeader(base.formatErrors([error.source])); + return { ...base, formatErrors: (errors) => { @@ -14,7 +22,9 @@ export function textCliOutputFormatter(context?: CliErrorSuggestionContext): Cli if (!formatted.changed) return base.formatErrors(errors); if (formatted.errors.length === 0) return ""; if (formatted.errors.length === 1) { - return `\nERROR\n ${formatted.errors[0]?.message}`; + const [error] = formatted.errors; + if (!error) return ""; + return error.changed ? `\nERROR\n ${error.message}` : base.formatErrors([error.source]); } const sections = ["\nERRORS"]; @@ -26,7 +36,7 @@ export function textCliOutputFormatter(context?: CliErrorSuggestionContext): Cli } for (const group of grouped.values()) { for (const error of group) { - sections.push(` ${error.message}`); + sections.push(` ${formatErrorMessage(error)}`); } } return sections.join("\n"); diff --git a/apps/cli/src/shared/output/text-formatter.unit.test.ts b/apps/cli/src/shared/output/text-formatter.unit.test.ts index 50bfda54ed..f49cf597ea 100644 --- a/apps/cli/src/shared/output/text-formatter.unit.test.ts +++ b/apps/cli/src/shared/output/text-formatter.unit.test.ts @@ -1,7 +1,12 @@ -import { CliError } from "effect/unstable/cli"; +import { CliError, Command } from "effect/unstable/cli"; import { describe, expect, it } from "vitest"; +import { legacyNetworkRestrictionsCommand } from "../../legacy/commands/network-restrictions/network-restrictions.command.ts"; import { textCliOutputFormatter } from "./text-formatter.ts"; +const testRoot = Command.make("supabase").pipe( + Command.withSubcommands([legacyNetworkRestrictionsCommand]), +); + describe("textCliOutputFormatter", () => { it("preserves default parser suggestions for unchanged errors", () => { const formatter = textCliOutputFormatter(); @@ -18,4 +23,34 @@ describe("textCliOutputFormatter", () => { expect(text).toContain("Did you mean this?"); expect(text).toContain("--plan"); }); + + it("preserves default parser suggestions for unchanged siblings in rewritten errors", () => { + const formatter = textCliOutputFormatter({ + rootCommand: testRoot, + args: ["network-restrictions", "--project-ref", "jacraenyzrorgjhsdvvf", "get", "--pla"], + }); + + const text = formatter.formatErrors([ + new CliError.UnrecognizedOption({ + option: "--project-ref", + command: ["supabase", "network-restrictions"], + suggestions: [], + }), + new CliError.UnknownSubcommand({ + subcommand: "jacraenyzrorgjhsdvvf", + parent: ["supabase", "network-restrictions"], + suggestions: [], + }), + new CliError.UnrecognizedOption({ + option: "--pla", + command: ["supabase", "projects", "create"], + suggestions: ["--plan"], + }), + ]); + + expect(text).toContain("Hint: --project-ref is available on"); + expect(text).toContain("Unrecognized flag: --pla in command supabase projects create"); + expect(text).toContain("Did you mean this?"); + expect(text).toContain("--plan"); + }); });