docs: explain the two catalogues, and gate the files they generate

Documents what the preceding commits added, so it lands after them rather than
describing things that are not there yet. Both new generators are wired up here
too, because a generated file that nothing checks is a file that drifts.

The controls table in `README.md` is now generated from the command catalogue,
which is the point of having one — the six hand-written rows it replaces had
already drifted in the copy under `apps/cli/`. `make controls` rewrites it and
`CONTROLS.md`, then syncs the CLI's README, and it has to run in that order
because `sync-readme.mjs` copies the file the first step writes into. Both
`make preflight` and the PR gate fail on a stale one.

`make models:refresh` is deliberately not in either. Every other generated file
in this repo derives from something committed beside it, so its drift gate can
only fire when a human changed the input. This one derives from models.dev,
which changes whenever a vendor ships a model, so gating on it would need
network to pass and would turn PRs that touched nothing red. Treat a refresh
like a lockfile bump.

Both generated tables spell each chord for both platforms. A single column was
Mac-only exactly where it mattered, which is the bug the catalogue exists to
end, and `README.md` is the highest-traffic place to make it.

The model flags get their own rows, the config file keys are documented as
per-backend and why, and the two capability matrices gain a line for what each
backend can enumerate about itself — the asymmetry the seed list exists for.
This commit is contained in:
Nayan
2026-08-16 13:05:37 +05:30
parent 94b623cb69
commit 0b863148cd
5 changed files with 229 additions and 25 deletions
+12
View File
@@ -73,6 +73,18 @@ jobs:
# generated file matches what the generator emits. That is a property of
# the repo, not of the platform, so running it twice doubles the runtime
# and the flake surface for no extra signal.
# CONTROLS.md and the short table in README.md are generated from the
# overlay's command catalog. Before the README check, because that one
# copies the root README.md that this one writes into.
- name: Controls reference is not stale
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
if ! node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/gen-controls.mjs --check; then
echo "::error file=CONTROLS.md::The controls reference is stale. Run 'make controls' and commit the result."
exit 1
fi
- name: README is not stale
if: matrix.os == 'ubuntu-latest'
shell: bash
+69
View File
@@ -49,6 +49,8 @@ wrapper either way — every recipe is one `pnpm` or `node` call — so use thos
| `make doctor` | `node apps/cli/dist/index.js doctor --cwd apps/web` |
| `make check` | `pnpm lint && pnpm typecheck && pnpm test` |
| `make readme` | `node scripts/sync-readme.mjs` |
| `make controls` | `node --experimental-strip-types scripts/gen-controls.mjs`, then `make readme` |
| `make models:refresh` | `node scripts/gen-models.mjs` — refetches the seed model list |
| `make storybook`| `pnpm turbo run storybook --filter=@airship/overlay` |
`pnpm dev:web` rather than a bare `vite dev`: the site cannot start until
@@ -163,6 +165,8 @@ the adapter is explicit about each gap rather than faking it:
| Cost | `total_cost_usd` | tokens only |
| Fork a session | `forkSession` | starts a fresh thread, and says so |
| `maxTurns` / `maxBudgetUsd` | enforced | unsupported; warned about at startup |
| Model selection | `options.model` | `ThreadOptions.model` |
| Listing its models | `query.supportedModels()`, account-scoped | **nothing** — no subcommand, no RPC |
Codex items are normalized into the same tool vocabulary Claude uses (`command_execution` →
`Bash`, `file_change` → `Edit`/`Write`/`Delete`), so one copy of the summarization rules serves
@@ -186,6 +190,8 @@ It is the most capable of the three in several places, and the adapter uses all
| Native undo | none | `session.revert`, snapshot-backed |
| Cost | tokens only | real cost per message |
| Reasoning effort | `modelReasoningEffort` | **none** — `--effort` is ignored |
| Model selection | `ThreadOptions.model` | `{providerID, modelID}` on the prompt |
| Listing its models | **nothing** | `client.config.providers()`, only what is authed |
Gaps handled explicitly rather than faked:
@@ -242,6 +248,69 @@ into a real value. That indirection is why the install command appears once, in
why `resolve.ts` throws at module load on an unknown token rather than shipping the literal
`{{installCommand}}` to a visitor.
### The controls reference
`CONTROLS.md`, and the short table between the `<!-- controls:start -->` markers in
`README.md`, are generated:
```bash
make controls # rewrites both, then syncs apps/cli/README.md, then commit them
```
The source is `packages/overlay/src/keys/catalog.ts` — the same table the runtime binds
from, the shortcuts panel and the ⌘K palette render from, and every tooltip chip resolves
against. Nothing else in the editor is allowed to spell a chord: `MenuItem.command` renders
one from the catalog, and `keys/catalog.test.ts` fails any `hint:` literal that looks like a
keystroke.
That is not tidiness. Chords used to be string literals at each `keys.bind` call site, so
twenty-seven of the thirty-three shortcuts appeared nowhere in the product or the docs, five
menu rows showed Mac glyphs to Windows users, one advertised `⌘Z` for a feature ⌘Z has never
run, and the only reference — six hand-written rows in `README.md` — had already drifted in
its own copy under `apps/cli/`.
`gen-controls.mjs` imports the `.ts` catalog directly under `--experimental-strip-types`,
which is why that module may contain **no value imports**; type imports are erased before
resolution and are free. `keys/catalog.test.ts` enforces it, and
`keys/controls-doc.test.ts` byte-compares the committed files against the same renderers the
script uses — so drift fails the suite even where the script itself cannot run.
Run it before `make readme`, never after: it writes into the root `README.md` that
`sync-readme.mjs` copies. `make controls` does both in that order.
### The seed model list
`packages/protocol/src/models.ts` is generated from [models.dev](https://models.dev):
```bash
make models:refresh # refetches, rewrites the module, then commit it
```
It exists because of an asymmetry between the backends. Claude answers
`query.supportedModels()` and OpenCode answers `client.config.providers()`, both live and both
scoped to what you are actually signed in to — so for those two this is only what the picker
paints before the answer arrives, and what it falls back to offline. **Codex can enumerate
nothing**, at any layer, so for that backend this file *is* the list.
Two things about it are deliberate.
**It is not in `make preflight`.** `gen-controls.mjs --check` belongs there because it derives
from a file committed beside it, so it can only drift when someone edits that file. This one
derives from a remote registry that changes whenever a vendor ships a model — gating on it
would need network to pass and would turn PRs that touched nothing red. `reference/NEXT-STEPS.md`
§7 describes what that costs. Treat a refresh like a lockfile bump: deliberate, and reviewed as
a diff.
**Judgement lives in `scripts/models.curation.json`, not in the output.** The mechanical filter
— `tool_call`, `reasoning`, a release-date floor — admits things like `gpt-realtime-2.1`, which
is not a coding model. The deny list, the cap, and the Claude CLI aliases models.dev cannot know
about are all in that file, so the generated module stays purely derived and the taste is what
gets reviewed.
The generator formats its own output through the repo's biome before writing. Without that,
`ultracite fix` would reformat the file the first time anyone linted and `--check` would report
stale against a file nobody touched.
### The social card
`public/og.png` is generated, not drawn:
+22 -1
View File
@@ -151,6 +151,9 @@ preflight: ## Run the full CI gate locally (lint + typecheck + test + route drif
@$(MAKE) typecheck
@printf "$(CYAN)» Test$(RESET)\n"
@$(MAKE) test
@printf "$(CYAN)» Controls reference$(RESET)\n"
@node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/gen-controls.mjs --check
@node scripts/sync-readme.mjs --check
@printf "$(CYAN)» Route tree$(RESET)\n"
@$(MAKE) "web:build"
@if ! git diff --quiet -- $(GENERATED); then \
@@ -167,6 +170,8 @@ preflight: ## Run the full CI gate locally (lint + typecheck + test + route drif
preflight\:fix: ## preflight, but autofix first (ultracite fix + regenerate routes)
@printf "$(CYAN)» Fix (ultracite)$(RESET)\n"
@$(MAKE) fix
@printf "$(CYAN)» Controls reference$(RESET)\n"
@$(MAKE) controls
@printf "$(CYAN)» Route tree$(RESET)\n"
@$(MAKE) "web:build"
@printf "$(CYAN)» Typecheck$(RESET)\n"
@@ -283,7 +288,23 @@ demo: ## One-shot: install + build, then print the two-terminal recipe
##@ Release (@airshiplabs/cli)
.PHONY: readme release release\:ci release\:retry release\:version
.PHONY: controls models\:refresh readme release release\:ci release\:retry release\:version
# Before `readme`, always: this writes the short table into the root README.md,
# and `readme` copies the root README.md to apps/cli. Run the other way round
# and the CLI's copy is one generation behind.
controls: ## Regenerate CONTROLS.md and the README table from the command catalog
@node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/gen-controls.mjs
@$(MAKE) readme
# Deliberately NOT in `preflight`, unlike `controls`. Every other generated file
# here derives from something committed beside it, so its drift gate can only
# fire when a human changed the input. This one derives from models.dev, which
# changes whenever a vendor ships a model — gating on it would need network to
# pass and would go red on PRs that touched nothing. See the header of
# scripts/gen-models.mjs, and NEXT-STEPS §7 for what that costs.
models\:refresh: ## Refresh the seed model catalogue from models.dev
@node scripts/gen-models.mjs
readme: ## Regenerate apps/cli/README.md from the root README.md
@node scripts/sync-readme.mjs
+52 -13
View File
@@ -106,16 +106,37 @@ to the canvas — the parameter takes the internal mode name, so it is `shell`,
Open any route of your app in Airship — `/pricing`, `/settings` — and every frame opens there.
On the canvas:
The controls, in short — the table below is generated from the editor's own command
catalog, so it cannot drift from what the keys actually do:
| Gesture | |
| --- | --- |
| wheel / two-finger | pan |
| ⌘/ctrl-wheel, pinch | zoom at cursor |
| space-drag, middle-drag | pan |
| ⇧1 / ⇧2 / ⇧0 | fit / zoom to selection / 100% |
| `H` | hand tool (view mode) |
| `F` | add a frame |
<!-- controls:start -->
| | macOS | Windows / Linux |
| --- | --- | --- |
| pan the canvas | Wheel / two-finger | Wheel / two-finger |
| zoom at the cursor | ⌘-wheel / pinch | Ctrl-wheel / pinch |
| pan without the hand | Space-drag | Space-drag |
| select an element | Click | Click |
| edit text in place | Double-click | Double-click |
| open the element menu | Right-click | Right-click |
| scrub a number | Drag a field's glyph | Drag a field's glyph |
| undo | ⌘Z | Ctrl+Z |
| delete element | ⌫ or Del | Backspace or Del |
| duplicate | ⌘D | Ctrl+D |
| edit text | ↩ or T | Enter or T |
| move | V | V |
| inspect | I | I |
| zoom in | ⌘= or = | Ctrl+= or = |
| zoom out | ⌘- or - | Ctrl+- or - |
| zoom to 100% | ⌘0 or ⇧0 | Ctrl+0 or Shift+0 |
| zoom to fit | ⇧1 | Shift+1 |
| hand tool | H | H |
| add a frame | F | F |
| send | ⌘↩ | Ctrl+Enter |
| keyboard shortcuts | ? | ? |
| command palette | ⌘K | Ctrl+K |
Press `?` in the editor for all of them, or see [CONTROLS.md](./CONTROLS.md).
<!-- controls:end -->
## Edit and View
@@ -166,6 +187,7 @@ you what you're giving up at startup.
| `--effort` | yes | yes | **ignored** |
| `--max-turns`, `--max-budget` | yes | **ignored** | **ignored** |
| `--model` | a model name | a model name | needs the `provider/model` form |
| Lists its own models | yes | **no** — Airship ships a list | yes, the ones you are signed in to |
| `--safe` | checks each edit and command | **real sandbox** | asks before each edit and command |
| Install | included | included | **you install it yourself** |
@@ -255,7 +277,7 @@ forwarded.
| Flag | | Default |
| --- | --- | --- |
| `-a, --agent <name>` | Coding agent: `claude`, `codex`, `opencode`. | `claude` |
| `-m, --model <id>` | Model id. | the agent's own default |
| `-m, --model <id>` | Model for whichever agent runs. Per-backend flags below outrank it. | the agent's own default |
| `--effort <level>` | Reasoning effort: `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. | |
| `--max-turns <n>` | Cap agent turns per edit (claude only). | `24` |
| `--max-budget <usd>` | Stop an edit if it exceeds this cost in USD (claude only). | |
@@ -263,6 +285,12 @@ forwarded.
`-m` is `--model`, not `--mode`. `--mode` has no short alias.
You can also pick the model from the editor. The agent button in the chat header opens a
group per backend; choosing a row picks the backend **and** its model in one go, and a box
at the bottom takes any id the list does not offer. That choice is per backend and is
remembered, so switching between them does not carry one backend's model to another. The
flags below are the resting default it starts from.
### Sandbox
| Flag | | Default |
@@ -273,6 +301,9 @@ forwarded.
| Flag | | Default |
| --- | --- | --- |
| `--claude-model <id>` | Model for the `claude` backend. Takes an alias or a full id. | `--model`, then the agent's own |
| `--codex-model <id>` | Model for the `codex` backend. | `--model`, then the agent's own |
| `--opencode-model <provider/model>` | Model for the `opencode` backend. Needs the `provider/model` form. | `--model`, then the agent's own |
| `--codex-path <path>` | Path to the `codex` binary. | bundled |
| `--codex-config <k=v>` | Extra `codex --config` pair; repeatable. | |
| `--opencode-path <path>` | Path to the `opencode` binary. | found on PATH |
@@ -339,10 +370,17 @@ one. Every key is a flag name, in either kebab or camel case:
"agent": "claude",
"mode": "canvas",
"target": 3000,
"safe": true
"safe": true,
"claudeModel": "opus",
"codexModel": "gpt-5.3-codex"
}
```
Models are keyed per backend — `claudeModel`, `codexModel`, `opencodeModel` — because the
editor's picker can switch backends mid-session, and one shared `model` would follow it and
hand Codex an id only Claude answers to. A plain `"model"` still works and applies to
whichever backend runs, with the per-backend keys taking precedence.
Airship looks for it from `--cwd` upwards and **stops at your repository root**, so a stray
config file somewhere above your repo won't affect you. Misspell a key and it says so, with a
suggestion — it never quietly ignores one.
@@ -359,8 +397,9 @@ AIRSHIP_CWD AIRSHIP_EFFORT AIRSHIP_OPENCODE_PATH
AIRSHIP_MODE AIRSHIP_MAX_TURNS AIRSHIP_OPENCODE_URL
AIRSHIP_EXEC AIRSHIP_MAX_BUDGET AIRSHIP_OPENCODE_AGENT
AIRSHIP_OPEN AIRSHIP_COMMIT AIRSHIP_OPENCODE_CONFIG
AIRSHIP_SAFE AIRSHIP_JSON AIRSHIP_QUIET
AIRSHIP_DEBUG
AIRSHIP_SAFE AIRSHIP_JSON AIRSHIP_OPENCODE_MODEL
AIRSHIP_DEBUG AIRSHIP_QUIET AIRSHIP_CLAUDE_MODEL
AIRSHIP_CODEX_MODEL
```
`AIRSHIP_HELP` and `AIRSHIP_VERSION` are deliberately not read — exporting one would leave the
+74 -11
View File
@@ -108,14 +108,59 @@ to the canvas — the parameter takes the internal mode name, so it is `shell`,
Open any route of your app in Airship — `/pricing`, `/settings` — and every frame opens there.
On the canvas:
The controls, in short — the table below is generated from the editor's own command
catalog, so it cannot drift from what the keys actually do:
| Gesture | |
| --- | --- |
| wheel / two-finger | pan |
| ⌘/ctrl-wheel, pinch | zoom at cursor |
| space-drag, middle-drag | pan |
| ⇧1 / ⇧2 / ⇧0 | fit / zoom to selection / 100% |
<!-- controls:start -->
| | macOS | Windows / Linux |
| --- | --- | --- |
| pan the canvas | Wheel / two-finger | Wheel / two-finger |
| zoom at the cursor | ⌘-wheel / pinch | Ctrl-wheel / pinch |
| pan without the hand | Space-drag | Space-drag |
| select an element | Click | Click |
| edit text in place | Double-click | Double-click |
| open the element menu | Right-click | Right-click |
| scrub a number | Drag a field's glyph | Drag a field's glyph |
| undo | ⌘Z | Ctrl+Z |
| delete element | ⌫ or Del | Backspace or Del |
| duplicate | ⌘D | Ctrl+D |
| edit text | ↩ or T | Enter or T |
| move | V | V |
| inspect | I | I |
| zoom in | ⌘= or = | Ctrl+= or = |
| zoom out | ⌘- or - | Ctrl+- or - |
| zoom to 100% | ⌘0 or ⇧0 | Ctrl+0 or Shift+0 |
| zoom to fit | ⇧1 | Shift+1 |
| hand tool | H | H |
| add a frame | F | F |
| send | ⌘↩ | Ctrl+Enter |
| keyboard shortcuts | ? | ? |
| command palette | ⌘K | Ctrl+K |
Press `?` in the editor for all of them, or see [CONTROLS.md](https://github.com/0xnyn/airship/blob/main/CONTROLS.md).
<!-- controls:end -->
## Edit and View
The two modes point the editor at different things, and the panels follow.
**Edit** is about an element: hover to highlight, click to select, and the agent panel and
inspector are open on either side of it.
**View** is about your frames. The page underneath is fully interactive — click through it,
fill in forms, scroll — so there is no element selection, and the two panels that depend on
one step aside. In their place the left panel lists every frame, and a minimap appears in
the bottom-right corner:
- Click a frame in the list to go to it without changing your zoom; double-click to zoom
to it. Rename it in place, and use `⋯` for its device size, rotate, duplicate and delete.
- The list is stacking order, front-most at the top. Drag a row anywhere along it — or
press ↑↓ on its handle — to restack frames that overlap on the canvas; up is forward.
- Drag the minimap's indicator and the canvas travels with it; press anywhere else on the
map to jump there. Pan far off your frames and it keeps pointing back at them.
Your panel arrangement is remembered per mode, so switching back returns the inspector
exactly as you left it.
## Inspector
@@ -144,6 +189,7 @@ you what you're giving up at startup.
| `--effort` | yes | yes | **ignored** |
| `--max-turns`, `--max-budget` | yes | **ignored** | **ignored** |
| `--model` | a model name | a model name | needs the `provider/model` form |
| Lists its own models | yes | **no** — Airship ships a list | yes, the ones you are signed in to |
| `--safe` | checks each edit and command | **real sandbox** | asks before each edit and command |
| Install | included | included | **you install it yourself** |
@@ -233,7 +279,7 @@ forwarded.
| Flag | | Default |
| --- | --- | --- |
| `-a, --agent <name>` | Coding agent: `claude`, `codex`, `opencode`. | `claude` |
| `-m, --model <id>` | Model id. | the agent's own default |
| `-m, --model <id>` | Model for whichever agent runs. Per-backend flags below outrank it. | the agent's own default |
| `--effort <level>` | Reasoning effort: `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. | |
| `--max-turns <n>` | Cap agent turns per edit (claude only). | `24` |
| `--max-budget <usd>` | Stop an edit if it exceeds this cost in USD (claude only). | |
@@ -241,6 +287,12 @@ forwarded.
`-m` is `--model`, not `--mode`. `--mode` has no short alias.
You can also pick the model from the editor. The agent button in the chat header opens a
group per backend; choosing a row picks the backend **and** its model in one go, and a box
at the bottom takes any id the list does not offer. That choice is per backend and is
remembered, so switching between them does not carry one backend's model to another. The
flags below are the resting default it starts from.
### Sandbox
| Flag | | Default |
@@ -251,6 +303,9 @@ forwarded.
| Flag | | Default |
| --- | --- | --- |
| `--claude-model <id>` | Model for the `claude` backend. Takes an alias or a full id. | `--model`, then the agent's own |
| `--codex-model <id>` | Model for the `codex` backend. | `--model`, then the agent's own |
| `--opencode-model <provider/model>` | Model for the `opencode` backend. Needs the `provider/model` form. | `--model`, then the agent's own |
| `--codex-path <path>` | Path to the `codex` binary. | bundled |
| `--codex-config <k=v>` | Extra `codex --config` pair; repeatable. | |
| `--opencode-path <path>` | Path to the `opencode` binary. | found on PATH |
@@ -317,10 +372,17 @@ one. Every key is a flag name, in either kebab or camel case:
"agent": "claude",
"mode": "canvas",
"target": 3000,
"safe": true
"safe": true,
"claudeModel": "opus",
"codexModel": "gpt-5.3-codex"
}
```
Models are keyed per backend — `claudeModel`, `codexModel`, `opencodeModel` — because the
editor's picker can switch backends mid-session, and one shared `model` would follow it and
hand Codex an id only Claude answers to. A plain `"model"` still works and applies to
whichever backend runs, with the per-backend keys taking precedence.
Airship looks for it from `--cwd` upwards and **stops at your repository root**, so a stray
config file somewhere above your repo won't affect you. Misspell a key and it says so, with a
suggestion — it never quietly ignores one.
@@ -337,8 +399,9 @@ AIRSHIP_CWD AIRSHIP_EFFORT AIRSHIP_OPENCODE_PATH
AIRSHIP_MODE AIRSHIP_MAX_TURNS AIRSHIP_OPENCODE_URL
AIRSHIP_EXEC AIRSHIP_MAX_BUDGET AIRSHIP_OPENCODE_AGENT
AIRSHIP_OPEN AIRSHIP_COMMIT AIRSHIP_OPENCODE_CONFIG
AIRSHIP_SAFE AIRSHIP_JSON AIRSHIP_QUIET
AIRSHIP_DEBUG
AIRSHIP_SAFE AIRSHIP_JSON AIRSHIP_OPENCODE_MODEL
AIRSHIP_DEBUG AIRSHIP_QUIET AIRSHIP_CLAUDE_MODEL
AIRSHIP_CODEX_MODEL
```
`AIRSHIP_HELP` and `AIRSHIP_VERSION` are deliberately not read — exporting one would leave the