|
1 | | -import { Command } from 'commander' |
| 1 | +import { Command, type Option } from 'commander' |
2 | 2 | import { CLI_CONTRACT } from '../contract/commands' |
3 | 3 | import type { CommandSpec, CommandVariantSpec } from '../contract/types' |
4 | 4 | import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' |
@@ -93,46 +93,133 @@ function looksLikeAnId(token: string): boolean { |
93 | 93 | ) |
94 | 94 | } |
95 | 95 |
|
| 96 | +const MISSING_OPTION_ERROR = /^error: required option '(.+?)' not specified/ |
| 97 | +const EXCESS_ARGUMENTS_ERROR = /^error: too many arguments/ |
| 98 | + |
| 99 | +interface CommandInternals { |
| 100 | + _actionHandler?: unknown |
| 101 | + _hidden?: boolean |
| 102 | +} |
| 103 | + |
| 104 | +function longFlagSyntax(option: Option): string { |
| 105 | + return option.flags.replace(/^-[A-Za-z], /, '') |
| 106 | +} |
| 107 | + |
| 108 | +/** The command line with its positionals and every required flag, as `--help` would read it. */ |
| 109 | +function usageExample(command: Command): string { |
| 110 | + const parts = [commandPath(command), argumentSyntax(command)] |
| 111 | + for (const option of command.options) if (option.mandatory) parts.push(longFlagSyntax(option)) |
| 112 | + return parts.filter(Boolean).join(' ') |
| 113 | +} |
| 114 | + |
| 115 | +function optionList(command: Command): string { |
| 116 | + const flags = command.options.filter((option) => !option.hidden).map((option) => option.flags) |
| 117 | + return flags.length > 0 |
| 118 | + ? `Options for ${commandPath(command)}: ${flags.join(', ')}` |
| 119 | + : `${commandPath(command)} takes no options.` |
| 120 | +} |
| 121 | + |
| 122 | +/** Operands past the declared positionals: the values a caller typed where a flag was wanted. */ |
| 123 | +function extraOperands(command: Command): string[] { |
| 124 | + const declared = command.registeredArguments.length |
| 125 | + if (command.registeredArguments.some((argument) => argument.variadic)) return [] |
| 126 | + return command.args.filter((arg) => !arg.startsWith('-')).slice(declared) |
| 127 | +} |
| 128 | + |
96 | 129 | /** |
97 | | - * Appends a worked example to the parse errors a positional argument causes, |
98 | | - * and the command's own flags to an unknown option. |
| 130 | + * The visible sibling commands that do declare a flag this one does not, each |
| 131 | + * with its description, so a read never picks a sibling that deletes. |
| 132 | + */ |
| 133 | +function siblingsDeclaring(command: Command, flag: string): string[] { |
| 134 | + const siblings = command.parent?.commands ?? [] |
| 135 | + return siblings |
| 136 | + .filter( |
| 137 | + (sibling) => |
| 138 | + sibling !== command && |
| 139 | + !(sibling as Command & CommandInternals)._hidden && |
| 140 | + sibling.options.some( |
| 141 | + (option) => !option.hidden && (option.long === flag || option.short === flag) |
| 142 | + ) |
| 143 | + ) |
| 144 | + .map((sibling) => ` ${commandPath(sibling)} — ${sibling.description().split('\n', 1)[0]}`) |
| 145 | +} |
| 146 | + |
| 147 | +/** |
| 148 | + * Appends recovery guidance to commander's parse errors, so the retry needs no |
| 149 | + * `--help` round. |
99 | 150 | * |
100 | | - * Covers the argument being absent and the argument being swallowed as an |
101 | | - * option because its id opens with a dash; the second needs the `--` escape, |
102 | | - * which commander never mentions. A guessed flag gets the real list: commander |
103 | | - * suggests only a near spelling, so `--row-ids` for `--row` got a bare error |
104 | | - * and a caller had to look the command up before retrying. |
| 151 | + * - A missing positional or required flag gets the full command line, and a |
| 152 | + * value typed as a positional where the flag was wanted (`files rename |
| 153 | + * <id> new.pdf`, `knowledge search <kbId>`) is named against that flag. |
| 154 | + * - Too many positionals get the same command line and the flag list. |
| 155 | + * - An unknown option gets the command's own flags, and the sibling commands |
| 156 | + * that do take it: `tables rows list --filter` belongs to `tables rows |
| 157 | + * query`, which commander's near-spelling suggestion never reaches. |
| 158 | + * - An id swallowed as an option because it opens with a dash gets the `--` |
| 159 | + * escape, which commander never mentions. |
105 | 160 | */ |
106 | | -function addParseErrorGuidance(command: Command): Command { |
107 | | - const outputError = command.configureOutput().outputError |
108 | | - if (!outputError) throw new Error('Commander output formatter is not configured') |
109 | | - |
110 | | - command.configureOutput({ |
111 | | - outputError: (message, write) => { |
112 | | - outputError(message, write) |
113 | | - |
114 | | - if (message.startsWith('error: missing required argument ')) { |
115 | | - const syntax = argumentSyntax(command) |
116 | | - const example = syntax ? `${commandPath(command)} ${syntax}` : commandPath(command) |
117 | | - write(`Example: ${example}\n`) |
118 | | - return |
119 | | - } |
| 161 | +function parseErrorGuidance(command: Command, message: string): string[] { |
| 162 | + if (message.startsWith('error: missing required argument ')) { |
| 163 | + return [`Example: ${usageExample(command)}`] |
| 164 | + } |
120 | 165 |
|
121 | | - const token = UNKNOWN_OPTION_TOKEN.exec(message)?.[1] |
122 | | - if (!token) return |
123 | | - if (looksLikeAnId(token) && command.registeredArguments.length > 0) { |
124 | | - write(`Example: ${commandPath(command)} -- ${token}\n`) |
125 | | - return |
126 | | - } |
127 | | - const flags = command.options.filter((option) => !option.hidden).map((option) => option.flags) |
128 | | - write( |
129 | | - flags.length > 0 |
130 | | - ? `Options for ${commandPath(command)}: ${flags.join(', ')}\n` |
131 | | - : `${commandPath(command)} takes no options.\n` |
132 | | - ) |
133 | | - }, |
134 | | - }) |
135 | | - return command |
| 166 | + const missingOption = MISSING_OPTION_ERROR.exec(message)?.[1] |
| 167 | + if (missingOption) { |
| 168 | + const extra = extraOperands(command) |
| 169 | + const flag = missingOption.split(' ', 1)[0] |
| 170 | + const moved = |
| 171 | + extra.length === 1 |
| 172 | + ? [`"${extra[0]}" was passed as a positional; pass it as ${flag} "${extra[0]}".`] |
| 173 | + : [] |
| 174 | + return [...moved, `Example: ${usageExample(command)}`] |
| 175 | + } |
| 176 | + |
| 177 | + if (EXCESS_ARGUMENTS_ERROR.test(message)) { |
| 178 | + return [`Example: ${usageExample(command)}`, optionList(command)] |
| 179 | + } |
| 180 | + |
| 181 | + const token = UNKNOWN_OPTION_TOKEN.exec(message)?.[1] |
| 182 | + if (!token) return [] |
| 183 | + if (looksLikeAnId(token) && command.registeredArguments.length > 0) { |
| 184 | + return [`Example: ${commandPath(command)} -- ${token}`] |
| 185 | + } |
| 186 | + const flag = token.split('=', 1)[0] |
| 187 | + const owners = siblingsDeclaring(command, flag) |
| 188 | + return [ |
| 189 | + optionList(command), |
| 190 | + ...(owners.length > 0 ? [`${flag} is an option of:`, ...owners] : []), |
| 191 | + ] |
| 192 | +} |
| 193 | + |
| 194 | +/** |
| 195 | + * Wraps the command's own `error`, which every commander parse failure on it |
| 196 | + * goes through. The output configuration cannot carry this: commands made with |
| 197 | + * `.command()` share their parent's configuration object, so a formatter set on |
| 198 | + * one would answer for all of them. |
| 199 | + */ |
| 200 | +function addParseErrorGuidance(command: Command): void { |
| 201 | + const error = command.error.bind(command) |
| 202 | + command.error = (message, options) => { |
| 203 | + const guidance = parseErrorGuidance(command, message) |
| 204 | + return error(guidance.length > 0 ? `${message}\n${guidance.join('\n')}` : message, options) |
| 205 | + } |
| 206 | +} |
| 207 | + |
| 208 | +/** |
| 209 | + * Attaches {@link addParseErrorGuidance} to every command that parses its own |
| 210 | + * arguments, generated or attached by hand (`files upload`, `secrets set`), so |
| 211 | + * a hand-written command does not answer a guessed flag with a bare error. |
| 212 | + * Pure dispatchers below the root are left to commander's unknown-command handling. |
| 213 | + */ |
| 214 | +export function addParseErrorGuidanceToTree(program: Command): void { |
| 215 | + const walk = (command: Command): void => { |
| 216 | + const parsesOwnArguments = |
| 217 | + command.commands.length === 0 || |
| 218 | + Boolean((command as Command & CommandInternals)._actionHandler) |
| 219 | + if (parsesOwnArguments || !command.parent) addParseErrorGuidance(command) |
| 220 | + for (const child of command.commands) walk(child) |
| 221 | + } |
| 222 | + walk(program) |
136 | 223 | } |
137 | 224 |
|
138 | 225 | /** |
@@ -374,7 +461,7 @@ function configureOperation( |
374 | 461 | } |
375 | 462 |
|
376 | 463 | function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: string): Command { |
377 | | - return addParseErrorGuidance(configureOperation(new Command(leafName), operation, spec)) |
| 464 | + return configureOperation(new Command(leafName), operation, spec) |
378 | 465 | } |
379 | 466 |
|
380 | 467 | /** |
|
0 commit comments