@swell/cli
Version:
Swell's command line interface/utility
338 lines (337 loc) • 14 kB
JavaScript
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;
}
}