UNPKG

@swell/cli

Version:

Swell's command line interface/utility

338 lines (337 loc) 14 kB
import { Help } from '@oclif/core'; /** * Custom Help class for Swell CLI * * Renders help in a cleaner format with: * - Two-line USAGE with "# interactive" comment * - Grouped flags by category (type options vs general) * - Custom sections for conditional dependencies (TYPE OPTIONS, APP TYPES, etc.) * * Commands opt-in to custom formatting by defining a static `helpMeta` property. * Commands without helpMeta use standard oclif formatting. */ export default class CustomHelp extends Help { /** * Override showCommandHelp to prevent duplicate summary for commands with helpMeta. * The default implementation logs summary separately, but our formatCommand includes it. * @param command - The command to display help for * @returns Promise that resolves when help is displayed */ async showCommandHelp(command) { const helpMeta = this.getHelpMeta(command); if (helpMeta) { // For commands with helpMeta, just log formatCommand output // (which includes the description/summary already) this.log(this.formatCommand(command)); } else { // For commands without helpMeta, use default behavior await super.showCommandHelp(command); } } /** * Override formatCommand to provide custom help output for commands with helpMeta. * Falls back to default formatting for commands without helpMeta. * @param command - The command to format help for * @returns Formatted help text as a string */ formatCommand(command) { const helpMeta = this.getHelpMeta(command); // No helpMeta - use default oclif formatting if (!helpMeta) { return super.formatCommand(command); } // Custom formatting for commands with helpMeta const lines = []; // Description (single line) - use summary if available, else first line of description const desc = command.summary || command.description?.split('\n')[0]; if (desc) { lines.push(desc, ''); } // USAGE section lines.push(...this.formatUsageSection(command, helpMeta)); // ARGUMENTS section const argsSection = this.formatArgumentsSection(command); if (argsSection.length > 0) { lines.push('', ...argsSection); } // TYPE SECTION (e.g., APP TYPES) - for commands with type-based conditional deps if (helpMeta.typeSection) { lines.push('', ...this.formatTypeSection(helpMeta.typeSection)); } // VARIANT SECTION (e.g., TRIGGER OPTIONS) - for simpler variant-based deps if (helpMeta.variantSection) { lines.push('', ...this.formatVariantSection(helpMeta.variantSection)); } // TYPE-SPECIFIC FLAGS (when using typeSection) if (helpMeta.typeFlags && helpMeta.typeFlags.length > 0) { const typeSpecificFlags = this.formatFlagsSection(command, 'TYPE OPTIONS', (name) => helpMeta.typeFlags.includes(name)); if (typeSpecificFlags.length > 0) { lines.push('', ...typeSpecificFlags); } } // GENERAL FLAGS section const excludeFlags = new Set([ ...(helpMeta.variantFlags || []), ...(helpMeta.typeFlags || []), ]); const flagsSection = this.formatFlagsSection(command, 'FLAGS', (name) => !excludeFlags.has(name)); if (flagsSection.length > 0) { lines.push('', ...flagsSection); } // DESCRIPTION section (if description differs from summary) const descSection = this.formatDescriptionSection(command); if (descSection.length > 0) { lines.push('', ...descSection); } // EXAMPLES section const examplesSection = this.formatExamplesSection(command); if (examplesSection.length > 0) { lines.push('', ...examplesSection); } return lines.join('\n'); } /** * Get helpMeta from a command, handling the Command.Loadable type. * The helpMeta is stored on the command class itself, accessible via the cached command. * @param command - The command to extract helpMeta from * @returns The helpMeta object if present on the command, otherwise undefined */ getHelpMeta(command) { // Command.Loadable stores all static properties on the object itself // We need to cast to access the helpMeta property return command.helpMeta; } /** * Format USAGE section with two-line pattern: * - Line 1: Interactive mode (no args) with "# interactive" comment * - Line 2: Direct/non-interactive mode with args * @param command - The command to format usage for * @param helpMeta - The help metadata for the command * @returns Formatted USAGE section as an array of strings */ formatUsageSection(command, helpMeta) { const lines = ['USAGE']; // Convert colon separator to space (e.g., "create:app" -> "create app") const cmdId = command.id.replaceAll(':', ' '); // First line: interactive (no args) const interactiveLine = ` $ swell ${cmdId}`; const padding = Math.max(50 - interactiveLine.length, 2); lines.push(`${interactiveLine}${' '.repeat(padding)}# interactive`); // Second line: direct mode with args if (helpMeta.usageDirect) { lines.push(` $ swell ${cmdId} ${helpMeta.usageDirect}`); } else { // Auto-generate from args and flags const directLine = this.generateDirectUsage(command); if (directLine !== interactiveLine) { lines.push(directLine); } } return lines; } /** * Auto-generate direct usage line from command definition. * @param command - The command to generate direct usage for * @returns The generated direct usage line as a string */ generateDirectUsage(command) { const cmdId = command.id.replaceAll(':', ' '); const parts = [` $ swell ${cmdId}`]; // Add args if (command.args) { const argEntries = Object.entries(command.args); for (const [name] of argEntries) { parts.push(`<${name.toLowerCase()}>`); } } // Check if there are any optional flags const flags = command.flags || {}; const hasOptionalFlags = Object.keys(flags).some((f) => f !== 'yes' && f !== 'help'); if (hasOptionalFlags) { parts.push('[...]'); } // Add -y if the command has it if (flags.yes) { parts.push('-y'); } return parts.join(' '); } /** * Format ARGUMENTS section with compact display. * @param command - The command to format arguments for * @returns Formatted ARGUMENTS section as an array of strings */ formatArgumentsSection(command) { if (!command.args || Object.keys(command.args).length === 0) { return []; } const lines = ['ARGUMENTS']; const argEntries = Object.entries(command.args); // Calculate padding for alignment const maxNameLength = Math.max(...argEntries.map(([name]) => name.length)); const padding = Math.max(maxNameLength + 4, 8); for (const [name, arg] of argEntries) { const argName = name.toUpperCase(); const desc = arg.description || ''; lines.push(` ${argName.padEnd(padding)}${desc}`); } return lines; } /** * Format TYPE section (APP TYPES, etc.) with requires/optional indicators. * @param section - The type section configuration with types and their requirements * @returns Formatted TYPE section as an array of strings */ formatTypeSection(section) { if (!section) return []; const lines = [section.title]; for (let i = 0; i < section.types.length; i++) { const type = section.types[i]; lines.push(` ${type.name.padEnd(12)} ${type.description}`); if (type.requires) { for (const req of type.requires) { lines.push(`${''.padEnd(15)}Requires: ${req}`); } } if (type.optional) { for (const opt of type.optional) { lines.push(`${''.padEnd(15)}Optional: ${opt}`); } } // Add blank line between types for readability (except last) if (i < section.types.length - 1) { lines.push(''); } } return lines; } /** * Format VARIANT section (TRIGGER OPTIONS, etc.) - compact inline format. * @param section - The variant section configuration with variants and their descriptions * @returns Formatted VARIANT section as an array of strings */ formatVariantSection(section) { if (!section) return []; const lines = [section.title]; for (const variant of section.variants) { // Format: " model -e, --events=<value> Description" const flagPart = variant.flag.padEnd(28); lines.push(` ${variant.name.padEnd(8)} ${flagPart} ${variant.description}`); } return lines; } /** * Format FLAGS section with filtering and sorting. * @param command - The command to format flags for * @param title - The section title to display * @param filter - Function to filter flags by name * @returns Formatted FLAGS section as an array of strings */ formatFlagsSection(command, title, filter) { if (!command.flags) { return []; } const flagEntries = Object.entries(command.flags).filter(([name, flag]) => filter(name) && name !== 'help' && !flag.hidden); if (flagEntries.length === 0) { return []; } const lines = [title]; // Sort flags: short flags first, then long-only flags, -y last flagEntries.sort(([aName, aFlag], [bName, bFlag]) => { // -y always last if (aName === 'yes') return 1; if (bName === 'yes') return -1; // Flags with short char before flags without const aHasChar = Boolean(aFlag.char); const bHasChar = Boolean(bFlag.char); if (aHasChar && !bHasChar) return -1; if (!aHasChar && bHasChar) return 1; // Alphabetical return aName.localeCompare(bName); }); for (const [name, flag] of flagEntries) { const shortChar = flag.char ? `-${flag.char}, ` : ' '; const longFlag = `--${name}`; const isBoolean = flag.type === 'boolean'; const hasOptions = 'options' in flag && flag.options; const valueStr = isBoolean ? '' : hasOptions ? '=<option>' : '=<value>'; const flagStr = `${shortChar}${longFlag}${valueStr}`; // Get description, clean up redundant "(required in -y)" text let desc = flag.summary || flag.description || ''; desc = desc .replaceAll(/\s*\(required (?:with|in) -y\)/gi, '') .replaceAll(/\s*\(required for .+ with -y\)/gi, '') .replaceAll(/\s*\(default in -y: .+\)/gi, '') .split('\n')[0] .trim(); lines.push(` ${flagStr.padEnd(30)} ${desc}`); } return lines; } /** * Format DESCRIPTION section - shows description if it differs from summary. * Only shown when there's meaningful additional content. * @param command - The command to format description for * @returns Formatted DESCRIPTION section as an array of strings */ formatDescriptionSection(command) { // Get description (may be multi-line) const { description } = command; if (!description) { return []; } // Skip if description equals summary (no additional info) const summary = command.summary || description.split('\n')[0]; if (description === summary) { return []; } const lines = ['DESCRIPTION']; // Format each line of description with proper indentation const descLines = description.split('\n'); for (const line of descLines) { lines.push(` ${line}`); } return lines; } /** * Format EXAMPLES section - clean, label-free examples. * @param command - The command to format examples for * @returns Formatted EXAMPLES section as an array of strings */ formatExamplesSection(command) { if (!command.examples || command.examples.length === 0) { return []; } const lines = ['EXAMPLES']; for (const example of command.examples) { if (typeof example === 'string') { // String example - check if it's a command if (example.trim().startsWith('$')) { lines.push(` ${example.trim()}`); } else if (example.includes('<%= command.id %>')) { // oclif template syntax const cmd = example.replace('<%= command.id %>', command.id); lines.push(` $ swell ${cmd.trim()}`); } // Skip description-only lines } else if (typeof example === 'object' && example.command) { // Object example with command property const cmd = example.command.startsWith('swell ') ? example.command : `swell ${example.command}`; lines.push(` $ ${cmd}`); } } return lines; } }