diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index 22babbe..77823bb 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -30,6 +30,10 @@ import { serveCommand } from "../src/serve.mjs"; import { createDohServer, nginxDohSite, parseDohPort, parseGuardArgs, DEFAULT_DOH_PORT, DOH_PATH } from "../src/doh-server.mjs"; import { completionScript } from "../src/completion.mjs"; import { CORE_CLI_COMMAND_NAMES } from "../src/cli-schema.mjs"; +import { + findCommand, findVerb, helpModel, renderAll, renderCommand, renderOverview, + suggest, wantsHelp, withoutHelp, +} from "../src/help.mjs"; import { moshcodeVersion } from "../src/ui.mjs"; const HERE = path.dirname(fileURLToPath(import.meta.url)); @@ -126,113 +130,121 @@ async function launchEngine(key, engine, args, { agentMode = false } = {}) { return backToPit(key, result.code, result.signal); } -// `write` decides the stream. Help asked for is output; help printed because a -// verb was wrong is a diagnostic, and a diagnostic on stdout ends up inside -// whatever the caller was redirecting to — `moshcode doh --nginx x > site.conf` -// on a build without `doh` writes this banner into an nginx config, and nginx -// then fails to start for reasons four layers from the typo. -function help(write = console.log) { - const vocab = moshVocabulary().all(); - // Every workflow tool is exposed as a CLI verb, so derive them from TOOLS - // rather than repeating the roster here — a tool missing from this list gets - // misfiled as a moshscript-only local verb. - const cliVerbs = [...CORE_CLI_COMMAND_NAMES, ...Object.keys(TOOLS)]; - const local = vocab.filter((c) => !cliVerbs.includes(c.name)); - const cli = vocab.filter((c) => !local.includes(c)); - write(`moshcode — metal scripting toolkit 🤘 - -usage: - moshcode open the TUI shell (then /agents ) - moshcode agents [engine] [args…] list engines, or launch one autonomously - (bypasses/auto-approves native permissions) - moshcode start [args…] raw engine launch; inject no arguments - moshcode [args…] raw launch shorthand (backward compatible) - moshcode [args…] transparently invoke any workflow tool listed below - (ugig, coinpay, gh, railway, supabase, doppler, doctl, …) - moshcode secrets [args…] manage/view team secrets (wraps the logicsrc CLI: - login, teams, credentials — e.g. \`secrets teams pull acme prod\`) - moshcode run [file.mosh] [--max N] run a moshscript (stdin with '-', or the - [--dry-run] [args…] built-in loop if no file); --max bounds - the while loop (default 3); --dry-run - narrates without executing; extra args - reach the script as argv - moshcode mcp list [--json] show MCP support + install status - moshcode mcp install register an MCP server across every engine - moshcode mcp add that supports it (claude/gemini/codex/opencode) - moshcode skill list [--json] show skills support + install status - moshcode skill install install a skill across every engine that - supports it (claude/gemini) - moshcode doh [--port N] run the DNS-over-HTTPS resolver (loopback; - [--rate N] [--burst N] put TLS in front of it). Rate limits and - [--ban-seconds N] [--no-guards] bans are ON by default. - moshcode doh --nginx print the reverse-proxy block for it - moshcode site [--install] install web-server config for a Moshpit - [--reload] [--proxy PORT] name (nginx/Caddy does the serving, not - [--root DIR] moshcode); shows the plan by default - moshcode template list starting stacks for a Moshpit-hosted service - moshcode template install copy one here (also takes a git URL, - [--into dir] [--force] owner/repo, or a .tar.gz); nothing is run - [--dry-run] preview creates and overwrites without writing - moshcode install install a coding engine or workflow tool - moshcode uninstall take one back off this machine - moshcode update --check say whether a newer release exists - moshcode update --if-newer install only when there is one - moshcode update --timer [--install] check on a schedule (default 15min) - moshcode upgrade [target…] update moshcode + installed engines/tools - (no args = everything; name targets to - narrow, e.g. \`upgrade ugig\`) - moshcode prd [idea] publish the next numbered PRD (OpenPRD) to - prd/NNNN-slug.md and hand it to an engine to - author; no arg lists existing PRDs - moshcode prd list [--json] list PRDs without creating or changing files - moshcode login [--device|--browser] authenticate this machine with app.moshcode.sh - (device code over SSH/headless; --browser forces loopback) - (browser OAuth+PKCE; --device = headless/CI - code flow) so notify()/ask() reach you - moshcode whoami | logout show / clear the logged-in account - moshcode console serve serve a browser terminal on this box (ttyd - [--port N] [--ttyd host:port] behind moshcode login); --bind defaults to - [--bind addr] 127.0.0.1 — put it on a tailnet, not 0.0.0.0 - moshcode console --url print that gateway's URL with your login token - moshcode pwd [--json] show the current dir + git repo/branch/origin - moshcode engines [--json] list engines + install status - moshcode tools [--json] list workflow tools + install status - moshcode commands [--json] list built-in moshscript commands - moshcode completion - print a shell completion script - moshcode help this - -engines (moshcode is a wrapper — it installs/drives these): -${engineList()} - -warning: agent mode intentionally weakens native safety checks. use it only in -isolated or trusted workspaces. use \`moshcode start \` for native defaults. - -tools (native CLI passthrough; each tool owns its auth and output): -${toolList()} -the primary development toolchain is available through \`moshcode\` as a -dev.profullstack.com user — https://dev.profullstack.com/ - -moshscript — secretly all JS is legal: -${DEFAULT_SCRIPT} -a .mosh file is real JavaScript with the command vocabulary injected as globals. -const, for, if, await, template strings — all just work. shebang lines -(#!/usr/bin/env moshscript) are stripped automatically, so chmod +x works. - -local commands (moshscript-only): -${local.map((c) => ` ${(`${c.name}()`).padEnd(14)} ${c.summary}`).join("\n")} - -CLI commands (each shells out to \`moshcode ...args\`): -${cli.map((c) => ` ${(`${c.name}()`).padEnd(14)} ${c.summary}`).join("\n")} - -human-in-the-loop + AI (via app.moshcode.sh): - notify(msg) ping the operator across their channels + return the link - ask(prompt) blocking gate — waits for the human's reply at app.moshcode.sh - ai(prompt, {engine}) run a coding engine headlessly and return its output - -env: MOSHCODE_API (default https://app.moshcode.sh), MOSHCODE_API_KEY (from the - app's Settings → API keys), MOSHCODE_WEBHOOK_SECRET, MOSHCODE_PLAYLIST -`); +/** + * Help, on the right stream, from the schema (PRD 0006 R3, R5). + * + * \`write\` decides the stream and it is not cosmetic: help asked for is output, + * help printed because a verb was wrong is a diagnostic, and a diagnostic on + * stdout ends up inside whatever the caller was redirecting to — \`moshcode doh + * --nginx x > site.conf\` on a build without \`doh\` used to write this banner + * into an nginx config, and nginx then failed to start for reasons four layers + * from the typo. + * + * The 87-line template literal that used to live here is gone. It had already + * drifted — \`dns\` and \`version\` were dispatchable and missing from it — and + * every fix to it was a second place to remember. + */ +function helpContext() { + return { + engines: Object.keys(ENGINES), + tools: Object.keys(TOOLS), + version: moshcodeVersion() || "", + }; +} + +function help(write = console.log, { all = false } = {}) { + write(all ? renderAll(helpContext()) : renderOverview(helpContext())); +} + +/** + * Answer every form of "how does this work" before anything is parsed. + * + * Ahead of dispatch on purpose (PRD 0006 R1, R2). Asking for help used to be + * handled by each command's own argument parser, by accident and differently + * every time: `prd --help` read `--help` as the PRD's idea and *published a + * document*, `run --help` was an unknown-option error, `console --help` printed + * usage to stderr and exited 1. Recognising it here means no parser sees it and + * none of them can act on it. + * + * Returns true when it handled the invocation. + * + * Two boundaries it deliberately does not cross: + * - an engine or tool name. `moshcode gh --help` is gh's question and passes + * through byte-for-byte; `moshcode help gh` asks how moshcode wraps it. + * - the moshscript filename. `moshcode run --help` is the runner's; after the + * file, `--help` is the script's argv (PRD 0004 R13). + */ +function handledHelp(cmd, rest) { + const asJson = rest.includes("--json"); + const topLevel = ["help", "--help", "-h"].includes(cmd); + + if (topLevel) { + const args = withoutHelp(rest).filter((a) => a !== "--json" && a !== "--all"); + // `moshcode help --help` asks about `help` itself, and R4 says every + // command answers for itself. Only the spelled-out verb: `moshcode --help` + // is the overview, which is what a bare flag has always meant. + if (cmd === "help" && !args.length && wantsHelp(rest)) args.push("help"); + if (asJson && !args.length) { + console.log(JSON.stringify(helpModel(helpContext()), null, 2)); + return true; + } + if (!args.length) { + help(console.log, { all: rest.includes("--all") }); + return true; + } + // `moshcode help [verb]` + const command = findCommand(args[0]); + if (!command) { + // An engine or a tool is a legitimate topic: this is the only way to ask + // how moshcode wraps something whose own --help passes through. + if (resolveEngine(args[0])) { + console.log(`moshcode ${args[0]} [args…] — open ${args[0]} directly (passthrough).`); + console.log(`its own flags belong to it: \`moshcode ${args[0]} --help\` asks ${args[0]}, not moshcode.`); + console.log(`see also: moshcode help agents · moshcode help start`); + return true; + } + if (resolveTool(args[0])) { + console.log(`moshcode ${args[0]} [args…] — run ${args[0]}, with moshcode's auth and env (passthrough).`); + console.log(`its own flags belong to it: \`moshcode ${args[0]} --help\` asks ${args[0]}, not moshcode.`); + console.log(`see also: moshcode help tools · moshcode help install`); + return true; + } + const near = suggest(args[0], [...Object.keys(ENGINES), ...Object.keys(TOOLS)]); + console.error(`✗ no help for ${JSON.stringify(args[0])}${near ? ` — did you mean ${near}?` : ""}`); + console.error(" moshcode help list commands"); + process.exitCode = 1; + return true; + } + const verb = args[1] ? findVerb(command, args[1]) : null; + if (args[1] && !verb) { + console.error(`✗ ${command.name} has no verb ${JSON.stringify(args[1])}`); + console.error(renderCommand(command)); + process.exitCode = 1; + return true; + } + if (asJson) { + const model = helpModel(helpContext()).commands.find((c) => c.name === command.name); + console.log(JSON.stringify(verb ? model.verbs.find((v) => v.name === verb.name) : model, null, 2)); + return true; + } + console.log(renderCommand(command, { verb })); + return true; + } + + // `moshcode [verb] --help`, at any depth. + const command = findCommand(cmd); + if (!command) return false; + // `run` claims --help only before the script filename. + if (!wantsHelp(rest, { stopAt: command.name === "run" })) return false; + + const verb = findVerb(command, withoutHelp(rest)[0]); + if (asJson) { + const model = helpModel(helpContext()).commands.find((c) => c.name === command.name); + console.log(JSON.stringify(verb ? model.verbs.find((v) => v.name === verb.name) : model, null, 2)); + return true; + } + console.log(renderCommand(command, { verb })); + return true; } async function main() { @@ -241,6 +253,9 @@ async function main() { // No args → open the interactive TUI shell (/agents , etc.). if (cmd === undefined) return tui(); + // Before every parser, so none of them can act on --help. See handledHelp(). + if (handledHelp(cmd, rest)) return; + if (cmd === "--version" || cmd === "-v" || cmd === "version") { console.log(moshcodeVersion() || "unknown"); return; @@ -603,10 +618,13 @@ async function main() { return; } - const asked = !cmd || ["help", "--help", "-h"].includes(cmd); - if (!asked) console.error(`moshcode: unknown command ${JSON.stringify(cmd)}\n`); - help(asked ? console.log : console.error); - if (!asked) process.exit(1); + // Unknown. One line and a way forward, on stderr (PRD 0006 R3, R11) — this + // used to dump 127 lines to *stdout* and exit 1, which polluted every pipe + // that had a typo in it and buried the one fact the caller needed. + const near = suggest(cmd, [...Object.keys(ENGINES), ...Object.keys(TOOLS)]); + console.error(`✗ unknown command ${JSON.stringify(cmd)}${near ? ` — did you mean ${near}?` : ""}`); + console.error(" moshcode help list commands"); + process.exit(1); } main(); diff --git a/prd/0006-help.md b/prd/0006-help.md new file mode 100644 index 0000000..f22ccfb --- /dev/null +++ b/prd/0006-help.md @@ -0,0 +1,359 @@ +--- +openprd: "0.2" +id: "0006" +title: "--help" +status: Draft +authors: + - anthony@chovy.com +created: 2026-08-01 +updated: 2026-08-01 +repo: https://github.com/moshcoder/moshcode +discussion: +implementation: src/cli-schema.mjs, src/help.mjs, bin/moshcode.mjs, src/tui.mjs, src/integrations.mjs, src/console.mjs, src/dns.mjs, src/completion.mjs, test/help.test.mjs +tags: + - cli + - help + - dx + - discoverability +supersedes: +superseded-by: +--- + +## Problem + +`--help` is the first command anyone types and the last one we maintain. In +moshcode today it is a single 87-line template literal — `help()` in +`bin/moshcode.mjs:125-211` — hand-written prose that is neither derived from the +command table nor reachable from anywhere except the top level. Four things are +broken, and one of them writes to disk. + +**1. `--help` only works at `argv[2]`.** Every subcommand handles it by accident, +and each accident is different: + +| command | today | | +|---|---|---| +| `moshcode prd --help` | **creates `prd/0006---help.md`, commits it, and hands it to Claude to author** | side effect | +| `moshcode run --help` | `moshcode run: unknown option --help`, exit 1 | error | +| `moshcode mcp --help` | `✗ unknown mcp verb "--help" — try install, add, catalog, or list`, exit 1 | error | +| `moshcode skill --help` | `✗ unknown skill verb "--help"`, exit 1 | error | +| `moshcode completion --help` | `unsupported shell "--help"`, exit 1 | error | +| `moshcode console --help` | usage on **stderr**, exit 1 | wrong stream + code | +| `moshcode install --help` | usage on **stderr**, exit 0 | wrong stream | +| `moshcode commands --help` | silently ignores the flag and runs | ignored | +| `moshcode dns --help` | correct usage, stdout, exit 0 | the one that works | + +`moshcode prd --help` is the sharp edge: `--help` is parsed as the PRD *idea*, so +asking for help publishes a document. (It is how this PRD's own file got its +title.) + +**2. The help text has already drifted from the CLI.** `moshcode dns` and +`moshcode version` are dispatched in `bin/moshcode.mjs` and present in +`CORE_CLI_COMMANDS` (`src/cli-schema.mjs`) and in shell completion — and appear +nowhere in `moshcode help`. Neither do the aliases (`where`, `remove`, `update`, +`skills`, `agent`), nor `uninstall --yes` / `--dry-run`. `src/cli-schema.mjs` +already exists as the structured command table with descriptions, and +`src/completion.mjs` already derives bash/zsh/fish completion from it — help is +the one consumer that re-types everything by hand. + +**3. There is no help at any level below the top.** `moshcode help mcp` prints +the same 127-line wall as `moshcode help`. There is no synopsis, no flag list, +and no example for any individual command, so the flags that exist only in code +(`--json`, `--bind`, `--ttyd`, `--max`, `--dry-run`, `--device`, `--browser`, +`--yes`, `--`) are undiscoverable without reading the source. + +**4. Failure prints the wall.** `moshcode bogus` dumps all 127 lines — to +**stdout**, so it pollutes pipes — and exits 1, with no "did you mean install?". +28 of those lines exceed 80 columns and wrap badly in a narrow terminal. + +The pit has the same problem in a second copy: `printHelp()` in `src/tui.mjs` +hardcodes `/railway /gh /supabase /doppler /doctl /turso /tailscale` immediately +below a `printTools()` whose own comment explains why hardcoding the tool roster +goes stale. `/help` also omits `/logout` and `/uninstall`, and lists no `/dns` or +`/console` because the TUI never implemented them. + +This matters now because moshcode's surface just tripled — engines, eleven +workflow tools, MCP/skill fan-out ([[0003-cross-engine-mcp-and-skill-installation]]), +PRDs, `console`, `dns`, and the moshscript vocabulary +([[0004-moshscript-run-programmable-moshcode]]) — and because the primary +consumer of `--help` is now an *agent*. A coding engine driving moshcode reads +`--help` to learn the interface; it gets a wall on stdout, an exit 1, or a +committed PRD. + +## Goals + +- `--help` is safe, uniform, and total: the same flag works at every level, on + every command and subcommand, and never changes anything on disk or over the + network. +- A newcomer can discover any moshcode capability — including every flag — from + the terminal alone, without opening `README.md` or the source. +- Help text can no longer drift from the CLI, because it is rendered from the + same schema the dispatcher and shell completion already use. +- An agent can consume the interface mechanically (`--json`) as reliably as it + consumes `moshcode engines --json` and `moshcode commands --json`. +- Failure is short and actionable: a mistyped command yields one line of + correction and one usage block, not a screenful. +- The pit and the CLI describe the same product, from one source. + +## Non-Goals + +- A CLI framework. No commander/yargs/oclif; moshcode's hand-rolled dispatch and + zero-runtime-dependency posture stay ([[0004-moshscript-run-programmable-moshcode]]). +- Restructuring the command surface. This PRD documents what exists; renaming + verbs, changing flags, or adding TUI commands (`/dns`, `/console`) is separate + work. Where help exposes an omission, help notes it — it doesn't fix it. +- Localization / i18n. English, metal, one voice. +- `man` pages, HTML docs, or a docs site. `--markdown` output (R13) is a + build-time convenience, not a docs pipeline. +- Help for the *wrapped* tools. `moshcode gh --help` is and remains a byte-exact + passthrough to `gh --help`; moshcode never intercepts a tool's own help. +- Rewriting `README.md` prose. Its command listings become schema-checked; its + narrative stays hand-written. + +## Users + +- **The first-run human** who installs moshcode and types `moshcode --help`. + They need one screen, not 127 lines, and a way to drill in. +- **The returning user** who knows the verb and forgot the flag — "was it + `--bind` or `--host`?" — and wants `moshcode console --help` to answer in four + lines. +- **The coding engine** (Claude, Codex, Gemini, opencode, aider) that moshcode + itself launched, now shelling back into `moshcode --help` to learn what it can + drive. Structured output, correct exit codes, and stdout/stderr discipline are + the whole interface for this user. +- **The moshscript author** who needs the vocabulary and its call signatures — + today split across `moshcode commands`, the tail of `moshcode help`, and + `README.md`. +- **The contributor** adding an engine, a tool, or a verb, who must not have to + remember that the roster is written down in five places. + +## Requirements + +- R1 [P0] `--help`, `-h`, and `help` MUST be recognized *before* any dispatch or + argument parsing, at every level: `moshcode --help`, `moshcode --help`, + and `moshcode --help` (e.g. `mcp install --help`). Recognition + MUST NOT depend on flag position among that level's own arguments. +- R2 [P0] Help MUST be free of side effects. `moshcode prd --help` MUST print + usage and MUST NOT call `createPrd()`, write to `prd/`, git-commit, or spawn an + engine. A regression test MUST assert `prd/` is unchanged after `prd --help`, + `prd -h`, and `prd help`. Equivalent no-side-effect assertions MUST cover + `install`, `uninstall`, `upgrade`, `mcp`, `skill`, `console`, `dns`, and `run`. +- R3 [P0] Stream and exit-code discipline MUST be uniform: + - help asked for explicitly → **stdout**, exit **0**; + - help printed because of a usage error → **stderr**, exit **1**, and the + message MUST be the offending command's own usage block (target: ≤ 12 + lines), never the full top-level wall. + This fixes `console --help` (stderr/exit 1), `install --help` (stderr), and + `moshcode bogus` (127 lines to stdout). +- R4 [P0] Per-command help MUST exist for every dispatched command, reachable + both as `moshcode --help` and `moshcode help `. Each MUST render: + one-line description, synopsis with positional arguments, every flag it + actually parses with its default, at least one runnable example, and a + `see also` line. Sub-verbs (`mcp install`, `skill list`, `dns enable`, + `console serve`, `upgrade engines`) MUST each get their own. +- R5 [P0] Help MUST be rendered from a single structured schema, not prose. The + existing `CORE_CLI_COMMANDS` in `src/cli-schema.mjs` MUST be extended with + `synopsis`, `args`, `flags`, `examples`, `aliasOf`, and `seeAlso`, and a new + `src/help.mjs` MUST own rendering. The 87-line template literal at + `bin/moshcode.mjs:125-211` MUST be deleted, not merely supplemented. Engine and + tool rosters MUST continue to be derived from `ENGINES` / `TOOLS`, and the + moshscript vocabulary from `moshVocabulary()`, exactly as `help()` does today. +- R6 [P0] A drift test MUST make missing help a build failure, mirroring the + existing `completion schema covers every explicitly dispatched CLI command` + test (`test/completion.test.mjs:71`): every `cmd === "…"` literal in + `bin/moshcode.mjs`, every entry in `MCP_VERBS` / `SKILL_VERBS` / + `UPGRADE_TARGETS`, every key of `ENGINES` and `TOOLS`, and every registered + moshscript verb MUST have a schema entry with a non-empty description. +- R7 [P0] The commands missing from today's help MUST be covered by the schema + and therefore appear: `dns` (and its `enable`/`disable`/`tlds`/`resolve` + sub-verbs), `version`, and every alias — `where`→`pwd`, `remove`→`uninstall`, + `update`→`upgrade`, `skills`→`skill`, `agent`/`engines`→`agents`, `-v`/`-h`. + Aliases MUST be rendered as aliases, not duplicated as peers. +- R8 [P0] Currently undocumented flags MUST be documented by R4's flag lists, at + minimum: `--json` (`engines`, `tools`, `commands`, `mcp list`, `skill list`), + `run --max N|-n N` (default 3), `run --dry-run`, `run --` and `-` for stdin, + `uninstall --yes|-y` and `--dry-run`, `login --device|-d` and `--browser|-b`, + `console --port|--ttyd|--bind|--url`, `mcp --name|-t|-e|-H|--`, and + `dns --port|--registry`. +- R9 [P1] `moshcode help --json` and `moshcode --help --json` MUST emit the + help model as JSON on stdout, exit 0, with no decoration — the same contract + `engines --json`, `tools --json`, and `commands --json` already honor (and the + same reason `moshcode tools` suppresses its trailing note under `--json`). + This is the agent-facing interface. +- R10 [P1] Top-level `moshcode --help` MUST fit one screen: a target of ≤ 40 + lines covering usage, grouped command names with one-line descriptions, and a + pointer to `moshcode help `. Full output MUST remain available via + `moshcode help --all`. All rendered help MUST wrap at 80 columns (127 lines + today, 28 of them over 80). +- R11 [P1] An unrecognized command or sub-verb MUST print, to stderr with exit 1, + a one-line error, a nearest-match suggestion computed from the schema + (`moshcode instal` → `did you mean install?`), and a pointer to + `moshcode help` — instead of the current full-wall dump. The suggestion MUST + draw on the same command set completion uses, so the two never disagree. +- R12 [P1] The pit MUST render from the same schema. `printHelp()` in + `src/tui.mjs` MUST stop hardcoding tool names (`/ugig /coinpay /railway /gh + /supabase /doppler /doctl /turso /tailscale`) and derive them from `TOOLS` as + `printTools()` already does; `/help ` and `/ --help` MUST + work; and `/help` MUST list every dispatched pit command, including the + currently omitted `/logout` and `/uninstall`. Where a CLI command has no pit + equivalent (`dns`, `console`, `completion`), `/help` MUST say so rather than + imply it exists. +- R13 [P1] `README.md`'s command listings MUST be checked against the schema by a + test (or generated from it via `moshcode help --markdown`), so a new verb + cannot ship documented in one place and absent in the other. +- R14 [P2] moshscript vocabulary help: `moshcode help ` MUST resolve + moshscript verbs too (`moshcode help ai`, `help ask`, `help notify`), rendering + a call signature — `ai(prompt, { engine })` — from an optional `usage` field on + the registry command object (`src/registry.mjs`), alongside its existing + `summary`. Verbs without a `usage` fall back to the summary. +- R15 [P2] Rendering MUST respect the terminal: honor `NO_COLOR` and non-TTY + stdout by emitting plain text (the CLI's `help()` is already plain; the pit's + is not), and MUST NOT paginate or require a pager. +- R16 [P2] `moshcode completion` MUST offer help topics after `moshcode help `, + so tab-completion and `help` share the same discoverability surface. + +## UX Notes + +Top level — one screen, grouped, with a way in (R10): + +``` +moshcode — metal scripting toolkit 🤘 + +usage: moshcode [command] [args…] no command → open the mosh pit + + engines agents · start · install · uninstall · upgrade · engines + tools tools · ugig · coinpay · c0mpute · secrets · gh · railway · … + extend mcp · skill · commands · completion + script run · prd + account login · whoami · logout · console + system dns · pwd · version · help + + moshcode help drill into one (flags, examples) + moshcode help --all the whole wall + moshcode help --json the machine-readable model + +engines are installed and driven by moshcode — 🤘 no bugs, only features +``` + +Per-command help — the shape every command gets (R4), and the case that today +publishes a document instead: + +``` +$ moshcode prd --help +moshcode prd — publish the next numbered PRD (OpenPRD) and author it + +usage: + moshcode prd list existing PRDs + moshcode prd publish prd/NNNN-slug.md (status: Draft) and hand + it to an installed engine to author + +examples: + moshcode prd # the index + moshcode prd "a --help that works" # publish + author + +see also: moshcode help run · prd/README.md · https://github.com/profullstack/logicsrc/blob/master/docs/openprd.md +``` + +Failure — short, corrective, on stderr, exit 1 (R3, R11): + +``` +$ moshcode instal claude +✗ unknown command "instal" — did you mean install? + moshcode help list commands +$ echo $? +1 +``` + +Sub-verb help, reachable at depth (R1): + +``` +$ moshcode mcp install --help +moshcode mcp install — register an MCP server across every engine that supports it + +usage: + moshcode mcp install remote server (http/sse) + moshcode mcp install --name -- local stdio server + moshcode mcp install e.g. porkbun, sentry + +flags: + --name override the derived server name + -t, --transport http | sse | stdio (default: inferred from target) + -e, --env K=V repeatable + -H, --header 'K: V' repeatable + -- everything after this is the server's argv + +fans out to: claude, gemini, codex, opencode (moshcode mcp list) +``` + +Constraints that shape all of it: the voice stays irreverent-metal (`the pit`, +`no bugs only features`, 🤘) but never at the cost of scanability — emoji are +decoration on headers, never load-bearing in a synopsis. Help is the one command +guaranteed to run before anything is installed and before anyone has logged in, +so it MUST NOT touch the network, read credentials, or probe for binaries beyond +the local `engineStatus()` / `toolStatus()` checks help already makes. And +because `moshcode run --help` and `./script.mosh --help` are different questions +(the runner's help vs. the script's argv), the runner MUST claim `--help` only +before the script filename; after it, `--help` belongs to the script (`argv`, per +[[0004-moshscript-run-programmable-moshcode]] R13). + +## Success Metrics + +- **Zero side effects from help.** `prd --help` leaves `git status` clean; + asserted in tests (R2). This is the one that must be true on day one. +- **Total coverage, enforced.** The R6 drift test passes with an empty allowlist: + every dispatched command, sub-verb, alias, engine, tool, and moshscript verb + has help. Adding a command without help fails `pnpm test`. +- **Uniformity.** For all N dispatched commands, `moshcode --help` exits 0 + and writes to stdout — today 5 of 9 sampled commands exit 1, and 2 more write + to stderr. +- **Flag discoverability.** Every flag parsed anywhere in `bin/moshcode.mjs`, + `src/console.mjs`, `src/dns.mjs`, and `src/integrations.mjs` appears in some + help output; verified by a test that greps the parsers for flag literals and + looks each one up in the schema. +- **Fits the screen.** `moshcode --help` ≤ 40 lines and no line over 80 columns + (127 lines / 28 over-length today); `moshcode help --all` unbounded. +- **Agent-consumable.** `moshcode help --json` parses, and a round-trip test + confirms every command in the JSON is dispatchable and every dispatched + command is in the JSON. +- **One source.** `grep -c "usage:" bin/moshcode.mjs src/tui.mjs` trends to 0 as + the hand-written blocks move into `src/cli-schema.mjs`. +- **README can't drift.** R13's test passes against the committed `README.md`. + +## Risks & Open Questions + +- **Losing the wall people grep.** `moshcode help | grep console` works today + precisely *because* everything is on one screen dump. Collapsing to a one-page + overview (R10) breaks that muscle memory. Mitigation: `moshcode help --all` + keeps the full text, and `--json` is the better grep target. Open: should + `--all` be the default when stdout is not a TTY, so pipes keep seeing + everything? +- **Schema verbosity.** Turning `CORE_CLI_COMMANDS` into full help entries makes + `src/cli-schema.mjs` the largest file describing the smallest amount of + behavior, and every new flag becomes a two-place edit (parser + schema). + R6/R8's tests are what make the second place non-optional — but a test that + greps parsers for flag literals is itself brittle. Open: is a stricter seam + worth it, where the schema *is* the parser (each command declares its flags and + the dispatcher parses from that), or does that quietly become the CLI framework + this PRD calls a Non-Goal? +- **`moshcode --help` ambiguity.** `moshcode gh --help` must reach `gh`, + not moshcode (Non-Goals) — but then there is no way to ask moshcode how *it* + wraps `gh`. Proposal: `moshcode help gh` answers the wrapper question and + `moshcode gh --help` passes through. Needs a decision, and it is the single + most likely source of user confusion in this design. +- **Same ambiguity for engines.** `moshcode claude --help` is a raw launch + shorthand that will pass `--help` to Claude Code. Same proposed split, same + risk. +- **`moshcode help` with an unknown topic.** Exit 0 with the overview, or exit 1 + with a suggestion? R11 implies the latter for commands; `help` itself asking a + question is arguably not an error. Undecided. +- **The pit's missing commands.** R12 exposes that `/dns` and `/console` don't + exist in the TUI. Saying so in `/help` is honest but reads as a to-do list in + the product. Do we ship the disclosure, or implement the two commands first + (out of scope here)? +- **`-h` collisions.** Some wrapped CLIs use `-h` for `--host`. Since moshcode + claims `-h` only before the tool/engine name (R1), this should be safe — but + it needs a test per tool, and `console --ttyd host:port` is a reminder that we + have host-shaped flags of our own. +- **Who owns `--help` inside `moshcode run`?** Stated as a constraint in UX + Notes, but a shebang script (`./deploy.mosh --help`) currently gets `--help` in + `argv` with no convention for what to do with it. Should moshscript ship a + `usage()` verb so scripts can answer for themselves? diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 32dae60..af326af 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -1,38 +1,332 @@ +// The command table, and everything help needs to describe it. +// +// One table, three consumers: the dispatcher in bin/moshcode.mjs, shell +// completion (src/completion.mjs), and help (src/help.mjs). Help used to be an +// 87-line template literal that re-typed all of this by hand, which is why +// `dns` and `version` were dispatchable, completable, and absent from `moshcode +// help` at the same time (PRD 0006). +// +// `description` stays the one-line summary completion already renders. The rest +// — synopsis, flags, examples — exists so per-command help can be rendered +// rather than written. A flag that is parsed but not listed here is a flag +// nobody can discover, so test/help.test.mjs greps the parsers and fails when +// the two disagree. + +/** + * Groups for the one-screen overview. + * + * The top-level help is a menu, not an index: 30-odd verbs in one flat list is + * the wall this replaces. Order is the order they appear. + */ +export const COMMAND_GROUPS = [ + { key: "engines", title: "engines" }, + { key: "tools", title: "tools" }, + { key: "extend", title: "extend" }, + { key: "script", title: "script" }, + { key: "account", title: "account" }, + { key: "hosting", title: "hosting" }, + { key: "system", title: "system" }, +]; + export const CORE_CLI_COMMANDS = [ - { name: "agents", description: "list engines or launch one autonomously" }, - { name: "start", description: "launch an engine with its native defaults" }, - { name: "install", description: "install an engine or workflow tool" }, - { name: "uninstall", description: "take an engine or workflow tool off this machine" }, - { name: "remove", description: "alias for uninstall" }, - { name: "upgrade", description: "update moshcode, engines, or tools" }, - { name: "update", description: "alias for upgrade" }, - { name: "mcp", description: "register and inspect MCP servers" }, - { name: "skill", description: "install and inspect agent skills" }, - { name: "skills", description: "alias for skill" }, - { name: "prd", description: "create or list product requirement documents" }, - { name: "login", description: "authenticate with app.moshcode.sh" }, - { name: "whoami", description: "show the logged-in account" }, - { name: "logout", description: "clear the logged-in account" }, - { name: "console", description: "serve or connect to the browser terminal" }, - { name: "dns", description: "manage DNS records" }, - { name: "doh", description: "run the DNS-over-HTTPS resolver" }, - { name: "site", description: "install web-server config for a Moshpit name" }, - { name: "serve", description: "alias for site" }, - { name: "template", description: "scaffold a stack for a Moshpit-hosted service" }, - { name: "templates", description: "alias for template" }, - { name: "pwd", description: "show the current directory and git context" }, - { name: "where", description: "alias for pwd" }, - { name: "engines", description: "list engines and installation status" }, - { name: "tools", description: "list workflow tools and installation status" }, - { name: "commands", description: "list built-in moshscript commands" }, - { name: "completion", description: "print a shell completion script" }, - { name: "run", description: "run a moshscript" }, - { name: "help", description: "show command help" }, - { name: "--help", description: "show command help" }, - { name: "-h", description: "show command help" }, - { name: "version", description: "show the installed version" }, - { name: "--version", description: "show the installed version" }, - { name: "-v", description: "show the installed version" }, + { + name: "agents", + group: "engines", + description: "list engines or launch one autonomously", + synopsis: [ + ["moshcode agents", "list engines and their install status"], + ["moshcode agents [args…]", "open the engine's agent view, or start it autonomously"], + ], + examples: [ + ["moshcode agents", "which engines are here"], + ["moshcode agents claude", "claude's agent list"], + ], + seeAlso: ["start", "engines", "install"], + note: "autonomous modes bypass approval prompts — use them in a container or a workspace you trust.", + }, + { + name: "start", + group: "engines", + description: "launch an engine with its native defaults", + synopsis: [["moshcode start [args…]", "no bypass flags, no agent view"]], + examples: [["moshcode start opencode", ""]], + seeAlso: ["agents", "engines"], + }, + { + name: "install", + group: "engines", + description: "install an engine or workflow tool", + synopsis: [["moshcode install ", ""]], + examples: [ + ["moshcode install claude", "an engine"], + ["moshcode install gh", "a workflow tool"], + ], + seeAlso: ["uninstall", "upgrade", "engines", "tools"], + }, + { + name: "uninstall", + group: "engines", + description: "take an engine or workflow tool off this machine", + synopsis: [["moshcode uninstall [--yes] [--dry-run]", ""]], + flags: [ + ["-y, --yes", "actually delete a binary this did not install", ""], + ["--dry-run", "print the plan and stop", ""], + ], + examples: [ + ["moshcode uninstall codex --dry-run", "what would happen"], + ["moshcode uninstall codex --yes", "do it"], + ], + seeAlso: ["install"], + note: "removing a plain binary needs --yes; a package-manager uninstall does not.", + }, + { name: "remove", aliasOf: "uninstall", description: "alias for uninstall" }, + { + name: "upgrade", + group: "engines", + description: "update moshcode, engines, or tools", + synopsis: [ + ["moshcode upgrade [target…]", "default: everything installed"], + ["moshcode upgrade --check", "report what is stale and exit"], + ], + flags: [ + ["--check", "report available updates without installing", ""], + ["--if-newer", "install only when the remote is newer", ""], + ["--timer ", "install a scheduled self-update", ""], + ], + verbs: "UPGRADE_TARGETS", + examples: [ + ["moshcode upgrade", "moshcode + engines + tools"], + ["moshcode upgrade engines", "just the engines"], + ], + seeAlso: ["install", "version"], + }, + { name: "update", aliasOf: "upgrade", description: "alias for upgrade" }, + { + name: "mcp", + group: "extend", + description: "register and inspect MCP servers", + synopsis: [["moshcode mcp [args…]", ""]], + verbs: "MCP_VERBS", + examples: [ + ["moshcode mcp list --json", "support and install status"], + ["moshcode mcp install https://mcp.example.com", "a remote server"], + ], + seeAlso: ["skill", "engines"], + }, + { + name: "skill", + group: "extend", + description: "install and inspect agent skills", + synopsis: [["moshcode skill [args…]", ""]], + verbs: "SKILL_VERBS", + examples: [["moshcode skill list", ""]], + seeAlso: ["mcp"], + }, + { name: "skills", aliasOf: "skill", description: "alias for skill" }, + { + name: "prd", + group: "script", + description: "publish or list product requirement documents", + synopsis: [ + ["moshcode prd", "list the PRDs in this repo"], + ["moshcode prd list [--json]", "the same, explicitly"], + ["moshcode prd ", "publish prd/NNNN-slug.md (Draft) and hand it to an engine"], + ], + flags: [["--json", "machine-readable listing", ""]], + examples: [ + ["moshcode prd", "the index"], + ['moshcode prd "a --help that works"', "publish + author"], + ], + seeAlso: ["run", "commands"], + note: "publishing writes a file and commits it. `moshcode prd --help` does neither.", + }, + { + name: "login", + group: "account", + description: "authenticate with app.moshcode.sh", + synopsis: [["moshcode login [--device] [--browser]", ""]], + flags: [ + ["-d, --device", "device-code flow", "default when stdin is not a TTY"], + ["-b, --browser", "force the browser flow", ""], + ], + examples: [["moshcode login --device", "on a headless box"]], + seeAlso: ["whoami", "logout", "console"], + }, + { + name: "whoami", + group: "account", + description: "show the logged-in account", + synopsis: [["moshcode whoami", ""]], + seeAlso: ["login", "logout"], + }, + { + name: "logout", + group: "account", + description: "clear the logged-in account", + synopsis: [["moshcode logout", ""]], + seeAlso: ["login"], + }, + { + name: "console", + group: "account", + description: "serve or connect to the browser terminal", + synopsis: [["moshcode console [--port N] [--bind addr] [--ttyd host:port] [--url u]", ""]], + flags: [ + ["--port ", "port to serve on", ""], + ["--bind ", "interface to bind", ""], + ["--ttyd ", "front an existing ttyd", ""], + ["--url ", "connect to a console already running", ""], + ], + examples: [["moshcode console --port 7681", ""]], + seeAlso: ["login"], + }, + { + name: "dns", + group: "hosting", + description: "resolve Moshpit names on this machine", + synopsis: [["moshcode dns [args…]", ""]], + verbs: "DNS_VERBS", + flags: [ + ["--port ", "port for the bridge", "5354"], + ["--registry ", "registry to resolve against", "https://pit.moshcode.sh"], + ], + examples: [ + ["sudo moshcode dns enable", "route Moshpit endings here"], + ["moshcode dns resolve blue.eggs", "what a machine actually gets"], + ], + seeAlso: ["doh", "site"], + }, + { + name: "doh", + group: "hosting", + description: "run the DNS-over-HTTPS resolver", + synopsis: [ + ["moshcode doh [--port N]", "serve DoH"], + ["moshcode doh --nginx [--tls]", "print an nginx site and exit"], + ], + flags: [ + ["--port ", "port to listen on", ""], + ["--nginx ", "emit an nginx server block instead of serving", ""], + ["--tls", "include TLS directives in the emitted block", ""], + ["--no-guards", "disable rate limiting and bans", "guards on"], + ], + examples: [["moshcode doh --nginx dns.example", ""]], + seeAlso: ["dns"], + note: "has no TLS of its own and trusts X-Forwarded-For — never expose it directly.", + }, + { + name: "site", + group: "hosting", + description: "install web-server config for a Moshpit name", + synopsis: [["moshcode site [args…]", ""]], + examples: [["moshcode site blue.eggs", ""]], + seeAlso: ["template", "dns"], + }, + { name: "serve", aliasOf: "site", description: "alias for site" }, + { + name: "template", + group: "hosting", + description: "scaffold a stack for a Moshpit-hosted service", + synopsis: [ + ["moshcode template list", "what there is"], + ["moshcode template install ", "write it here"], + ], + examples: [["moshcode template install bun-caddy-sqlite", ""]], + seeAlso: ["site"], + }, + { name: "templates", aliasOf: "template", description: "alias for template" }, + { + name: "pwd", + group: "system", + description: "show the current directory and git context", + synopsis: [["moshcode pwd [--json]", ""]], + flags: [["--json", "machine-readable", ""]], + seeAlso: ["commands"], + }, + { name: "where", aliasOf: "pwd", description: "alias for pwd" }, + { + name: "engines", + group: "engines", + description: "list engines and installation status", + synopsis: [["moshcode engines [--json]", ""]], + flags: [["--json", "machine-readable", ""]], + seeAlso: ["agents", "install"], + }, + { + name: "tools", + group: "tools", + description: "list workflow tools and installation status", + synopsis: [["moshcode tools [--json]", ""]], + flags: [["--json", "machine-readable, and suppresses the trailing note", ""]], + seeAlso: ["install"], + }, + { + name: "commands", + group: "script", + description: "list built-in moshscript commands", + synopsis: [["moshcode commands [--json]", ""]], + flags: [["--json", "machine-readable", ""]], + seeAlso: ["run", "prd"], + }, + { + name: "completion", + group: "extend", + description: "print a shell completion script", + synopsis: [["moshcode completion ", ""]], + examples: [["moshcode completion zsh > ~/.moshcode-completion.zsh", ""]], + seeAlso: ["help"], + }, + { + name: "run", + group: "script", + description: "run a moshscript", + synopsis: [ + ["moshcode run [flags] [file] [args…]", "no file → the bundled example"], + ["moshcode run -", "read the script from stdin"], + ], + flags: [ + ["-n, --max ", "loop ceiling", "3"], + ["--dry-run", "parse and report without side effects", ""], + ["--", "everything after this reaches the script as argv", ""], + ], + examples: [ + ["moshcode run deploy.mosh", ""], + ["moshcode run deploy.mosh -- --verbose", "--verbose goes to the script"], + ], + seeAlso: ["commands", "prd"], + note: "--help before the file is the runner's; after it, it belongs to the script.", + }, + { + name: "help", + group: "system", + description: "show command help", + synopsis: [ + ["moshcode help", "the one-screen overview"], + ["moshcode help [verb]", "drill into one"], + ["moshcode help --all", "every command, in full"], + ["moshcode help --json", "the machine-readable model"], + ], + flags: [ + ["--all", "render every command instead of the overview", ""], + ["--json", "emit the help model as JSON", ""], + ], + examples: [ + ["moshcode help mcp install", "a sub-verb"], + ["moshcode help --json | jq '.commands[].name'", "for an agent"], + ], + seeAlso: ["commands", "completion"], + }, + { name: "--help", aliasOf: "help", description: "show command help" }, + { name: "-h", aliasOf: "help", description: "show command help" }, + { + name: "version", + group: "system", + description: "show the installed version", + synopsis: [["moshcode version", ""]], + seeAlso: ["upgrade"], + }, + { name: "--version", aliasOf: "version", description: "show the installed version" }, + { name: "-v", aliasOf: "version", description: "show the installed version" }, ]; export const CORE_CLI_COMMAND_NAMES = CORE_CLI_COMMANDS.map(({ name }) => name); @@ -42,14 +336,32 @@ export const MCP_VERBS = [ name: "install", description: "register an MCP server across engines", acceptsServerSpec: true, + synopsis: [ + ["moshcode mcp install ", "remote server (http/sse)"], + ["moshcode mcp install --name -- ", "local stdio server"], + ["moshcode mcp install ", "e.g. porkbun, sentry"], + ], + flags: [ + ["--name ", "override the derived server name", ""], + ["-t, --transport ", "http | sse | stdio", "inferred from the target"], + ["-e, --env K=V", "repeatable", ""], + ["-H, --header 'K: V'", "repeatable", ""], + ["--", "everything after this is the server's argv", ""], + ], }, { name: "add", description: "register a named MCP server", acceptsServerSpec: true, + synopsis: [["moshcode mcp add --name ", ""]], + }, + { name: "catalog", description: "show known MCP servers", synopsis: [["moshcode mcp catalog", ""]] }, + { + name: "list", + description: "show MCP support and install status", + synopsis: [["moshcode mcp list [--json]", ""]], + flags: [["--json", "machine-readable", ""]], }, - { name: "catalog", description: "show known MCP servers" }, - { name: "list", description: "show MCP support and install status" }, ]; export const SKILL_VERBS = [ @@ -57,8 +369,14 @@ export const SKILL_VERBS = [ name: "install", description: "install a skill across supported engines", acceptsSource: true, + synopsis: [["moshcode skill install ", ""]], + }, + { + name: "list", + description: "show skills support and install status", + synopsis: [["moshcode skill list [--json]", ""]], + flags: [["--json", "machine-readable", ""]], }, - { name: "list", description: "show skills support and install status" }, ]; export const UPGRADE_TARGETS = [ @@ -68,3 +386,30 @@ export const UPGRADE_TARGETS = [ { name: "engines", description: "update all installed engines" }, { name: "tools", description: "update all installed workflow tools" }, ]; + +/** + * `dns` sub-verbs. + * + * New here rather than in completion, which never had them: the bridge grew + * these verbs after the completion table was written, so they were dispatchable + * and invisible to both help and the shell. + */ +export const DNS_VERBS = [ + { name: "enable", description: "route Moshpit endings to the local bridge (needs sudo)" }, + { name: "disable", description: "undo enable" }, + { name: "status", description: "what is running, what is routed, does it work" }, + { name: "refresh", description: "re-apply routing for endings claimed since" }, + { name: "start", description: "run the bridge in the foreground" }, + { name: "install", description: "print the resolver config without applying it" }, + { name: "service", description: "install or remove the background service" }, + { name: "tlds", description: "list the endings claimed in the Pit" }, + { name: "resolve", description: "what a name resolves to, and why" }, +]; + +/** Sub-verb tables, by the name a command's `verbs` field refers to. */ +export const VERB_TABLES = { + MCP_VERBS, + SKILL_VERBS, + UPGRADE_TARGETS, + DNS_VERBS, +}; diff --git a/src/help.mjs b/src/help.mjs new file mode 100644 index 0000000..803c05f --- /dev/null +++ b/src/help.mjs @@ -0,0 +1,313 @@ +// Help, rendered from the command table rather than written out. +// +// This replaces an 87-line template literal in bin/moshcode.mjs that had +// already drifted: `dns` and `version` were dispatchable and absent from it, +// aliases were missing, and half the flags existed only in the parsers. Nothing +// here knows a command name — it all comes from src/cli-schema.mjs, so a verb +// that is added to the table is documented by construction (PRD 0006 R5). +// +// Pure and side-effect free by design. Help is the one command guaranteed to +// run before anything is installed and before anyone has logged in, so it must +// not touch the network, read credentials, or write to disk (R2). The only +// impurity is the engine/tool roster, which the caller passes in. + +import { + CORE_CLI_COMMANDS, + COMMAND_GROUPS, + VERB_TABLES, +} from "./cli-schema.mjs"; + +/** Every rendered line wraps here. 28 lines of the old help ran past it. */ +export const WIDTH = 80; + +/** What asks for help, at any level. */ +export const HELP_TOKENS = ["--help", "-h", "help"]; + +/** + * Is this argument list asking for help? + * + * Position-independent within its own level (R1), which is the whole fix: + * `moshcode mcp install --help` and `moshcode mcp --help install` both ask the + * same question, and today the first one is a usage error. + * + * `stopAt` is for `run`, where the boundary matters: `moshcode run --help` is + * the runner's help, and `moshcode run script.mosh --help` is the script's + * argument (PRD 0004 R13). Everything at or after the first non-flag token is + * somebody else's to interpret. + */ +export function wantsHelp(args = [], { stopAt = false } = {}) { + for (const arg of args) { + if (stopAt && !String(arg).startsWith("-")) return false; + if (HELP_TOKENS.includes(arg)) return true; + } + return false; +} + +/** Arguments with the help tokens removed, so the rest can still be read. */ +export function withoutHelp(args = []) { + return args.filter((a) => !HELP_TOKENS.includes(a)); +} + +const isAlias = (c) => Boolean(c.aliasOf); + +/** Commands that are not aliases — the ones with something to say. */ +export const primaryCommands = () => CORE_CLI_COMMANDS.filter((c) => !isAlias(c) && c.group); + +/** The aliases pointing at a command, as bare names. */ +export const aliasesFor = (name) => + CORE_CLI_COMMANDS.filter((c) => c.aliasOf === name).map((c) => c.name); + +/** + * Resolve what the user typed to the entry that documents it. + * + * Aliases resolve to their target rather than rendering twice (R7): `moshcode + * help where` is a question about `pwd`, and answering it with a stub that says + * "alias for pwd" and nothing else would be true and useless. + */ +export function findCommand(name) { + const wanted = String(name ?? "").toLowerCase(); + const hit = CORE_CLI_COMMANDS.find((c) => c.name === wanted); + if (!hit) return null; + return hit.aliasOf ? CORE_CLI_COMMANDS.find((c) => c.name === hit.aliasOf) ?? null : hit; +} + +/** The sub-verb table a command declares, or an empty list. */ +export function verbsFor(command) { + return (command?.verbs && VERB_TABLES[command.verbs]) || []; +} + +export function findVerb(command, name) { + const wanted = String(name ?? "").toLowerCase(); + return verbsFor(command).find((v) => v.name === wanted) ?? null; +} + +/* ------------------------------------------------------------------ suggest */ + +/** Levenshtein, iterative, two rows. Small inputs; this is a typo check. */ +function distance(a, b) { + if (a === b) return 0; + let prev = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 0; i < a.length; i++) { + const row = [i + 1]; + for (let j = 0; j < b.length; j++) { + row[j + 1] = Math.min( + prev[j + 1] + 1, + row[j] + 1, + prev[j] + (a[i] === b[j] ? 0 : 1), + ); + } + prev = row; + } + return prev[b.length]; +} + +/** + * The nearest command to something that is not one, or null. + * + * Drawn from the same set completion offers (R11), so the two can never + * disagree about what exists. The threshold is deliberately tight: suggesting + * `run` for `xyzzy` is noise, and a wrong suggestion is worse than none because + * it sends people to try a second wrong command. + */ +export function suggest(input, extra = []) { + const typed = String(input ?? "").toLowerCase(); + if (!typed) return null; + const candidates = [...CORE_CLI_COMMANDS.map((c) => c.name), ...extra] + .filter((n) => !n.startsWith("-")); + + let best = null; + let bestScore = Infinity; + for (const name of candidates) { + const score = distance(typed, name); + if (score < bestScore) { best = name; bestScore = score; } + } + // Allow one edit for short words, two for longer ones; never more. + const ceiling = typed.length <= 4 ? 1 : 2; + return bestScore <= ceiling ? best : null; +} + +/* ------------------------------------------------------------------- render */ + +/** Wrap `text` to WIDTH, indenting continuations by `indent` spaces. */ +export function wrap(text, indent = 0, width = WIDTH) { + // Every line — the first one included — is placed after `indent` columns: + // the first continues an already-printed label, the rest are padded to sit + // under it. So they all get the same budget, and the total never exceeds + // `width`. Getting this wrong is how a "wraps at 80" helper emits 92. + const budget = Math.max(20, width - indent); + const pad = " ".repeat(indent); + const lines = []; + let line = ""; + for (const word of String(text).split(/\s+/).filter(Boolean)) { + const candidate = line ? `${line} ${word}` : word; + if (candidate.length > budget && line) { + lines.push(line); + line = word; + } else line = candidate; + } + if (line) lines.push(line); + return lines.map((l, i) => (i ? pad + l : l)).join("\n"); +} + +/** ` name description`, wrapped, for a two-column list. */ +function row(left, right, pad = 22) { + const head = ` ${left.padEnd(pad)}`; + if (!right) return head.trimEnd(); + const wrapped = wrap(right, head.length, WIDTH); + return `${head}${wrapped}`; +} + +/** + * The top-level overview: one screen, grouped, with a way in (R10). + * + * A menu rather than an index. The old help printed every command, every + * engine, every tool and the whole moshscript vocabulary in 127 lines — which + * is why nobody read it and why `moshcode help | grep` became the interface. + * `--all` keeps that available for the people who grep. + */ +export function renderOverview({ engines = [], tools = [], version = "" } = {}) { + const out = []; + out.push(`moshcode${version ? ` ${version}` : ""} — a metal wrapper for coding engines 🤘`); + out.push(""); + out.push("usage: moshcode [command] [args…] no command → open the mosh pit"); + out.push(""); + + for (const group of COMMAND_GROUPS) { + const members = primaryCommands().filter((c) => c.group === group.key); + if (!members.length) continue; + const names = members.map((c) => c.name).join(" · "); + out.push(row(group.title, names, 10)); + } + + out.push(""); + if (engines.length) out.push(row("engines", engines.join(" · "), 10)); + if (tools.length) out.push(row("tools", tools.join(" · "), 10)); + out.push(""); + out.push(row("moshcode help ", "drill into one (flags, examples)", 26)); + out.push(row("moshcode help --all", "the whole wall", 26)); + out.push(row("moshcode help --json", "the machine-readable model", 26)); + out.push(""); + out.push("engines are installed and driven by moshcode — 🤘 no bugs, only features"); + return out.join("\n"); +} + +/** One command, in full: synopsis, flags, sub-verbs, examples, see also (R4). */ +export function renderCommand(command, { verb = null } = {}) { + if (!command) return ""; + const target = verb || command; + const title = verb ? `moshcode ${command.name} ${verb.name}` : `moshcode ${command.name}`; + const out = [`${title} — ${target.description}`]; + + const synopsis = target.synopsis || []; + if (synopsis.length) { + out.push("", "usage:"); + for (const [line, note] of synopsis) out.push(row(line, note, 44)); + } + + const flags = target.flags || []; + if (flags.length) { + out.push("", "flags:"); + for (const [flag, description, fallback] of flags) { + out.push(row(flag, `${description}${fallback ? ` (default: ${fallback})` : ""}`, 22)); + } + } + + if (!verb) { + const verbs = verbsFor(command); + if (verbs.length) { + out.push("", "verbs:"); + for (const v of verbs) out.push(row(v.name, v.description, 22)); + out.push("", `moshcode help ${command.name} for one of them`); + } + } + + const examples = target.examples || []; + if (examples.length) { + out.push("", "examples:"); + for (const [cmd, note] of examples) out.push(row(cmd, note ? `# ${note}` : "", 44)); + } + + if (!verb) { + const aliases = aliasesFor(command.name); + if (aliases.length) out.push("", `aliases: ${aliases.join(", ")}`); + } + + if (target.note) out.push("", wrap(target.note, 0)); + + const seeAlso = target.seeAlso || command.seeAlso || []; + if (seeAlso.length) { + out.push("", `see also: ${seeAlso.map((s) => `moshcode help ${s}`).join(" · ")}`); + } + return out.join("\n"); +} + +/** Every command, in full — what `--all` and the old wall give you (R10). */ +export function renderAll(context = {}) { + const blocks = [renderOverview(context), ""]; + for (const command of primaryCommands()) { + blocks.push("─".repeat(WIDTH - 20), renderCommand(command), ""); + } + return blocks.join("\n"); +} + +/** + * The help model, for an agent (R9). + * + * The same shape `engines --json` and `commands --json` already honor: data on + * stdout, no decoration, exit 0. This is the interface for the consumer that + * cannot read a terminal layout — a coding engine that moshcode itself + * launched, shelling back in to learn what it can drive. + */ +export function helpModel({ engines = [], tools = [], version = "" } = {}) { + return { + name: "moshcode", + version: version || null, + usage: "moshcode [command] [args…]", + engines, + tools, + groups: COMMAND_GROUPS.map(({ key, title }) => ({ + name: key, + title, + commands: primaryCommands().filter((c) => c.group === key).map((c) => c.name), + })), + commands: primaryCommands().map((command) => ({ + name: command.name, + group: command.group, + description: command.description, + aliases: aliasesFor(command.name), + synopsis: (command.synopsis || []).map(([line, note]) => ({ usage: line, note: note || null })), + flags: (command.flags || []).map(([flags, description, fallback]) => ({ + flags, + description, + default: fallback || null, + })), + examples: (command.examples || []).map(([cmd, note]) => ({ command: cmd, note: note || null })), + verbs: verbsFor(command).map((v) => ({ + name: v.name, + description: v.description, + synopsis: (v.synopsis || []).map(([line, note]) => ({ usage: line, note: note || null })), + flags: (v.flags || []).map(([flags, description, fallback]) => ({ + flags, + description, + default: fallback || null, + })), + })), + seeAlso: command.seeAlso || [], + note: command.note || null, + })), + }; +} + +/** + * The block printed when a command was used wrongly (R3). + * + * The command's own usage, never the top-level wall — a mistyped flag on + * `console` is not a reason to print 127 lines about engines. Callers send this + * to stderr and exit 1; `renderCommand` on stdout is the same text for the + * person who asked politely. + */ +export function usageBlock(name, verb = null) { + const command = findCommand(name); + if (!command) return ""; + return renderCommand(command, { verb: verb ? findVerb(command, verb) : null }); +} diff --git a/test/help-stream.test.mjs b/test/help-stream.test.mjs index f9fde76..e69a269 100644 --- a/test/help-stream.test.mjs +++ b/test/help-stream.test.mjs @@ -27,7 +27,12 @@ test("an unknown verb writes nothing to stdout and exits non-zero", async () => assert.notEqual(failed.code, 0, "an unknown verb must not exit 0"); assert.equal(failed.stdout, "", "stdout must stay clean so redirection is safe"); assert.match(failed.stderr, /unknown command/, "the reason belongs on stderr"); - assert.match(failed.stderr, /usage:/, "the banner still gets shown, just not on stdout"); + // Was: the full banner on stderr. PRD 0006 R11 replaced the 127-line dump + // with one line and a way forward — the pointer is what has to survive, not + // the wall. `moshcode help` is now where the banner lives. + assert.match(failed.stderr, /moshcode help/, "the way forward still gets shown"); + assert.ok(failed.stderr.split("\n").filter(Boolean).length <= 3, + "a typo should not print a screenful"); }); test("help asked for goes to stdout", async () => { diff --git a/test/help.test.mjs b/test/help.test.mjs new file mode 100644 index 0000000..bf57add --- /dev/null +++ b/test/help.test.mjs @@ -0,0 +1,283 @@ +// `--help` at every level, and the promise that it changes nothing. +// +// The failure this file exists to prevent is not a typo in a usage string. It +// is `moshcode prd --help` *publishing a document* — `--help` was read as the +// PRD's idea, so asking how the command worked ran it. That is why the +// no-side-effect tests spawn the real binary against a real temp directory +// rather than importing and asserting on a return value: the bug was in +// dispatch, and dispatch is what has to be exercised. +// +// The rest is the uniformity the PRD asks for (0006): one stream, one exit +// code, one shape, at every depth. +import assert from "node:assert/strict"; +import test from "node:test"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; + +import { + CORE_CLI_COMMANDS, MCP_VERBS, SKILL_VERBS, UPGRADE_TARGETS, DNS_VERBS, +} from "../src/cli-schema.mjs"; +import { + WIDTH, findCommand, helpModel, renderCommand, renderOverview, suggest, wantsHelp, wrap, +} from "../src/help.mjs"; + +const run = promisify(execFile); +const BIN = fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url)); +const ROOT = fileURLToPath(new URL("..", import.meta.url)); + +/** Run the real CLI and hand back both streams and the code, never throwing. */ +async function cli(args, options = {}) { + try { + const { stdout, stderr } = await run(process.execPath, [BIN, ...args], { cwd: ROOT, ...options }); + return { code: 0, stdout, stderr }; + } catch (e) { + return { code: e.code ?? 1, stdout: e.stdout ?? "", stderr: e.stderr ?? "" }; + } +} + +/* ------------------------------------------------------------------ R1: total */ + +test("help is recognised at the top level, in every spelling", async () => { + for (const flag of ["--help", "-h", "help"]) { + const { code, stdout, stderr } = await cli([flag]); + assert.equal(code, 0, `${flag} exited ${code}`); + assert.equal(stderr, "", `${flag} wrote to stderr`); + assert.match(stdout, /usage: moshcode/, flag); + } +}); + +test("every dispatched command answers --help on stdout with exit 0", async () => { + // The uniformity claim. Before this, 5 of 9 sampled commands exited 1 and two + // more wrote to stderr. + const names = CORE_CLI_COMMANDS.filter((c) => !c.aliasOf && c.group).map((c) => c.name); + assert.ok(names.length >= 20, "the schema lost commands"); + for (const name of names) { + const { code, stdout, stderr } = await cli([name, "--help"]); + assert.equal(code, 0, `${name} --help exited ${code}: ${stderr}`); + assert.equal(stderr, "", `${name} --help wrote to stderr`); + assert.match(stdout, new RegExp(`^moshcode ${name} —`), `${name} --help printed the wrong block`); + } +}); + +test("help reaches sub-verbs, and does not care where the flag sits", async () => { + const after = await cli(["mcp", "install", "--help"]); + const before = await cli(["mcp", "--help", "install"]); + assert.equal(after.code, 0); + assert.match(after.stdout, /^moshcode mcp install —/); + // R1 is explicit that recognition must not depend on flag position. + assert.equal(before.stdout, after.stdout); +}); + +test("aliases resolve to what they alias, and say so", async () => { + const { stdout } = await cli(["where", "--help"]); + assert.match(stdout, /^moshcode pwd —/, "an alias should answer as its target"); + assert.match(stdout, /aliases: where/, "and name itself as an alias"); +}); + +/* ---------------------------------------------------- R2: no side effects */ + +test("prd --help prints usage and publishes nothing", async () => { + // The bug that motivated the PRD: `--help` was the idea, so asking for help + // wrote prd/NNNN---help.md, committed it, and handed it to an engine. + const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-help-prd-")); + try { + for (const flag of ["--help", "-h", "help"]) { + const before = fs.readdirSync(workdir); + const { code, stdout } = await cli(["prd", flag], { cwd: workdir }); + assert.equal(code, 0, `prd ${flag} exited ${code}`); + assert.match(stdout, /^moshcode prd —/); + assert.deepEqual(fs.readdirSync(workdir), before, `prd ${flag} touched the directory`); + } + assert.equal(fs.existsSync(path.join(workdir, "prd")), false, "a prd/ directory was created"); + } finally { + fs.rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("no command writes anything to disk when asked for help", async () => { + const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-help-clean-")); + try { + for (const name of ["install", "uninstall", "upgrade", "mcp", "skill", "console", "dns", "run", "site", "template"]) { + const { code } = await cli([name, "--help"], { cwd: workdir }); + assert.equal(code, 0, `${name} --help exited ${code}`); + assert.deepEqual(fs.readdirSync(workdir), [], `${name} --help wrote into the cwd`); + } + } finally { + fs.rmSync(workdir, { recursive: true, force: true }); + } +}); + +/* ------------------------------------------------- R3 / R11: failure shape */ + +test("an unknown command is one line on stderr, with a suggestion", async () => { + const { code, stdout, stderr } = await cli(["instal", "claude"]); + assert.equal(code, 1); + assert.equal(stdout, "", "the wall went to stdout and polluted the pipe"); + assert.match(stderr, /unknown command "instal"/); + assert.match(stderr, /did you mean install\?/); + // Short: the old behaviour was 127 lines. + assert.ok(stderr.split("\n").filter(Boolean).length <= 3, `stderr was ${stderr.split("\n").length} lines`); +}); + +test("a bad sub-verb prints that command's usage, not the whole wall", async () => { + const { code, stdout, stderr } = await cli(["help", "mcp", "nonsense"]); + assert.equal(code, 1); + assert.equal(stdout, ""); + assert.match(stderr, /has no verb "nonsense"/); + assert.ok(stderr.split("\n").length <= 24, "usage block should be the command's own, and short"); +}); + +test("an unknown help topic suggests rather than dumping", async () => { + const { code, stderr } = await cli(["help", "instal"]); + assert.equal(code, 1); + assert.match(stderr, /did you mean install\?/); +}); + +/* ------------------------------------------------------------- R9: --json */ + +test("help --json is a parseable model of the whole CLI", async () => { + const { code, stdout, stderr } = await cli(["help", "--json"]); + assert.equal(code, 0); + assert.equal(stderr, "", "decoration on stderr would still break a naive consumer"); + const model = JSON.parse(stdout); + + assert.equal(model.name, "moshcode"); + assert.ok(Array.isArray(model.commands) && model.commands.length >= 20); + // Round trip: everything in the model is dispatchable, and the dispatched + // commands are all in the model. + const inModel = new Set(model.commands.map((c) => c.name)); + for (const c of CORE_CLI_COMMANDS.filter((x) => !x.aliasOf && x.group)) { + assert.ok(inModel.has(c.name), `${c.name} missing from --json`); + } + const mcp = model.commands.find((c) => c.name === "mcp"); + assert.ok(mcp.verbs.some((v) => v.name === "install"), "sub-verbs must be in the model"); +}); + +test("a single command answers --json too", async () => { + const { code, stdout } = await cli(["run", "--help", "--json"]); + assert.equal(code, 0); + const model = JSON.parse(stdout); + assert.equal(model.name, "run"); + assert.ok(model.flags.some((f) => f.flags.includes("--max")), "run's --max is undocumented"); + assert.equal(model.flags.find((f) => f.flags.includes("--max")).default, "3"); +}); + +/* --------------------------------------------------------- R10: fits a screen */ + +test("the overview fits one screen and wraps at 80 columns", async () => { + const { stdout } = await cli(["--help"]); + const lines = stdout.split("\n"); + assert.ok(lines.length <= 40, `overview is ${lines.length} lines`); + for (const line of lines) { + assert.ok(line.length <= WIDTH, `over ${WIDTH} columns: ${JSON.stringify(line)}`); + } +}); + +test("--all is still the whole wall, for the people who grep", async () => { + const overview = await cli(["--help"]); + const all = await cli(["help", "--all"]); + assert.equal(all.code, 0); + assert.ok(all.stdout.length > overview.stdout.length * 3, "--all should be substantially longer"); + assert.match(all.stdout, /moshcode console —/, "every command appears in --all"); +}); + +test("wrap() never exceeds the width it was given", () => { + const long = "word ".repeat(60).trim(); + for (const indent of [0, 10, 24, 44]) { + for (const line of wrap(long, indent).split("\n")) { + assert.ok(line.length <= WIDTH, `indent ${indent} produced ${line.length} columns`); + } + } +}); + +/* ------------------------------------------------------------ R6: no drift */ + +test("every dispatched command has a schema entry with a description", async () => { + // Mirrors the completion drift test: the dispatcher is the source of truth + // for what exists, so a verb added there without help fails the build. + const source = fs.readFileSync(path.join(ROOT, "bin/moshcode.mjs"), "utf8"); + const dispatched = new Set([...source.matchAll(/cmd === "([^"]+)"/g)].map((m) => m[1])); + assert.ok(dispatched.size >= 20, "the dispatch scrape found nothing — did the shape change?"); + + const known = new Map(CORE_CLI_COMMANDS.map((c) => [c.name, c])); + for (const name of dispatched) { + const entry = known.get(name); + assert.ok(entry, `dispatched command "${name}" has no schema entry`); + assert.ok(entry.description?.length, `"${name}" has no description`); + } +}); + +test("every sub-verb has a description", () => { + for (const [label, table] of [["mcp", MCP_VERBS], ["skill", SKILL_VERBS], ["upgrade", UPGRADE_TARGETS], ["dns", DNS_VERBS]]) { + for (const verb of table) { + assert.ok(verb.description?.length, `${label} ${verb.name} has no description`); + } + } +}); + +test("every non-alias command renders a synopsis and a see-also", () => { + for (const command of CORE_CLI_COMMANDS.filter((c) => !c.aliasOf && c.group)) { + const text = renderCommand(command); + assert.match(text, /usage:/, `${command.name} has no synopsis`); + assert.ok(text.includes("see also:") || command.name === "help", `${command.name} has no see-also`); + } +}); + +test("flags that the dispatcher parses are documented", () => { + // The discoverability claim from the PRD's success metrics. Scoped to the + // flags it names, because a blanket grep of every string starting with `--` + // also finds the ones being *passed through* to other tools. + const model = helpModel({}); + const documented = new Set(); + for (const command of model.commands) { + for (const flag of command.flags) for (const f of flag.flags.split(/[ ,]+/)) documented.add(f.replace(/[<>].*$/, "").trim()); + for (const verb of command.verbs) { + for (const flag of verb.flags) for (const f of flag.flags.split(/[ ,]+/)) documented.add(f.replace(/[<>].*$/, "").trim()); + } + } + for (const flag of ["--json", "--max", "-n", "--dry-run", "--yes", "-y", "--device", "-d", + "--browser", "-b", "--port", "--bind", "--ttyd", "--url", "--name", "-t", "-e", "-H", + "--registry", "--check", "--nginx", "--all"]) { + assert.ok(documented.has(flag), `${flag} is parsed somewhere but documented nowhere`); + } +}); + +/* -------------------------------------------------------------- pure units */ + +test("wantsHelp stops at the moshscript filename", () => { + // `moshcode run --help` is the runner's question; after the file it is the + // script's argv and the runner must not claim it. + assert.equal(wantsHelp(["--help"], { stopAt: true }), true); + assert.equal(wantsHelp(["--dry-run", "--help"], { stopAt: true }), true); + assert.equal(wantsHelp(["script.mosh", "--help"], { stopAt: true }), false); + assert.equal(wantsHelp(["script.mosh", "--help"]), true, "without the boundary it is just a flag"); +}); + +test("suggest is tight enough to stay useful", () => { + assert.equal(suggest("instal"), "install"); + assert.equal(suggest("upgrad"), "upgrade"); + assert.equal(suggest("agent"), "agents"); + // A wrong suggestion is worse than none — it sends people to a second wrong + // command. + assert.equal(suggest("xyzzy"), null); + assert.equal(suggest(""), null); +}); + +test("findCommand resolves aliases and rejects strangers", () => { + assert.equal(findCommand("where").name, "pwd"); + assert.equal(findCommand("update").name, "upgrade"); + assert.equal(findCommand("PWD").name, "pwd"); + assert.equal(findCommand("nope"), null); +}); + +test("the overview names every group that has commands", () => { + const text = renderOverview({ engines: ["claude"], tools: ["gh"] }); + for (const group of ["engines", "tools", "extend", "script", "account", "hosting", "system"]) { + assert.match(text, new RegExp(`\\b${group}\\b`), `group ${group} missing from the overview`); + } +});