diff --git a/CLAUDE.md b/CLAUDE.md index 74776fc..3aee423 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,8 +23,9 @@ bun run build # → dist/tabbrew (self-contained compiled b There is **no linter configured**, and the test suite is deliberately narrow: `bun test` (Bun's built-in runner, so still zero deps) covers only the pure functions where a wrong -answer is invisible in review — currently `src/table.test.ts` for display-width -measurement. Everything that touches the network, the filesystem, or a real terminal is +answer is invisible in review — `src/table.test.ts` for display-width measurement, and +`src/registry.test.ts` for the help layout (an over-long summary looks fine in the source +and wraps in the terminal). Everything that touches the network, the filesystem, or a real terminal is still verified by hand. `typecheck` + `test` + `build` (in `.github/workflows/ci.yml`) is the whole *check* CI surface — releases are cut by the separate `.github/workflows/release.yml` (see **Releasing**). "Testing" a subcommand @@ -213,12 +214,27 @@ summary, and the flags it accepts. Both `ui.ts`'s `printHelp` and `index.ts`'s leaking into another. `parseArgs` still needs one flat option table (Node's API), so the registry is the *second* gate: declare a new flag in `index.ts` **and** attach it to its command in `registry.ts`, or it will be rejected at runtime. Adding a command = a row here -+ a `case` in `index.ts`; help follows automatically. Help is **two-tier**: the default -prints grouped commands (`GROUPS`) + `GLOBAL_FLAGS` only, while `help --all` adds -per-command flags and the two env tables (`COMMON_ENV` = what a normal user reaches for, -`DEV_ENV` = endpoint/plumbing overrides) and reveals `hidden: true` rows (currently -`tools repo-info`). Keep the env tables in sync with `config.ts` and with the -**Configuration** table below — three places, no generator. ++ a `case` in `index.ts`; help follows automatically. + +Help is **three views** over that one table: +- the **default** (`printHelp()`) — grouped commands (`GROUPS`, ordered by what the CLI is + *for*, so `tabs` leads) + non-`hidden` `GLOBAL_FLAGS` + the `GETTING_STARTED` block that + carries onboarding now that the groups aren't journey-ordered; +- **per-command** (`printCommandHelp()`, reached by `tabbrew --help` or + `tabbrew help `) — that command's flags plus its optional `details` prose, the + caveat a one-line `summary` has no room for; +- **`help --all`** (`printHelp(true)`) — adds per-command flags, the two env tables + (`COMMON_ENV` = what a normal user reaches for, `DEV_ENV` = endpoint/plumbing overrides), + `FILES`, and reveals `hidden: true` rows (currently `tools repo-info` and `--all` itself). + +`index.ts` resolves `--help` through `findCommand` *before* dispatching, which is what +makes the per-command view reachable — don't move that check back above it. + +Every rendered row must fit **80 columns**; `SUMMARY_MAX` encodes the budget a command +summary gets after the label column, and `src/registry.test.ts` renders all three views and +fails on any line over 80. That's why the summaries are terse and the long form lives in +`details`. Keep the env tables in sync with `config.ts` and with the **Configuration** +table below — three places, no generator — and `FILES` with `credentials.ts`/`config.ts`. `ui.ts` centralizes colors (disabled when non-TTY or `NO_COLOR`), holds `link()` (OSC 8 hyperlinks), renders help from the registry, and reads the version from `package.json` @@ -245,7 +261,8 @@ src/ update.ts # self-update: release lookup, download+checksum, atomic binary swap util.ts # sleep, which(), safeText, open-browser registry.ts # command surface as data: groups, summaries, per-command flags, env tables - ui.ts # colors, OSC 8 links, version, help (two-tier) rendered from registry.ts + registry.test.ts # bun test — help fits 80 cols, groups intact, findCommand precedence + ui.ts # colors, OSC 8 links, version, help (3 views) rendered from registry.ts table.ts # display-width column padding shared by docs list / tabs list table.test.ts # bun test — pins down width() (CJK, emoji, marks, escapes) agents.ts # init: AgentTarget registry (Claude Code; extensible) + skills dir diff --git a/README.md b/README.md index 299e45b..9e1b4e3 100644 --- a/README.md +++ b/README.md @@ -28,29 +28,33 @@ TabBrew Script, and drops it into the extension for you to run. ## Commands ``` -ACCOUNT - login Sign in via OAuth device flow and store the token - logout Delete the stored token - whoami Verify the token works and print the user profile +TABS organize your Chrome tabs + tabs serve Start the local bridge the extension exports your tabs to + tabs list Show the tabs the extension last exported + tabs check Validate a TabBrew Script (--snapshot for a preview) + tabs push Send a script to the extension to preview & run + tabs prompt Print the interactive TabBrew Script skill prompt -DOCS +DOCS send HTML into the sidepanel docs push Send an HTML file to the TabBrew sidepanel Docs view docs list List the HTML docs you've pushed (titles are click-to-open) docs open Open a pushed HTML doc in your browser -TABS - tabs check Validate a generated TabBrew Script (add --snapshot for a preview) - tabs push Send a validated TabBrew Script to the extension to preview & run - tabs serve Start the local bridge the extension exports your tabs to - tabs list Show the tabs the extension last exported - tabs prompt Print the interactive TabBrew Script skill prompt +ACCOUNT + login Sign in via OAuth device flow and store the token + whoami Print the signed-in user (exit 1 if signed out) + logout Delete the stored token SETUP - init Install tabbrew-cli awareness + the tabbrew-tabs skill into an AI agent + init Set up an AI agent to use tabbrew (+ the tabs skill) update Update the installed binary to the latest release - help Show usage (add --all for per-command flags + env overrides) + help Show this help ``` +`tabbrew --help` prints one command in depth — its options plus the caveat the +one-liner has no room for. `tabbrew help --all` prints everything: hidden commands, +every per-command flag, and the environment overrides. + Every `tabs` command is offline except `push`/`serve`, which only ever talk to `127.0.0.1`. **None of them can change your tabs** — the browser does that, after you click **Run**. diff --git a/src/index.ts b/src/index.ts index a0e7695..e8feb79 100755 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,7 @@ import { AuthError } from "./auth"; import { ApiError, NotAuthenticatedError, TokenExpiredError } from "./api"; import { UpdateError } from "./update"; import { assertFlagsAllowed, findCommand, UsageError } from "./registry"; -import { c, printHelp, VERSION } from "./ui"; +import { c, printCommandHelp, printHelp, VERSION } from "./ui"; async function route(): Promise { const { values, positionals } = parseArgs({ @@ -51,7 +51,17 @@ async function route(): Promise { console.log(VERSION); return; } + const cmd = findCommand(positionals); if (values.help || command === "help" || command === undefined) { + // Asking for help *about a command* gets that command's help — both + // `tabbrew tabs push --help` and `tabbrew help tabs push`. Bare `--help`, + // `help`, `help --all`, an unknown command, and `help` itself all fall + // through to the full listing. + const target = command === "help" ? findCommand(positionals.slice(1)) : cmd; + if (target && target.name !== "help") { + printCommandHelp(target); + return; + } printHelp(values.all); return; } @@ -59,7 +69,7 @@ async function route(): Promise { // `parseArgs` runs one flat option table (Node needs every flag declared up // front), so on its own it happily accepts `docs push --port 99`. The registry // is the second gate that binds each flag to the command that implements it. - assertFlagsAllowed(findCommand(positionals), values); + assertFlagsAllowed(cmd, values); switch (command) { case "login": diff --git a/src/registry.test.ts b/src/registry.test.ts new file mode 100644 index 0000000..e22caba --- /dev/null +++ b/src/registry.test.ts @@ -0,0 +1,85 @@ +// Pins the help layout, the way table.test.ts pins display width: what breaks +// here is invisible in a diff. A summary two characters too long doesn't look +// wrong in registry.ts — it looks wrong in a user's 80-column terminal, where +// the row wraps and the whole screen reads as broken output. +import { expect, test } from "bun:test"; +import { printCommandHelp, printHelp } from "./ui"; +import { + COMMANDS, + GROUPS, + SUMMARY_MAX, + commandLabel, + findCommand, +} from "./registry"; + +const TERM_WIDTH = 80; + +/** Colors are decided at import time from `isTTY`, so measure on stripped text. */ +const stripAnsi = (s: string): string => + // eslint-disable-next-line no-control-regex + s.replace(/\x1b\[[0-9;]*m/g, "").replace(/\x1b\]8;;.*?\x07/g, ""); + +function capture(render: () => void): string[] { + const original = console.log; + const chunks: string[] = []; + console.log = (...args: unknown[]) => void chunks.push(args.join(" ")); + try { + render(); + } finally { + console.log = original; + } + return stripAnsi(chunks.join("\n")).split("\n"); +} + +const tooWide = (lines: string[]): string[] => + lines.filter((line) => line.length > TERM_WIDTH); + +test("the default help fits an 80-column terminal", () => { + expect(tooWide(capture(() => printHelp()))).toEqual([]); +}); + +test("`help --all` fits an 80-column terminal", () => { + expect(tooWide(capture(() => printHelp(true)))).toEqual([]); +}); + +test("every command's own help fits an 80-column terminal", () => { + for (const cmd of COMMANDS) { + expect({ + cmd: cmd.name, + wide: tooWide(capture(() => printCommandHelp(cmd))), + }).toEqual({ cmd: cmd.name, wide: [] }); + } +}); + +test("summaries stay inside the width the label column leaves them", () => { + // SUMMARY_MAX is derived from the longest label; if a longer command lands + // here, the constant is stale and the rows above will start wrapping. + const widest = Math.max(...COMMANDS.map((cmd) => commandLabel(cmd).length)); + expect(2 + widest + 2 + SUMMARY_MAX).toBeLessThanOrEqual(TERM_WIDTH); + for (const cmd of COMMANDS) { + expect({ cmd: cmd.name, len: cmd.summary.length > SUMMARY_MAX }).toEqual({ + cmd: cmd.name, + len: false, + }); + } +}); + +test("every command lands in a group, and no group is left empty", () => { + const ids = new Set(GROUPS.map((group) => group.id)); + for (const cmd of COMMANDS) expect(ids.has(cmd.group)).toBe(true); + for (const group of GROUPS) { + expect({ + group: group.id, + any: COMMANDS.some((cmd) => cmd.group === group.id), + }).toEqual({ group: group.id, any: true }); + } +}); + +test("a two-word command beats a one-word match", () => { + // `index.ts` resolves `--help` against this before dispatching, so a + // regression here would send `tabs push --help` to the wrong command. + expect(findCommand(["tabs", "push"])?.name).toBe("tabs push"); + expect(findCommand(["docs", "open", "42"])?.name).toBe("docs open"); + expect(findCommand(["tabs"])).toBeUndefined(); + expect(findCommand(["bogus"])).toBeUndefined(); +}); diff --git a/src/registry.ts b/src/registry.ts index c8c9a84..8e2ee17 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -28,9 +28,11 @@ export interface FlagSpec { /** Value placeholder shown in help; omitted for booleans. */ value?: string; summary: string; + /** Accepted, but kept out of the default help; shown under `help --all`. */ + hidden?: boolean; } -export type CommandGroup = "account" | "docs" | "tabs" | "setup"; +export type CommandGroup = "tabs" | "docs" | "account" | "setup"; export interface CommandSpec { /** The command exactly as typed, e.g. "tabs push". Also the lookup key. */ @@ -38,17 +40,36 @@ export interface CommandSpec { /** Positional placeholder shown in help, e.g. "". */ args?: string; group: CommandGroup; + /** One line, ≤ SUMMARY_MAX chars so the help row fits an 80-column terminal. */ summary: string; + /** + * The caveat a summary has no room for — shown only by `tabbrew --help`, + * so the one-line view stays scannable without losing what a user needs to + * know before running the thing. Written as prose; the renderer wraps it. + */ + details?: string; flags: readonly FlagSpec[]; /** Kept out of the default help; listed only under `help --all`. */ hidden?: boolean; } -/** Heading each group prints under in `help`, in display order. */ -export const GROUPS: ReadonlyArray<{ id: CommandGroup; title: string }> = [ +/** + * Heading each group prints under in `help`, in display order. + * + * Ordered by what the CLI is *for*, not by the order a new user meets it: tabs + * are the product, so they lead. Onboarding (`init`, `login`) is served by the + * GETTING STARTED block at the foot of the help instead — the same split `gh` + * and `docker` use, and it keeps the top of the screen useful for the returning + * user who just wants the name of a command. + */ +export const GROUPS: ReadonlyArray<{ + id: CommandGroup; + title: string; + blurb?: string; +}> = [ + { id: "tabs", title: "TABS", blurb: "organize your Chrome tabs" }, + { id: "docs", title: "DOCS", blurb: "send HTML into the sidepanel" }, { id: "account", title: "ACCOUNT" }, - { id: "docs", title: "DOCS" }, - { id: "tabs", title: "TABS" }, { id: "setup", title: "SETUP" }, ]; @@ -58,87 +79,80 @@ export const GLOBAL_FLAGS: readonly FlagSpec[] = [ { name: "version", short: "v", summary: "Print the version" }, { name: "all", - summary: "With `help`: add per-command flags and environment overrides", + // Hidden by default: the footer line already points at `help --all`, and a + // flag whose summary has to say "with `help`:" is noise in a global list. + hidden: true, + summary: "With `help`: add per-command flags and env overrides", }, ]; const VARIANT_FLAG: FlagSpec = { name: "variant", value: "", - summary: "Skill prompt variant: full|standard|compact (default full)", + summary: "Prompt variant: full|standard|compact (default full)", }; -export const COMMANDS: readonly CommandSpec[] = [ - { - name: "login", - group: "account", - summary: "Sign in via OAuth device flow and store the token", - flags: [], - }, - { - name: "logout", - group: "account", - summary: "Delete the stored token", - flags: [], - }, - { - name: "whoami", - group: "account", - summary: "Verify the token works and print the user profile", - flags: [], - }, +/** + * Longest a `summary` may be. The help row is 2 spaces + the label column + + * 2 spaces + the summary, and the longest label is `tabs check ` (17), so + * anything past this wraps on an 80-column terminal — which reads as broken + * output, not as a long sentence. Pinned by registry.test.ts. + */ +export const SUMMARY_MAX = 59; +// Display order *is* array order (ui.ts filters by group, it never sorts), so +// these are grouped and sequenced deliberately. Within `tabs` that means +// workflow order — serve, then list what arrived, then check and push a script — +// not the order the commands happened to be written in. +export const COMMANDS: readonly CommandSpec[] = [ { - name: "docs push", - args: "", - group: "docs", - summary: "Send an HTML file to the TabBrew sidepanel Docs view", + name: "tabs serve", + group: "tabs", + summary: "Start the local bridge the extension exports your tabs to", + details: + "Long-running: it binds 127.0.0.1 only and blocks until Ctrl+C, so start it " + + "in a second shell. The extension POSTs your open tabs to it, and it saves " + + "them (mode 0600 — they're browsing history) for `tabs list` to read.", flags: [ + { name: "port", value: "", summary: "Port to listen on (default 49227)" }, { - name: "cloud", - summary: - "Upload the content to cloud storage (≤ 2 MB) instead of registering the local path", - }, - { - name: "title", - value: "", - summary: - "Title shown in the Docs list (default: the doc's , else the filename)", + name: "out", + value: "<path>", + summary: "Where to save the received tabs JSON", }, ], }, { - name: "docs list", - group: "docs", - summary: "List the HTML docs you've pushed (titles are click-to-open)", + name: "tabs list", + group: "tabs", + summary: "Show the tabs the extension last exported", + details: + "Reads the file `tabs serve` wrote — a snapshot on disk, not a live query. " + + "Check its `savedAt` before trusting the tab ids.", flags: [ - { name: "json", summary: "Print the raw JSON array instead of a table" }, + { name: "json", summary: "Print the raw saved JSON instead of a table" }, ], }, - { - name: "docs open", - args: "<id>", - group: "docs", - summary: "Open a pushed HTML doc in your browser", - flags: [], - }, - { name: "tabs check", args: "<file>", group: "tabs", - summary: "Validate a generated TabBrew Script (add --snapshot for a preview)", + summary: "Validate a TabBrew Script (--snapshot for a preview)", + details: + "Fully offline — no server, no browser. Prints line-numbered parse errors and " + + "exits 1 if there are any. Takes a file or `-` for stdin, and accepts a whole " + + "```tabbrew fenced block.", flags: [ { name: "snapshot", value: "<f>", summary: - "Snapshot for the before/after preview (Copy-AI-Prompt .md, or a .json payload)", + "Snapshot for the before/after preview (.md or .json)", }, { name: "json", summary: - "Print structured JSON (ok/ops/errors/stats/preview) instead of text", + "Print structured JSON instead of text", }, ], }, @@ -146,7 +160,11 @@ export const COMMANDS: readonly CommandSpec[] = [ name: "tabs push", args: "<file>", group: "tabs", - summary: "Send a validated TabBrew Script to the extension to preview & run", + summary: "Send a script to the extension to preview & run", + details: + "Requires `tabbrew tabs serve` to already be running. This does not run the " + + "script: it lands in the extension's panel and you click Run there. Nothing " + + "the CLI does can change your tabs.", flags: [ { name: "port", @@ -156,52 +174,100 @@ export const COMMANDS: readonly CommandSpec[] = [ ], }, { - name: "tabs serve", + name: "tabs prompt", group: "tabs", - summary: "Start the local bridge the extension exports your tabs to", + summary: "Print the interactive TabBrew Script skill prompt", + details: + "The same prompt `init` installs as the tabbrew-tabs skill — print it when you " + + "want to paste it somewhere by hand instead.", + flags: [VARIANT_FLAG], + }, + + { + name: "docs push", + args: "<file>", + group: "docs", + summary: "Send an HTML file to the TabBrew sidepanel Docs view", + details: + "Local by default: it registers the file's absolute path, so the doc opens as " + + "a file:// URL on this machine only. Use --cloud to upload the content " + + "(≤ 2 MB) when you want to read it from another machine.", flags: [ - { name: "port", value: "<n>", summary: "Port to listen on (default 49227)" }, { - name: "out", - value: "<path>", - summary: "Where to save the tabs JSON (default ~/.config/tabbrew/tabs.json)", + name: "cloud", + summary: + "Upload the content (≤ 2 MB) instead of the local path", + }, + { + name: "title", + value: "<t>", + summary: + "Title in the Docs list (default: the doc's <title>)", }, ], }, { - name: "tabs list", - group: "tabs", - summary: "Show the tabs the extension last exported", + name: "docs list", + group: "docs", + summary: "List the HTML docs you've pushed (titles are click-to-open)", flags: [ - { name: "json", summary: "Print the raw saved JSON instead of a table" }, + { name: "json", summary: "Print the raw JSON array instead of a table" }, ], }, { - name: "tabs prompt", - group: "tabs", - summary: "Print the interactive TabBrew Script skill prompt", - flags: [VARIANT_FLAG], + name: "docs open", + args: "<id>", + group: "docs", + summary: "Open a pushed HTML doc in your browser", + flags: [], + }, + + { + name: "login", + group: "account", + summary: "Sign in via OAuth device flow and store the token", + details: + "Opens a browser and prints a code to enter there. The token is stored at " + + "~/.config/tabbrew/credentials.json (chmod 600). Set TABBREW_TOKEN instead to " + + "authenticate without an interactive login, e.g. in CI.", + flags: [], + }, + { + name: "whoami", + group: "account", + summary: "Print the signed-in user (exit 1 if signed out)", + flags: [], + }, + { + name: "logout", + group: "account", + summary: "Delete the stored token", + flags: [], }, { name: "init", group: "setup", - summary: "Install tabbrew-cli awareness + the tabbrew-tabs skill into an AI agent", + summary: "Set up an AI agent to use tabbrew (+ the tabs skill)", + details: + "Writes a TABBREW-CLI.md awareness doc plus a managed block in the agent's " + + "CLAUDE.md that imports it, and installs the tabbrew-tabs skill. Idempotent — " + + "a re-run reports `unchanged`. --uninstall removes all three.", flags: [ { name: "global", short: "g", - summary: "Write to the agent's global dir (~/.claude) instead of the cwd", + summary: "Write to the agent's global dir instead of the cwd", }, { name: "dry-run", summary: "Print what would change; write nothing" }, { name: "uninstall", - summary: "Remove the awareness doc, managed block, and the tabbrew-tabs skill", + summary: "Remove the awareness doc, managed block, and skill", }, { name: "yes", short: "y", - summary: "Skip the confirmation prompt when modifying an existing file", + summary: "Skip the confirmation prompt on an existing file", }, { name: "agent", value: "<id>", summary: "Target agent (default claude)" }, VARIANT_FLAG, @@ -212,11 +278,15 @@ export const COMMANDS: readonly CommandSpec[] = [ name: "update", group: "setup", summary: "Update the installed binary to the latest release", + details: + "Downloads the newest GitHub release, verifies its SHA-256, and swaps it over " + + "the running binary. Refuses to run from `bun run src/index.ts` so it never " + + "overwrites bun itself. --check only reports, and always exits 0.", flags: [ { name: "check", summary: - "Report whether a newer version exists; change nothing (--json for scripting)", + "Report whether a newer version exists; change nothing", }, { name: "json", summary: "Machine-readable output for --check" }, ], @@ -236,11 +306,33 @@ export const COMMANDS: readonly CommandSpec[] = [ }, ]; +/** + * The first-run path, shown at the foot of the default help. This is where + * onboarding lives now that the groups are ordered by value rather than by + * journey — one block a new user can follow top to bottom. + */ +export const GETTING_STARTED: ReadonlyArray<[string, string]> = [ + ["tabbrew init", "teach your AI agent that this CLI exists"], + ["tabbrew login", "sign in to your TabBrew account"], + ["tabbrew tabs serve", "run in a 2nd shell; the extension sends your tabs over"], +]; + +/** + * Where the CLI keeps state, listed under `help --all`. Hardcoded defaults, like + * the env tables below — they're documentation of the shipped behaviour, not a + * readout of the resolved config, which keeps ui.ts from importing config.ts. + * Keep in sync with credentials.ts (CRED_PATH) and config.ts (`serve.out`). + */ +export const FILES: ReadonlyArray<[string, string]> = [ + ["~/.config/tabbrew/credentials.json", "Stored login token (chmod 600)"], + ["~/.config/tabbrew/tabs.json", "Tabs `tabs serve` received (mode 0600)"], +]; + /** Environment overrides a normal user might reach for. */ export const COMMON_ENV: ReadonlyArray<[string, string]> = [ - ["TABBREW_TOKEN", "Use this token directly (for CI/CD); wins over stored file"], - ["TABBREW_SERVE_PORT", "Default port for `tabs serve`/`tabs push` (default 49227)"], - ["TABBREW_TABS_PATH", "Where `tabs serve` saves tabs (default ~/.config/tabbrew/tabs.json)"], + ["TABBREW_TOKEN", "Use this token; wins over the stored file"], + ["TABBREW_SERVE_PORT", "Port for `tabs serve`/`tabs push` (default 49227)"], + ["TABBREW_TABS_PATH", "Where `tabs serve` saves the tabs it receives"], ["TABBREW_NO_BROWSER", "Set to skip auto-opening the browser during login"], ["TABBREW_DEBUG", "Set to print stack traces on unexpected errors"], ["NO_COLOR", "Disable ANSI colors"], @@ -248,7 +340,7 @@ export const COMMON_ENV: ReadonlyArray<[string, string]> = [ /** Endpoint/plumbing overrides — only useful pointing the binary at staging or local. */ export const DEV_ENV: ReadonlyArray<[string, string]> = [ - ["TABBREW_BASE_URL", "Auth server base URL (default https://www.tabbrew.com)"], + ["TABBREW_BASE_URL", "Auth server base URL (default www.tabbrew.com)"], ["TABBREW_CLIENT_ID", "OAuth client id (default tabbrew-cli)"], ["TABBREW_SCOPE", "Optional OAuth scope"], ["TABBREW_DEVICE_CODE_URL", "Override the device-code endpoint"], @@ -258,11 +350,11 @@ export const DEV_ENV: ReadonlyArray<[string, string]> = [ ["TABBREW_HTML_UPLOAD_URL", "Override the docs-push cloud-upload endpoint"], ["TABBREW_HTML_LIST_URL", "Override the docs-list endpoint"], ["TABBREW_TIMEOUT_MS", "Per-request timeout in ms (default 15000)"], - ["TABBREW_REPO", "GitHub owner/name for `update` (default colevels/tabbrew-cli)"], + ["TABBREW_REPO", "GitHub owner/name `update` pulls releases from"], ["TABBREW_RELEASE_URL", "Override the releases/latest URL used by `update`"], ["TABBREW_DOWNLOAD_BASE_URL", "Override the release-asset download base URL"], ["TABBREW_DOWNLOAD_TIMEOUT_MS", "Binary-download timeout in ms (default 120000)"], - ["CLAUDE_CONFIG_DIR", "Global agent dir used by init --global (default ~/.claude)"], + ["CLAUDE_CONFIG_DIR", "Global agent dir used by `init --global`"], ]; /** The label shown in help: the command plus its positional placeholder. */ diff --git a/src/ui.ts b/src/ui.ts index 53a353f..aa8a6f5 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -3,10 +3,13 @@ import { COMMANDS, COMMON_ENV, DEV_ENV, + FILES, + GETTING_STARTED, GLOBAL_FLAGS, GROUPS, commandLabel, flagLabel, + type CommandSpec, } from "./registry"; export const NAME = pkg.name; @@ -49,18 +52,51 @@ export function indent(text: string, spaces = 2): string { .join("\n"); } +/** Terminal width every help view is laid out against. */ +const HELP_WIDTH = 80; + +/** Greedy word wrap. Long enough for prose paragraphs; no dependency needed. */ +function wrapText(text: string, width: number): string[] { + const out: string[] = []; + let line = ""; + for (const word of text.split(/\s+/).filter(Boolean)) { + if (line && line.length + 1 + word.length > width) { + out.push(line); + line = word; + } else { + line = line ? `${line} ${word}` : word; + } + } + if (line) out.push(line); + return out; +} + +/** ` label summary` rows, aligned on a shared label column. */ +function twoCol( + rows: ReadonlyArray<readonly [string, string]>, + width: number, + indent = 2, +): string[] { + const pad = " ".repeat(indent); + return rows.map(([label, summary]) => `${pad}${label.padEnd(width)} ${summary}`); +} + +const colWidth = (rows: ReadonlyArray<readonly [string, string]>): number => + Math.max(...rows.map(([label]) => label.length)); + /** * Print CLI help, rendered from the command registry rather than hand-written * strings — so a command's flags in help are, by construction, the flags it * actually accepts. * - * The default (`full = false`) is a lean, user-facing summary: commands grouped - * by what they're for, plus the two global options. Developer mode - * (`tabbrew help --all` / `--help --all`) adds hidden commands, every - * per-command flag, the ENVIRONMENT override tables, and the credentials path — - * the reference surface a maintainer or scripter needs. Endpoint overrides only - * matter to someone pointing the binary at staging/local or wiring CI, so - * they're split out from the handful a normal user might set. + * Help is three views over the same registry: + * - the default here (`full = false`) — commands grouped by what they're for, + * the global options, and the GETTING STARTED path a first-run user needs; + * - `printCommandHelp` below — one command in depth, for `tabbrew <cmd> --help`; + * - `tabbrew help --all` (`full = true`) — the reference surface: hidden + * commands, every per-command flag, the ENVIRONMENT tables, and FILES. + * Endpoint overrides only matter to someone pointing the binary at staging/local + * or wiring CI, so they're split out from the handful a normal user might set. */ export function printHelp(full = false): void { const lines: string[] = [ @@ -71,26 +107,45 @@ export function printHelp(full = false): void { ]; const visible = COMMANDS.filter((cmd) => full || !cmd.hidden); - const labelWidth = Math.max(...visible.map((cmd) => commandLabel(cmd).length)); + const globals = GLOBAL_FLAGS.filter((flag) => full || !flag.hidden); + // One column across both tables, so the commands and the global options below + // them read as a single list rather than two ragged ones. + const labelWidth = Math.max( + ...visible.map((cmd) => commandLabel(cmd).length), + ...globals.map((flag) => flagLabel(flag).length), + ); for (const group of GROUPS) { const inGroup = visible.filter((cmd) => cmd.group === group.id); if (inGroup.length === 0) continue; - lines.push("", c.bold(group.title)); - for (const cmd of inGroup) { - lines.push(` ${commandLabel(cmd).padEnd(labelWidth)} ${cmd.summary}`); - } + lines.push( + "", + c.bold(group.title) + (group.blurb ? c.dim(` ${group.blurb}`) : ""), + ...twoCol( + inGroup.map((cmd) => [commandLabel(cmd), cmd.summary] as const), + labelWidth, + ), + ); } - lines.push("", c.bold("OPTIONS")); - for (const flag of GLOBAL_FLAGS) { - lines.push(` ${flagLabel(flag).padEnd(labelWidth)} ${flag.summary}`); - } + lines.push( + "", + c.bold("OPTIONS"), + ...twoCol( + globals.map((flag) => [flagLabel(flag), flag.summary] as const), + labelWidth, + ), + ); if (!full) { lines.push( "", - `Run ${c.bold(BIN + " help --all")} for per-command flags and environment overrides.`, + c.bold("GETTING STARTED"), + ...twoCol(GETTING_STARTED, colWidth(GETTING_STARTED)), + "", + `Run ${c.bold(BIN + " <cmd> --help")} for one command, ${c.bold( + BIN + " help --all", + )} for everything.`, ); console.log(lines.join("\n")); return; @@ -98,35 +153,61 @@ export function printHelp(full = false): void { const withFlags = visible.filter((cmd) => cmd.flags.length > 0); if (withFlags.length > 0) { + // Flags sit one level deeper than the command they belong to — at the same + // indent (as they were) the command name doesn't read as a heading. const flagWidth = Math.max( ...withFlags.flatMap((cmd) => cmd.flags.map((f) => flagLabel(f).length)), ); lines.push("", c.bold("COMMAND OPTIONS")); for (const cmd of withFlags) { - lines.push(` ${c.dim(cmd.name + ":")}`); - for (const flag of cmd.flags) { - lines.push(` ${flagLabel(flag).padEnd(flagWidth)} ${flag.summary}`); - } + lines.push( + ` ${c.bold(cmd.name)}`, + ...twoCol( + cmd.flags.map((flag) => [flagLabel(flag), flag.summary] as const), + flagWidth, + 4, + ), + ); } } - const envWidth = Math.max( - ...[...COMMON_ENV, ...DEV_ENV].map(([name]) => name.length), - ); - const envBlock = (rows: ReadonlyArray<readonly [string, string]>): string[] => - rows.map(([name, summary]) => ` ${name.padEnd(envWidth)} ${summary}`); - - lines.push("", c.bold("ENVIRONMENT"), ...envBlock(COMMON_ENV)); + const envWidth = colWidth([...COMMON_ENV, ...DEV_ENV]); + lines.push("", c.bold("ENVIRONMENT"), ...twoCol(COMMON_ENV, envWidth)); lines.push( "", c.bold("ENVIRONMENT") + c.dim(" (pointing the binary at staging or local)"), - ...envBlock(DEV_ENV), + ...twoCol(DEV_ENV, envWidth), ); - lines.push( + lines.push("", c.bold("FILES"), ...twoCol(FILES, colWidth(FILES))); + + console.log(lines.join("\n")); +} + +/** + * Print help for one command — what `tabbrew tabs push --help` shows. The + * summary is the same line the command list carries; `details` is the caveat + * that had no room there (see `CommandSpec.details`). + */ +export function printCommandHelp(cmd: CommandSpec): void { + const lines: string[] = [ + cmd.summary, "", - c.bold("CREDENTIALS"), - " Stored at ~/.config/tabbrew/credentials.json (chmod 600).", - ); + c.bold("USAGE"), + ` ${BIN} ${commandLabel(cmd)}${cmd.flags.length > 0 ? " [options]" : ""}`, + ]; + + if (cmd.flags.length > 0) { + lines.push( + "", + c.bold("OPTIONS"), + ...twoCol( + cmd.flags.map((flag) => [flagLabel(flag), flag.summary] as const), + colWidth(cmd.flags.map((flag) => [flagLabel(flag), flag.summary] as const)), + ), + ); + } + + if (cmd.details) lines.push("", ...wrapText(cmd.details, HELP_WIDTH)); console.log(lines.join("\n")); }