Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <cmd> --help` or
`tabbrew help <cmd>`) — 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`
Expand All @@ -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
Expand Down
30 changes: 17 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file> Validate a TabBrew Script (--snapshot for a preview)
tabs push <file> 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 <file> 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 <id> Open a pushed HTML doc in your browser

TABS
tabs check <file> Validate a generated TabBrew Script (add --snapshot for a preview)
tabs push <file> 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 <cmd> --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**.
Expand Down
14 changes: 12 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const { values, positionals } = parseArgs({
Expand Down Expand Up @@ -51,15 +51,25 @@ async function route(): Promise<void> {
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;
}

// `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":
Expand Down
85 changes: 85 additions & 0 deletions src/registry.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Loading