/** * Per-command help text. * * `shannon --help`, `shannon -h`, and `shannon help ` * all render the matching command's usage, so a user can discover a command's * flags without scanning the global help. The global help lives in index.ts. */ import { commandPrefix, getMode } from './mode.js'; interface CommandHelp { readonly usage: readonly string[]; readonly description: string; readonly options?: readonly (readonly [string, string])[]; readonly examples?: readonly string[]; } const YES_OPTION: readonly [string, string] = [ '-y, --yes', 'Skip the confirmation prompt (required for non-interactive use)', ]; const HELP_OPTION: readonly [string, string] = ['-h, --help', 'Show this help']; /** * `start`'s flags, the single source rendered by both the per-command help here * and the global help in index.ts, so the two can never drift. */ export const START_OPTIONS: readonly (readonly [string, string])[] = [ ['-u, --url ', 'Target URL (required)'], ['-r, --repo ', 'Repository path (required)'], ['-c, --config ', 'Configuration file (YAML)'], ['-o, --output ', 'Copy deliverables to this directory after the run'], ['-w, --workspace ', 'Named workspace (auto-resumes if it exists)'], ['-f, --follow', 'Stream the scan log until it finishes'], ['--pipeline-testing', 'Use minimal prompts for fast testing'], ['--keep-container', 'Preserve the worker container after exit for log inspection'], ]; const COMMAND_HELP: Readonly> = { start: { usage: ['start -u -r [options]'], description: 'Start a pentest scan.', examples: [ 'start -u https://example.com -r ./my-repo', 'start -u https://example.com -r /path/to/repo -c config.yaml -w q1-audit', 'start -u https://example.com -r ./my-repo --follow', ], }, stop: { usage: ['stop [] [--yes]', 'stop --all [--yes]'], description: 'Stop one scan by workspace, or every scan with --all (Temporal stays up). With no workspace, stops the single running scan; when several are running, name one or use --all.', options: [['--all', 'Stop all running scans'], YES_OPTION], examples: ['stop', 'stop q1-audit', 'stop --all'], }, reset: { usage: ['reset'], description: 'Stop everything and permanently remove all Temporal data and volumes.', }, logs: { usage: ['logs []'], description: "Tail a scan's live log until it completes. With no workspace, follows the single running scan, or the most recent workspace when none is running; when several are running, name one.", examples: ['logs', 'logs q1-audit'], }, status: { usage: ['status [] [--json]'], description: "Show one scan's phase-by-phase progress, read live from Temporal. With no workspace, shows the single running scan, or the most recent workspace when none is running; when several are running, name one. Watches and redraws until the scan finishes on a terminal; prints one frame when piped or already finished. With --json, prints a single machine-readable snapshot and exits.", options: [['--json', 'Output a point-in-time snapshot as JSON, then exit']], examples: ['status', 'status q1-audit', 'status q1-audit --json'], }, scans: { usage: ['scans [--json]'], description: 'List running and completed scans, and where each finished report lives.', options: [['--json', 'Output the scan list as JSON']], examples: ['scans', 'scans --json'], }, build: { usage: ['build [--no-cache]'], description: 'Build the worker Docker image (local mode only).', options: [['--no-cache', 'Build without using the Docker layer cache']], }, setup: { usage: ['setup'], description: 'Configure provider credentials interactively (npx mode only).', }, version: { usage: ['version [--json]'], description: 'Show the version. With --json, prints the version and mode as a machine-readable object.', options: [['--json', 'Output the version and mode as JSON']], examples: ['version', 'version --json'], }, }; /** Commands that only exist in one mode; everything else is available in both. */ const MODE_ONLY: Readonly> = { build: 'local', setup: 'npx', }; /** Whether a command has its own help page (and so responds to `--help`/`-h`). */ export function isHelpableCommand(command: string): boolean { return command in COMMAND_HELP; } /** * Every explicit help topic, mode-blind, with `help` itself as the known global topic. * Topic lookup is deliberately not mode-filtered (unlike `availableCommands`) so * cross-mode help such as local `help setup` and npx `help build` keeps working. */ export function helpTopics(): readonly string[] { return [...Object.keys(COMMAND_HELP), 'help']; } /** * User-facing command names available in the current mode, for "did you mean?" * suggestions. Derived from the same table that backs per-command help, so the * suggestion set can never drift from the commands that actually exist. */ export function availableCommands(): readonly string[] { const mode = getMode(); const commands = Object.keys(COMMAND_HELP).filter((command) => (MODE_ONLY[command] ?? mode) === mode); return [...commands, 'help']; } /** Print the help page for one command. No-op if the command has no page. */ export function printCommandHelp(command: string): void { const help = COMMAND_HELP[command]; if (!help) return; const prefix = commandPrefix(); const baseOptions = command === 'start' ? START_OPTIONS : (help.options ?? []); const options = [...baseOptions, HELP_OPTION]; const flagWidth = Math.max(...options.map(([flag]) => flag.length)); const lines: string[] = ['', help.description, '', 'USAGE']; for (const line of help.usage) { lines.push(` ${prefix} ${line}`); } lines.push('', 'OPTIONS'); for (const [flag, desc] of options) { lines.push(` ${flag.padEnd(flagWidth)} ${desc}`); } if (help.examples && help.examples.length > 0) { lines.push('', 'EXAMPLES'); for (const example of help.examples) { lines.push(` ${prefix} ${example}`); } } lines.push(''); console.log(lines.join('\n')); }