|
1 | | -import { Command } from 'commander'; |
| 1 | +import { Command, type Argument as CommanderArgument, type Option as CommanderOption } from 'commander'; |
2 | 2 | import yaml from 'js-yaml'; |
3 | 3 | import type { Arg, CliCommand } from './registry.js'; |
4 | 4 | import { fullName } from './registry.js'; |
5 | 5 | import { formatCommandExample } from './serialization.js'; |
6 | 6 |
|
7 | 7 | export type StructuredHelpFormat = 'yaml' | 'json'; |
8 | 8 |
|
| 9 | +export interface ArgSpec { |
| 10 | + name: string; |
| 11 | + required?: true; |
| 12 | + variadic?: true; |
| 13 | + help?: string; |
| 14 | + default?: unknown; |
| 15 | + choices?: string[]; |
| 16 | +} |
| 17 | + |
| 18 | +export interface OptionSpec { |
| 19 | + name: string; |
| 20 | + flags: string; |
| 21 | + help?: string; |
| 22 | + takes_value?: 'required' | 'optional'; |
| 23 | + required?: true; |
| 24 | + default?: unknown; |
| 25 | + choices?: string[]; |
| 26 | + negate?: true; |
| 27 | +} |
| 28 | + |
9 | 29 | const COMMON_OPTIONS = [ |
10 | 30 | { |
11 | 31 | flags: '-f, --format <fmt>', |
@@ -158,6 +178,182 @@ function compactCommonOption(option: typeof COMMON_OPTIONS[number]): Record<stri |
158 | 178 | }; |
159 | 179 | } |
160 | 180 |
|
| 181 | +function compactCommanderArgument(arg: CommanderArgument): ArgSpec { |
| 182 | + return { |
| 183 | + name: arg.name(), |
| 184 | + ...(arg.required ? { required: true } : {}), |
| 185 | + ...(arg.variadic ? { variadic: true } : {}), |
| 186 | + ...(arg.description ? { help: arg.description } : {}), |
| 187 | + ...(arg.defaultValue !== undefined ? { default: arg.defaultValue } : {}), |
| 188 | + ...(arg.argChoices?.length ? { choices: [...arg.argChoices] } : {}), |
| 189 | + }; |
| 190 | +} |
| 191 | + |
| 192 | +function compactCommanderOption(option: CommanderOption): OptionSpec | null { |
| 193 | + if (option.hidden) return null; |
| 194 | + return { |
| 195 | + name: option.attributeName(), |
| 196 | + flags: option.flags, |
| 197 | + ...(option.description ? { help: option.description } : {}), |
| 198 | + ...(option.required ? { takes_value: 'required' as const } : {}), |
| 199 | + ...(option.optional ? { takes_value: 'optional' as const } : {}), |
| 200 | + ...(option.mandatory ? { required: true } : {}), |
| 201 | + ...(option.defaultValue !== undefined ? { default: option.defaultValue } : {}), |
| 202 | + ...(option.argChoices?.length ? { choices: [...option.argChoices] } : {}), |
| 203 | + ...(option.negate ? { negate: true } : {}), |
| 204 | + }; |
| 205 | +} |
| 206 | + |
| 207 | +function compactCommanderOptions(options: readonly CommanderOption[]): OptionSpec[] { |
| 208 | + return options |
| 209 | + .map(compactCommanderOption) |
| 210 | + .filter((option): option is OptionSpec => option !== null); |
| 211 | +} |
| 212 | + |
| 213 | +function commanderPath(command: Command): string[] { |
| 214 | + const parts: string[] = []; |
| 215 | + let current: Command | null = command; |
| 216 | + while (current) { |
| 217 | + const name = current.name(); |
| 218 | + if (name) parts.push(name); |
| 219 | + current = current.parent; |
| 220 | + } |
| 221 | + return parts.reverse(); |
| 222 | +} |
| 223 | + |
| 224 | +function commandPathFromRoot(namespaceRoot: Command, command: Command): string[] { |
| 225 | + const rootPath = commanderPath(namespaceRoot); |
| 226 | + const commandPath = commanderPath(command); |
| 227 | + return commandPath.slice(rootPath.length); |
| 228 | +} |
| 229 | + |
| 230 | +function collectLeafCommands(command: Command): Command[] { |
| 231 | + if (command.commands.length === 0) return [command]; |
| 232 | + return command.commands.flatMap(child => collectLeafCommands(child)); |
| 233 | +} |
| 234 | + |
| 235 | +function collectDescendantCommands(command: Command): Command[] { |
| 236 | + return command.commands.flatMap(child => [child, ...collectDescendantCommands(child)]); |
| 237 | +} |
| 238 | + |
| 239 | +function formatCommanderPositionals(args: readonly CommanderArgument[]): string { |
| 240 | + return args |
| 241 | + .map(arg => { |
| 242 | + const name = `${arg.name()}${arg.variadic ? '...' : ''}`; |
| 243 | + return arg.required ? `<${name}>` : `[${name}]`; |
| 244 | + }) |
| 245 | + .join(' '); |
| 246 | +} |
| 247 | + |
| 248 | +function formatCommanderUsage( |
| 249 | + command: Command, |
| 250 | + opts: { namespaceRoot?: Command; globalCommand?: Command } = {}, |
| 251 | +): string { |
| 252 | + const path = commanderPath(command).join(' '); |
| 253 | + const positionalText = formatCommanderPositionals(command.registeredArguments); |
| 254 | + const hasOptions = compactCommanderOptions(command.options).length > 0 |
| 255 | + || (opts.namespaceRoot ? compactCommanderOptions(opts.namespaceRoot.options).length > 0 : false) |
| 256 | + || (opts.globalCommand ? compactCommanderOptions(opts.globalCommand.options).length > 0 : false); |
| 257 | + const optionText = hasOptions ? ' [options]' : ''; |
| 258 | + return `${path}${positionalText ? ` ${positionalText}` : ''}${optionText}`; |
| 259 | +} |
| 260 | + |
| 261 | +function compactCommanderCommand( |
| 262 | + namespaceRoot: Command, |
| 263 | + command: Command, |
| 264 | + opts: { globalCommand?: Command } = {}, |
| 265 | +): Record<string, unknown> { |
| 266 | + const relativePath = commandPathFromRoot(namespaceRoot, command); |
| 267 | + return { |
| 268 | + name: relativePath.join(' '), |
| 269 | + command: commanderPath(command).join(' '), |
| 270 | + usage: formatCommanderUsage(command, { namespaceRoot, globalCommand: opts.globalCommand }), |
| 271 | + description: command.description(), |
| 272 | + ...(command.aliases().length ? { aliases: command.aliases() } : {}), |
| 273 | + positionals: command.registeredArguments.map(compactCommanderArgument), |
| 274 | + command_options: compactCommanderOptions(command.options), |
| 275 | + }; |
| 276 | +} |
| 277 | + |
| 278 | +export function commanderNamespaceHelpData( |
| 279 | + namespaceRoot: Command, |
| 280 | + opts: { globalCommand?: Command; description?: string } = {}, |
| 281 | +): Record<string, unknown> { |
| 282 | + const leaves = collectLeafCommands(namespaceRoot) |
| 283 | + .filter(command => command !== namespaceRoot) |
| 284 | + .sort((a, b) => commandPathFromRoot(namespaceRoot, a).join(' ').localeCompare(commandPathFromRoot(namespaceRoot, b).join(' '))); |
| 285 | + return { |
| 286 | + namespace: namespaceRoot.name(), |
| 287 | + command: commanderPath(namespaceRoot).join(' '), |
| 288 | + usage: `${commanderPath(namespaceRoot).join(' ')} <command> [args] [options]`, |
| 289 | + description: opts.description ?? namespaceRoot.description(), |
| 290 | + command_count: leaves.length, |
| 291 | + commands: leaves.map(command => compactCommanderCommand(namespaceRoot, command, opts)), |
| 292 | + namespace_options: compactCommanderOptions(namespaceRoot.options), |
| 293 | + ...(opts.globalCommand ? { global_options: compactCommanderOptions(opts.globalCommand.options) } : {}), |
| 294 | + structured_help: { |
| 295 | + formats: ['yaml', 'json'], |
| 296 | + usage: `${commanderPath(namespaceRoot).join(' ')} --help -f yaml`, |
| 297 | + }, |
| 298 | + }; |
| 299 | +} |
| 300 | + |
| 301 | +export function commanderCommandHelpData( |
| 302 | + namespaceRoot: Command, |
| 303 | + command: Command, |
| 304 | + opts: { globalCommand?: Command } = {}, |
| 305 | +): Record<string, unknown> { |
| 306 | + return { |
| 307 | + namespace: namespaceRoot.name(), |
| 308 | + ...compactCommanderCommand(namespaceRoot, command, opts), |
| 309 | + namespace_options: compactCommanderOptions(namespaceRoot.options), |
| 310 | + ...(opts.globalCommand ? { global_options: compactCommanderOptions(opts.globalCommand.options) } : {}), |
| 311 | + structured_help: { |
| 312 | + formats: ['yaml', 'json'], |
| 313 | + usage: `${commanderPath(command).join(' ')} --help -f yaml`, |
| 314 | + }, |
| 315 | + }; |
| 316 | +} |
| 317 | + |
| 318 | +export function commanderGroupHelpData( |
| 319 | + namespaceRoot: Command, |
| 320 | + groupCommand: Command, |
| 321 | + opts: { globalCommand?: Command } = {}, |
| 322 | +): Record<string, unknown> { |
| 323 | + const leaves = collectLeafCommands(groupCommand) |
| 324 | + .filter(command => command !== groupCommand) |
| 325 | + .sort((a, b) => commandPathFromRoot(namespaceRoot, a).join(' ').localeCompare(commandPathFromRoot(namespaceRoot, b).join(' '))); |
| 326 | + return { |
| 327 | + namespace: namespaceRoot.name(), |
| 328 | + group: commandPathFromRoot(namespaceRoot, groupCommand).join(' '), |
| 329 | + command: commanderPath(groupCommand).join(' '), |
| 330 | + usage: `${commanderPath(groupCommand).join(' ')} <command> [args] [options]`, |
| 331 | + description: groupCommand.description(), |
| 332 | + command_count: leaves.length, |
| 333 | + commands: leaves.map(command => compactCommanderCommand(namespaceRoot, command, opts)), |
| 334 | + namespace_options: compactCommanderOptions(namespaceRoot.options), |
| 335 | + ...(opts.globalCommand ? { global_options: compactCommanderOptions(opts.globalCommand.options) } : {}), |
| 336 | + structured_help: { |
| 337 | + formats: ['yaml', 'json'], |
| 338 | + usage: `${commanderPath(groupCommand).join(' ')} --help -f yaml`, |
| 339 | + }, |
| 340 | + }; |
| 341 | +} |
| 342 | + |
| 343 | +export function installCommanderNamespaceStructuredHelp( |
| 344 | + namespaceRoot: Command, |
| 345 | + opts: { globalCommand?: Command; description?: string } = {}, |
| 346 | +): void { |
| 347 | + installStructuredHelp(namespaceRoot, () => commanderNamespaceHelpData(namespaceRoot, opts)); |
| 348 | + for (const command of collectDescendantCommands(namespaceRoot)) { |
| 349 | + if (command.commands.length > 0) { |
| 350 | + installStructuredHelp(command, () => commanderGroupHelpData(namespaceRoot, command, opts)); |
| 351 | + } else { |
| 352 | + installStructuredHelp(command, () => commanderCommandHelpData(namespaceRoot, command, opts)); |
| 353 | + } |
| 354 | + } |
| 355 | +} |
| 356 | + |
161 | 357 | function positionals(cmd: CliCommand): Arg[] { |
162 | 358 | return cmd.args.filter(arg => arg.positional); |
163 | 359 | } |
|
0 commit comments