import { COMMAND_ARGUMENTS, normalizeCommandAlias } from "./command-args"; import { apiOperations } from "./command-registry"; import { FILE_COMMAND_ARGUMENTS, FILE_COMMAND_NOTES, } from "./file-command-contract"; /** Literal command words only; placeholders and quoted data are not commands. */ function words(usage: string): string[] { const parts = usage .replace(/^dench\s+/, "") .split(/\s+/) .filter(Boolean); const end = parts.findIndex((part) => /^[<[('"|-]/.test(part)); return end < 0 ? parts : parts.slice(0, end); } /** Resolve only actual CLI aliases, while callers retain the requested spelling. */ export function normalizeHelpCommand(argv: readonly string[]): string[] { return normalizeCommandAlias(argv.filter((arg) => arg !== "help")); } function matches( prefix: readonly string[], candidate: readonly string[], ): boolean { return prefix.every((word, index) => candidate[index]?.split("|").includes(word), ); } type Entry = { usage: string; description: string; rank: number; examples?: string[]; notes?: string[]; }; /** Even a dispatcher with no richer usage must disclose required arguments. */ export function argumentUsage( entry: (typeof COMMAND_ARGUMENTS)[number], ): string { if (entry.usage) return entry.usage; const [min, max] = entry.positionals; const count = max ?? min; const positional = Array.from({ length: count }, (_, i) => { const name = count === 1 ? "argument" : `argument${i + 1}`; return i < min ? `<${name}>` : `[<${name}>]`; }); if (max === null) positional.push("[arguments...]"); return [ `dench ${entry.command}`, ...positional, ...entry.values.map((flag) => `[${flag} ]`), ...entry.switches.map((flag) => `[${flag}]`), ].join(" "); } function nativeFileUsage(usage: string): boolean { const path = words(usage); return ( path[0] !== "files" || path.length < 2 || FILE_COMMAND_ARGUMENTS.some((entry) => matches(path.slice(0, 2), entry.command.split(" ")), ) ); } /** Prefer richer syntax PER command, never suppress uncovered sibling commands. */ function preferredEntries(entries: Entry[]): Entry[] { const groups = new Map(); for (const entry of entries) { const key = words(entry.usage).join(" "); if (!key || !nativeFileUsage(entry.usage)) continue; const existing = groups.get(key) ?? []; const rank = existing[0]?.rank ?? -1; if (entry.rank > rank) groups.set(key, [entry]); else if ( entry.rank === rank && !existing.some((item) => item.usage === entry.usage) ) { groups.set(key, [...existing, entry]); } } return [...groups.values()].flat(); } function stripUsageComment(line: string): string { let quote = ""; for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === "\\" && quote !== "'") { i++; continue; } if (char === quote) quote = ""; else if (!quote && (char === "'" || char === '"')) quote = char; else if (!quote && char === "#" && /\s/.test(line[i - 1] ?? "")) return line.slice(0, i).trim(); } return line.trim(); } /** Preserve immediately following option lines without turning prose into syntax. */ function usageEntries(text: string): Entry[] { const entries: Entry[] = []; let current: Entry | undefined; let indent = 0; for (const line of text.split("\n")) { const value = line.trim(); const depth = line.length - line.trimStart().length; if (value.startsWith("dench ")) { current = { usage: stripUsageComment(value).replace(/\\$/, "").trim(), description: "", rank: 3, }; entries.push(current); indent = depth; } else if (current && depth > indent && /^(?:\[?-{1,2}|<)/.test(value)) { current.usage += ` ${stripUsageComment(value).replace(/\\$/, "").trim()}`; } else { current = undefined; } } return entries; } /** Registry metadata supplies discovery, never invokes command implementations. */ export function commandHelp(argv: readonly string[], usageText = ""): string { const args = [...argv]; if (args[0] === "help") args.shift(); const requested = words(args.filter((arg) => arg !== "help").join(" ")); const input = normalizeHelpCommand(requested); const aliased = input.join(" ") !== requested.join(" "); if (input[0] === "files" && input[1] === "mkdir") { return "dench files mkdir is not a native CLI command. Directory creation is available as files.mkdir through the API/SDK. Run dench files --help for supported file commands."; } const candidates: Entry[] = [ ...usageEntries(usageText), ...COMMAND_ARGUMENTS.map((entry) => ({ usage: argumentUsage(entry), description: entry.description ?? "", rank: entry.usage ? 4 : 2, examples: entry.examples, notes: entry.notes, })), ...apiOperations .filter((op) => { if (!op.deprecated) return true; const command = words(op.cli); return ( command.length > 1 && input.length >= command.length && matches(input.slice(0, command.length), command) ); }) .map((op) => ({ usage: op.cli, description: op.deprecated ? `Compatibility command. ${op.summary}` : op.summary, rank: 1, })), ]; const preferred = preferredEntries(candidates); if (!input.length) { const families = new Map>(); for (const entry of preferred) { const path = words(entry.usage); if (!path.length) continue; const set = families.get(path[0]) ?? new Set(); set.add(path.join(" ")); families.set(path[0], set); } return [ "Dench CLI", "Usage: dench [arguments] [options]", "", "Commands:", ...[...families] .sort(([a], [b]) => a.localeCompare(b)) .map( ([name, paths]) => ` ${name}${paths.size > 1 ? ` (${paths.size} commands)` : ""}`, ), "", "Run dench --help or dench --help for syntax and examples.", "Connect: dench signin. Inspect the workspace: dench context --json.", "--json selects structured results. --help and -h run locally without authentication.", "Backend overrides: --prod, --staging, --backend , --host .", "Versioned references: dench reference --list --json.", ].join("\n"); } let prefix = input; while ( prefix.length && !preferred.some((item) => matches(prefix, words(item.usage))) ) prefix = prefix.slice(0, -1); const selected = preferred.filter((item) => matches(prefix, words(item.usage)), ); const lines = selected.slice(0, 12); const exact = lines.filter( (item) => words(item.usage).length === prefix.length, ); const contract = COMMAND_ARGUMENTS.find( (entry) => entry.command === prefix.join(" "), ); const printedUsage = lines.map((item) => item.usage).join(" "); const missingFlags = contract ? [ ...contract.values .filter((flag) => !printedUsage.includes(flag)) .map((flag) => `${flag} `), ...contract.switches.filter((flag) => !printedUsage.includes(flag)), ] : []; const displayPath = aliased ? requested.slice(0, prefix.length + requested.length - input.length) : prefix; return [ `Usage: dench ${displayPath.join(" ")}${displayPath.length ? "" : ""}`, ...(aliased ? [`Alias of dench ${prefix.join(" ")}.`] : []), ...lines.map( (item) => ` ${item.usage}${item.description ? `\n ${item.description}` : ""}`, ), ...(selected.length > 12 ? [ ` ${selected.length - 12} more commands; add a subcommand before --help.`, ] : []), ...(missingFlags.length ? [`Additional options: ${missingFlags.join(", ")}`] : []), ...exact.flatMap((item) => item.notes ?? []), ...(prefix.length === 1 && prefix[0] === "files" ? FILE_COMMAND_NOTES : []), ...exact.flatMap((item) => item.examples?.length ? ["Examples:", ...item.examples.map((value) => ` ${value}`)] : [], ), "Options: --help, -h (local help); --json (structured command results).", ].join("\n"); }