import type { StandardJSONSchemaV1 } from '@standard-schema/spec'; import { extractSchemaMetadata, getJsonSchema, type PadroneArgsSchemaMeta, parsePositionalConfig } from '../core/args.ts'; import { findCommandByName } from '../core/commands.ts'; import type { AnyPadroneCommand } from '../types/index.ts'; import { getRootCommand } from '../util/utils.ts'; import type { ColorConfig, ColorTheme } from './colorizer.ts'; import { createFormatter, type HelpArgumentInfo, type HelpDetail, type HelpFormat, type HelpInfo, type HelpPositionalInfo, type HelpSubcommandInfo, } from './formatter.ts'; export type HelpPreferences = { format?: HelpFormat | 'auto'; detail?: HelpDetail; theme?: ColorTheme | ColorConfig; /** Show all global commands and flags in full detail */ all?: boolean; /** Terminal width for text wrapping. Defaults to terminal columns or 80. */ width?: number; /** Terminal capabilities for auto-detection of ANSI and width. */ terminal?: { columns?: number; isTTY?: boolean }; /** Environment variables for auto-detection (e.g., NO_COLOR, CI). */ env?: Record; }; /** * Extract positional arguments info from schema based on meta.positional config. */ function extractPositionalArgsInfo( schema: StandardJSONSchemaV1, meta?: PadroneArgsSchemaMeta, ): { args: HelpPositionalInfo[]; positionalNames: Set } { const args: HelpPositionalInfo[] = []; const positionalNames = new Set(); if (!schema || !meta?.positional || meta.positional.length === 0) { return { args, positionalNames }; } const positionalConfig = parsePositionalConfig(meta.positional); try { const jsonSchema = getJsonSchema(schema) as Record; if (jsonSchema.type === 'object' && jsonSchema.properties) { const properties = jsonSchema.properties as Record; const required = (jsonSchema.required as string[]) || []; for (const { name, variadic } of positionalConfig) { const prop = properties[name]; if (!prop) continue; positionalNames.add(name); const optMeta = meta.fields?.[name]; args.push({ name: variadic ? `...${name}` : name, description: optMeta?.description ?? prop.description, optional: !required.includes(name), default: prop.default, type: variadic ? `array<${prop.items?.type || 'string'}>` : prop.type, enum: (prop.enum ?? prop.items?.enum) as string[] | undefined, }); } } } catch { // Fallback to empty result if toJSONSchema fails } return { args, positionalNames }; } function extractArgsInfo(schema: StandardJSONSchemaV1, meta?: PadroneArgsSchemaMeta, positionalNames?: Set) { const result: HelpArgumentInfo[] = []; if (!schema) return result; const vendor = schema['~standard'].vendor; if (!vendor.includes('zod')) return result; const argsMeta = meta?.fields; try { const jsonSchema = getJsonSchema(schema) as Record; // Handle object: z.object({ key: z.string(), ... }) if (jsonSchema.type === 'object' && jsonSchema.properties) { const properties = jsonSchema.properties as Record; const required = (jsonSchema.required as string[]) || []; const propertyNames = new Set(Object.keys(properties)); // Helper to check if a negated version of an arg exists const hasExplicitNegation = (key: string): boolean => { // Check for noVerbose style (camelCase) const camelNegated = `no${key.charAt(0).toUpperCase()}${key.slice(1)}`; if (propertyNames.has(camelNegated)) return true; // Check for no-verbose style (kebab-case, though rare in JS) const kebabNegated = `no-${key}`; if (propertyNames.has(kebabNegated)) return true; return false; }; // Helper to check if this arg is itself a negation of another arg const isNegationOf = (key: string): boolean => { // Check for noVerbose -> verbose (camelCase) if (key.startsWith('no') && key.length > 2 && key[2] === key[2]?.toUpperCase()) { const positiveKey = key.charAt(2).toLowerCase() + key.slice(3); if (propertyNames.has(positiveKey)) return true; } // Check for no-verbose -> verbose (kebab-case) if (key.startsWith('no-')) { const positiveKey = key.slice(3); if (propertyNames.has(positiveKey)) return true; } return false; }; for (const [key, prop] of Object.entries(properties)) { // Skip positional arguments - they are shown in arguments section if (positionalNames?.has(key)) continue; const isOptional = !required.includes(key); const enumValues = (prop.enum ?? prop.items?.enum) as string[] | undefined; const optMeta = argsMeta?.[key]; const propType = prop.type as string; // Resolve custom negative keywords from meta or schema const rawNegative = optMeta?.negative ?? prop?.negative; const hasCustomNegative = rawNegative !== undefined; const negativeList = hasCustomNegative ? typeof rawNegative === 'string' ? rawNegative ? [rawNegative] : [] : Array.from(rawNegative as readonly string[]).filter(Boolean) : undefined; // Booleans are negatable unless there's an explicit noArg property, // this arg is itself a negation of another arg, or custom negative keywords are set const isNegatable = propType === 'boolean' && !hasCustomNegative && !hasExplicitNegation(key) && !isNegationOf(key); result.push({ name: key, description: optMeta?.description ?? prop.description, optional: isOptional, default: prop.default, type: propType === 'array' ? `${prop.items?.type || 'string'}[]` : propType, enum: enumValues, deprecated: optMeta?.deprecated ?? prop?.deprecated, hidden: optMeta?.hidden ?? prop?.hidden, examples: optMeta?.examples ?? prop?.examples, variadic: propType === 'array', negatable: isNegatable, negative: negativeList?.length ? negativeList : undefined, group: optMeta?.group, }); } } } catch { // Fallback to empty result if toJSONSchema fails } return result; } // ============================================================================ // Core Help Info Builder // ============================================================================ /** * Builds a comprehensive HelpInfo structure from a command. * This is the single source of truth that all formatters use. * @param cmd - The command to build help info for * @param detail - The level of detail ('minimal', 'standard', or 'full') */ export function getHelpInfo(cmd: AnyPadroneCommand, detail: HelpPreferences['detail'] = 'standard', all?: boolean): HelpInfo { const rootCmd = getRootCommand(cmd); // A command is a "default" command if its name is '' or it has '' as an alias const isDefaultCommand = cmd.parent && (!cmd.name || cmd.aliases?.includes('')); // For commands with empty name, use the first non-empty alias as display name const nonEmptyAliases = cmd.aliases?.filter(Boolean); const commandName = cmd.path || cmd.name || nonEmptyAliases?.[0] || (cmd.parent ? '[default]' : 'program'); // Build display aliases: real aliases (excluding the one promoted to display name) + [default] marker const remainingAliases = !cmd.name && nonEmptyAliases?.length ? nonEmptyAliases.slice(1) : (nonEmptyAliases ?? []); const displayAliases = isDefaultCommand ? [...remainingAliases, '[default]'] : nonEmptyAliases; // Extract positional args from schema based on meta.positional const { args: positionalArgs, positionalNames } = cmd.argsSchema ? extractPositionalArgsInfo(cmd.argsSchema, cmd.meta) : { args: [], positionalNames: new Set() }; const hasPositionals = positionalArgs.length > 0; const helpInfo: HelpInfo = { name: commandName, title: cmd.title, description: cmd.description, examples: cmd.examples, aliases: displayAliases, deprecated: cmd.deprecated, hidden: cmd.hidden, usage: { command: rootCmd === cmd ? commandName : `${rootCmd.name} ${commandName}`, hasSubcommands: !!(cmd.commands && cmd.commands.length > 0), hasPositionals, hasArguments: false, // updated below after extracting arguments stdinField: cmd.meta?.stdin, }, }; // Build subcommands info (filter out hidden commands unless showing full detail) if (cmd.commands && cmd.commands.length > 0) { const visibleCommands = detail === 'full' ? cmd.commands : cmd.commands.filter((c) => !c.hidden); // If the command has both a handler and subcommands, show the handler as a "[default]" entry const selfEntry: typeof helpInfo.subcommands = cmd.action ? [{ name: '[default]', title: cmd.title, description: cmd.description }] : []; helpInfo.subcommands = [ ...selfEntry, ...visibleCommands.flatMap((c): HelpSubcommandInfo[] => { const isDefault = !c.name || c.aliases?.includes(''); const nonEmptyAliases = c.aliases?.filter(Boolean); const displayName = c.name || nonEmptyAliases?.[0] || '[default]'; const remainingAliases = !c.name && nonEmptyAliases?.length ? nonEmptyAliases.slice(1) : (nonEmptyAliases ?? []); // Only add [default] alias marker if it's not already the display name const displayAliases = isDefault && displayName !== '[default]' ? [...remainingAliases, '[default]'] : isDefault ? remainingAliases : nonEmptyAliases; const hasSubcommands = !!(c.commands && c.commands.length > 0); // If a command has subcommands AND a default handler (direct or '' subcommand), // show two entries: one for the default action, one for the subcommand router const hasDefaultHandler = c.action || c.commands?.some((sub) => !sub.name || sub.aliases?.includes('')); if (hasSubcommands && hasDefaultHandler) { const defaultSub = !c.action ? c.commands?.find((sub) => !sub.name || sub.aliases?.includes('')) : undefined; const hasDefaultSubInfo = defaultSub && (defaultSub.title || defaultSub.description); return [ { name: displayName, title: hasDefaultSubInfo ? defaultSub.title : c.title, description: hasDefaultSubInfo ? defaultSub.description : c.description, aliases: displayAliases?.length ? displayAliases : undefined, deprecated: c.deprecated, hidden: c.hidden, group: c.group, }, { name: displayName, title: c.title, description: c.description, deprecated: c.deprecated, hidden: c.hidden, hasSubcommands: true, group: c.group, }, ]; } return [ { name: displayName, title: c.title, description: c.description, aliases: displayAliases?.length ? displayAliases : undefined, deprecated: c.deprecated, hidden: c.hidden, hasSubcommands, group: c.group, }, ]; }), ]; // In 'full' detail mode, recursively build help for all nested commands if (detail === 'full') { helpInfo.nestedCommands = visibleCommands.map((c) => getHelpInfo(c, 'full')); } } // Build arguments info from positionals if (hasPositionals) { helpInfo.positionals = positionalArgs; } // Build arguments info with aliases (excluding positional args) if (cmd.argsSchema) { const argsInfo = extractArgsInfo(cmd.argsSchema, cmd.meta, positionalNames); const argMap: Record = Object.fromEntries(argsInfo.map((arg) => [arg.name, arg])); // Merge flags and aliases into arguments const { flags, aliases } = extractSchemaMetadata(cmd.argsSchema, cmd.meta?.fields, cmd.meta?.autoAlias); for (const [flag, name] of Object.entries(flags)) { const arg = argMap[name]; if (!arg) continue; arg.flags = [...(arg.flags || []), flag]; } for (const [alias, name] of Object.entries(aliases)) { const arg = argMap[name]; if (!arg) continue; arg.aliases = [...(arg.aliases || []), alias]; } // Filter out hidden arguments const visibleArgs = argsInfo.filter((arg) => !arg.hidden); if (visibleArgs.length > 0) { helpInfo.arguments = visibleArgs; helpInfo.usage.hasArguments = true; } } // Add global commands/flags (root command by default, all commands when --all is passed) if (!cmd.parent || all) { const builtins: HelpInfo['builtins'] = []; if (!findCommandByName('help', rootCmd.commands)) { builtins.push({ name: 'help [command], -h, --help', description: 'Show help for a command', sub: [ { name: '--all', description: 'Show all global commands and flags' }, { name: '--detail ', description: 'Detail level (minimal, standard, full)' }, { name: '--format ', description: 'Output format (text, ansi, json, markdown, html)' }, ], }); } if (!findCommandByName('version', rootCmd.commands)) { builtins.push({ name: 'version, -v, --version', description: 'Show version information', }); } if (!findCommandByName('completion', rootCmd.commands)) { builtins.push({ name: 'completion [shell]', description: 'Generate shell completions (bash, zsh, fish, powershell)', }); } if (!findCommandByName('man', rootCmd.commands)) { builtins.push({ name: 'man', description: 'Show or install man pages (--setup to install, --remove to uninstall) (experimental)', }); } builtins.push({ name: '[command] --repl', description: 'Start interactive REPL scoped to a command', }); if (!findCommandByName('mcp', rootCmd.commands)) { builtins.push({ name: 'mcp [http|stdio]', description: 'Start a Model Context Protocol server to expose commands as AI tools (experimental)', sub: [ { name: '--port ', description: 'HTTP port (default: 3000)' }, { name: '--host ', description: 'HTTP host (default: 127.0.0.1)' }, ], }); } builtins.push({ name: '--color [theme], --no-color', description: 'Set color theme (default, ocean, warm, monochrome) or disable colors', }); if (builtins.length > 0) { helpInfo.builtins = builtins; } } return helpInfo; } // ============================================================================ // Main Entry Point // ============================================================================ export function generateHelp(rootCommand: AnyPadroneCommand, commandObj: AnyPadroneCommand = rootCommand, prefs?: HelpPreferences): string { const helpInfo = getHelpInfo(commandObj, prefs?.detail, prefs?.all); const formatter = createFormatter( prefs?.format ?? 'auto', prefs?.detail, prefs?.theme, prefs?.all, prefs?.width, prefs?.terminal, prefs?.env, ); return formatter.format(helpInfo); }