Files
airship/CONTRIBUTING.md
T
Nayan 4c2d1c822d feat(cli): add ./airship, a dev wrapper that rebuilds the stale bundle
tsup inlines every @airship/* package into apps/cli/dist/index.js
(noExternal), so an edit anywhere under packages/ is invisible until the
CLI is rebuilt. The Makefile could not catch that: $(CLI) was a file
prerequisite with no source prerequisites, so `@pnpm build` ran only when
dist was absent. Once it existed, `make run` launched the stale bundle
silently.

./airship is a pure passthrough — argv untouched, cwd never changed, so
--cwd, the upward config search and the bare-invocation wizard behave
exactly as the published binary does — that mtime-scans apps/cli and
packages/* and shells out to turbo only when something moved.

Three things the obvious implementation gets wrong:

- Turbo hashes contents, so a cache hit leaves dist/ untouched and a
  plain mtime comparison never converges. A successful build stamps the
  entry, but only when turbo did not rewrite it, since stamping backwards
  reopens the window tsup's `clean` creates on apps/cli's own mtime.
- Scanning */src misses real build inputs. `turbo --dry=json` reaches
  package.json, tsconfig.json, scripts/ and editor-icons' 507 SVGs under
  assets/, so the scan covers package roots minus a skip set.
- SIGINT arrives from the tty at the whole process group, so forwarding
  it would trip the CLI's own second-Ctrl-C escape hatch; SIGTERM arrives
  at one pid, so swallowing it would hang. Swallow the first, forward the
  second.

The logic lives in scripts/airship-run.mjs so airship.cmd gives Windows
the same behaviour rather than a degraded passthrough. The Makefile's
run:* and doctor targets now go through the wrapper, so they inherit the
build check; a test reserves the wrapper's two flag names, since the CLI
generates --no-<name> for every boolean and would collide silently.
2026-08-16 13:05:37 +05:30

33 KiB
Raw Blame History

Contributing

This is the contributor's half of the documentation. README.md covers installing and using Airship; everything here is about working on it.

Getting set up

Node 22.13 or later, and pnpm — the repo pins pnpm@11.9.0 through packageManager, so Corepack will pick the right one.

pnpm install
pnpm build

make help lists every target, grouped by the surface it acts on. Targets follow a <surface>:<action> convention — web:dev, run:codex, storybook:build — and repo-wide operations stay bare (build, check, preflight).

The fastest way to see the tool working on itself:

make demo        # install + build, then prints the recipe

make web:dev     # terminal 1 — apps/web's dev server on :5173
make run         # terminal 2 — airship on :5174, editing apps/web

Open http://localhost:5174 and you are looking at Airship, with Airship's own home page live inside it. Pick the hero's button, ask for a change, and the diff lands in apps/web/src/. make run:solo does both in one terminal via --exec.

make run is a preset. For anything it does not model — a different port, another project, --effort, --max-budget — use ./airship, which is this checkout's CLI with every flag available. See Running the dev CLI; read it before your first change under packages/, because the bundle it builds is staler than you would expect.

On Windows

Everything builds, tests and runs on Windows — checks.yml gates every PR on a windows-latest leg alongside Linux, so a break there fails the PR.

make is the one thing that does not carry over: the Makefile declares SHELL := /bin/bash and a handful of targets genuinely need it (help is an awk program, preflight a shell conditional, release a bash script). It is a thin wrapper either way — every recipe is one pnpm, node or airship call — so use those directly:

Instead of Run
make demo pnpm install && pnpm build
make web:dev pnpm dev:web
make run airship.cmd --target 5173 --cwd apps/web
make run:solo airship.cmd --cwd apps/web --exec "pnpm dev:web"
make doctor airship.cmd 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 @airship/site-tokens has emitted dist/tokens.css, and only turbo knows that.

airship.cmd is the Windows half of ./airship, with the same behaviour including the rebuild check — the logic lives in scripts/airship-run.mjs precisely so both platforms share it. From Git Bash, prefer ./airship directly. Three Windows-only notes: PowerShell needs .\airship.cmd, and its 5.x releases mangle quotes when passing arguments to native commands, so --exec "…" is a Git Bash job; and Ctrl-C in cmd.exe prints Terminate batch job (Y/N)? after the CLI has already shut down cleanly, which is a batch-file fact rather than an airship one.

Two things worth setting up once:

  • Git Bash, which ships with Git for Windows, runs the Husky hooks and the release scripts. Without a POSIX sh on PATH the pre-commit formatter silently does not run.
  • Developer Mode (Settings → System → For developers), so pnpm can create the symlinks its node_modules layout depends on without elevation.

Line endings are pinned to LF by .gitattributes — do not override it with core.autocrlf. Several generators parse their input with anchored regexes, and a CRLF checkout makes them report a missing front-matter block rather than a wrong one. If you cloned before that file existed, renormalize once with git rm --cached -r . && git reset --hard.

Repo layout

Package Role
@airship/protocol Shared zod schemas + types (the client↔server contract)
@airship/source DOM-element → source-file resolution (element-source + server fallback)
@airship/git Optional auto-commit (Conventional Commits) + content-restore undo
@airship/core The agent engine — runEdit() dispatches to a backend adapter
@airship/server Reverse proxy + WebSocket + job/history store
@airship/overlay Canvas shell + frames, picker, inspector, prompt, diffs, history — and a Storybook of all of it
@airship/editor-tokens The editor's own --ap-* design tokens, generated from EDITOR.md
@airship/editor-icons Vendored UI icon set, normalised to one generated module
@airship/site-tokens The home page's --pk-* design tokens, generated from DESIGN.md
@airshiplabs/cli The airship binary — the one package published to npm
@airship/web The home page — and the app Airship edits in make run

Everyday commands

pnpm build       # turbo build (topological, including apps/web)
pnpm dev         # watch every package AND serve the site
pnpm dev:pkgs    # …packages only, when the site is not what you are changing
pnpm typecheck   # tsc --noEmit across the workspace
pnpm test        # vitest
pnpm lint        # biome (ultracite preset)
pnpm commit      # guided Conventional Commit (czg)
make storybook   # the overlay's own chrome, browsable — see below
./airship        # this checkout's CLI, rebuilt when it is behind — see below

make check runs lint + typecheck + test. make preflight runs that plus the route-tree drift check, which is exactly what CI gates a PR on — run it before opening one. make preflight:fix autofixes what is autofixable first, then verifies the rest.

Toolchain: pnpm workspaces + Turborepo, Biome via Ultracite, Husky + commitlint for Conventional Commits, tsup builds.

Running the dev CLI

./airship runs the CLI built from this checkout. It is a pure passthrough — every argument reaches the CLI untouched and the working directory is never changed — so anything in airship --help works verbatim, --cwd and the upward airship.config.json search resolve against wherever you typed it, and a bare ./airship gives you the same interactive wizard a real user gets. It works from outside the repo too: cd ~/my-app && /path/to/airship/airship --target 3000 drives your own project with this checkout's build.

./airship                     # the wizard, against $PWD
./airship --target 3000       # a dev server on another port
./airship doctor              # any subcommand
./airship --skip-build ...    # trust dist as-is (AIRSHIP_SKIP_BUILD=1)
./airship --force-build ...   # rebuild even when it looks fresh (AIRSHIP_FORCE_BUILD=1)

--skip-build and --force-build are consumed by the wrapper and never forwarded, so they are not in airship --help. Everything after a -- is the CLI's, including tokens that look like those two. A test in apps/cli/src/lib/args.test.ts keeps the CLI from ever claiming those names, because the wrapper would silently swallow them.

Why this exists, and why it is not optional. apps/cli/tsup.config.ts sets noExternal: [/^@airship\//], which inlines every workspace package into apps/cli/dist/index.js. That is deliberate: none of the @airship/* packages is published, so the tarball must declare no dependency on them, which is also why they sit in devDependencies rather than dependencies.

The consequence catches everyone once. Edit packages/core/src/runner.ts, run the CLI, and you are running the old code — no error, no warning, your change simply does not happen. The same goes for server, overlay, protocol, source, git, and for the two generated packages whose real inputs are not even TypeScript: @airship/editor-icons builds from 507 SVGs under assets/, and @airship/editor-tokens from a markdown file.

The Makefile could not catch this. Its guard was $(CLI): ; @pnpm build — a file prerequisite, so make ran it only when dist/index.js was absent. A stale bundle exists, so make run launched it happily. ./airship replaces that check, and the run:* targets now go through the wrapper, so they get it too.

There is also no watch loop to fall back on. apps/cli's dev script is a bare tsup --watch, which never runs scripts/vendor-assets.mjs and — because the config has clean: true — deletes dist/vendor/ on every rebuild. And pnpm dev:pkgs rebuilding packages/*/dist does nothing for a bundle that already inlined them. On-demand rebuild is the loop.

How the check works. Before launching, the wrapper compares apps/cli/dist/index.js against apps/cli/ and every packages/*/ — the package roots, not just their src/, because turbo run build --filter=@airshiplabs/cli --dry=json shows the real input set reaching package.json, tsconfig.json, tsup.config.ts, scripts/ and those assets/ trees. Build output and machinery (dist, node_modules, dotted directories) are skipped. If anything is newer it runs turbo run build --filter=@airshiplabs/cli — the CLI's slice of the graph, so apps/web is never touched — and otherwise launches straight through, for about 90 ms of overhead.

Two details worth knowing when it surprises you:

  • It over-triggers rather than under-triggers, on purpose. Turbo remains the authority on what actually needs rebuilding; the mtime scan is only a cheap doorman deciding whether to ask it. A false positive costs one cached turbo run. A false negative runs stale code, which is the bug being fixed. So editing packages/site-tokens — which only apps/web uses — will rebuild the CLI, and that is fine.
  • A successful build stamps dist/index.js. Turbo hashes file contents, so on a cache hit it replays logs and leaves dist/ untouched — meaning a plain mtime comparison would never converge and would rebuild on every single invocation forever. Anything that moves timestamps without changing bytes (git checkout and back, git stash pop, an ultracite fix pass) hits exactly that path.

Finally: run ./airship, not airship. If you have @airshiplabs/cli installed globally, the bare name runs the published binary from inside this repo, and --version will often not tell them apart.

Three turbo edges worth knowing

All three are the same edge for the same reason: a package's dist has to exist before something else can start against it.

  • @airship/web#dev depends on @airship/site-tokens#build, because Vite has no dist/tokens.css to import until that package's postbuild has emitted it. Start the site through turbo (make web:dev, pnpm dev:web) rather than with a bare vite dev.
  • Storybook must start through turbo for the same reason — see below.
  • @airshiplabs/cli#build depends on @airship/overlay#build and @airship/editor-tokens#build, on top of the usual ^build. Those two are not imported, they are served: packages/server/src/proxy.ts resolves the overlay IIFEs and the editor fonts at runtime, so no bundler can inline them and their dist has to be on disk. It is also why ./airship rebuilds through turbo rather than calling tsup itself.

Hooks

pre-commit blocks direct commits to main and runs ultracite fix over staged files. commit-msg runs commitlint. pre-push blocks pushes to main. The test suite deliberately does not run in pre-commit: it runs in CI on every PR and locally via make preflight, and a full turbo run test on every commit would tax exactly the fast checkpoint commits this repo lives on while catching nothing the PR gate would not.

Architecture

canvas shell ──ws──► proxy/server ──► @airship/core ──► claude │ codex │ opencode ──► edits your files
  ├ frame 1440×900     (serve + route)   (adapter)          (agent backend)          (diff + undo)
  └ frame  393×852
    (live app, pick element)

runEdit() owns everything that does not depend on which agent runs — the rendered prompt, the activity timeline, diff capture, and result assembly — and dispatches the rest through AgentAdapter (packages/core/src/providers/).

Claude

Leans on the Agent SDK rather than hand-rolling a subprocess:

  • query() streaming-input loop — typed SDKMessages; native image input; cancel via AbortController.
  • includePartialMessages — token-level streaming of Claude's text to the overlay.
  • PreToolUse hooks — robust before/after diff capture, plus (under --safe) a sandbox that denies edits/commands outside the project.
  • createSdkMcpServer + tool() — a get_element_context tool exposing the selection to Claude.
  • outputFormat (JSON schema) — typed { summary, filesChanged, followUps } per edit.
  • enableFileCheckpointing + rewindFiles — native undo (with content-restore fallback).
  • resume / forkSession — multi-turn refinement on the same session.
  • settingSources: ["project"] — respects the target repo's CLAUDE.md.
  • maxTurns / maxBudgetUsd / effort — per-edit guardrails; usage/cost reported back.

Codex

The Codex SDK spawns codex exec --experimental-json and streams JSONL. It offers less, and the adapter is explicit about each gap rather than faking it:

Claude Codex
Text streaming token-level whole messages at completion
Diff before side PreToolUse snapshot reconstructed from the git HEAD blob
Tool screening hooks + canUseTool the CLI's own OS sandbox
Selection re-read get_element_context MCP tool inlined in the prompt
Structured output validated by the SDK model JSON, zod-parsed defensively
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 both and the transcript reads the same either way.

OpenCode

Structurally unlike the other two: OpenCode is a client/server pair, not an in-process iterator. The SDK shells out to opencode serve and speaks HTTP + SSE, so a turn is a blocking session.prompt raced against a global event subscription carrying every session on that server. Airship keeps one lazily started server per process and filters every event by session id.

It is the most capable of the three in several places, and the adapter uses all of them:

Codex OpenCode
Text streaming whole messages at completion token-level (message.part.delta)
System prompt rides on the first turn's text a real system field
Fork a session starts a fresh thread, and says so native session.fork, history intact
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:

  • No bundled binary. @opencode-ai/sdk ships only a spawn helper; the opencode CLI is a separate install. checkAuth() scans PATH and says so.
  • No OS sandbox. See the safety section in the README.
  • No in-process MCP — MCP servers are config-declared subprocesses, so the selection is inlined in the prompt as it is for Codex.
  • --model wants provider/model. A bare id cannot be resolved to a provider, so it is dropped with a warning rather than silently ignored.
  • Structured output is fragile twice over. On the wire, format is a forced tool call — opencode registers an internal StructuredOutput tool and sets toolChoice: "required", which providers reject outright on thinking/reasoning models (opencode#15226, closed upstream). The adapter classifies that rejection, retries the turn once without format, and remembers the model. In the text, the JSON arrives inside <structuredoutput> tags within ordinary prose — a convention the system prompt now carries too, so it survives format being dropped — and opencode's own extractor frequently fails to lift it back out. The adapter parses the tag itself and strips it from the streamed transcript on the way past — including when the opening tag arrives split across two deltas.

The wire types are declared in providers/opencode-wire.ts rather than imported from the SDK: the SDK's generated Event union omits message.part.delta entirely (237 of 313 events in a real turn), omits server.heartbeat, and declares a permission.updated the server does not emit in place of the permission.asked it does. Trusting it would drop streaming and deadlock every permission request while type-checking cleanly.

The site

apps/web does two jobs. It is Airship's home page, and it is the app the run targets point the CLI at — so the demo in its hero is a picture of the tool editing the very page you are reading.

It is a TanStack Start (Vite 7) app inside this workspace, styled from packages/site-tokens (--pk-*, generated from that package's DESIGN.md).

The hero recreates Airship's inline mode in miniature and animates the thing the tool actually claims: pick an element, describe the change, the agent edits the file — composer, streaming tool calls, diff, and the button changing on the page. It is a static recreation, not the editor: the chrome is HTML and a ten-second CSS timeline, sharing only the --ap-* palette with the real thing, which site-tokens re-emits scoped to that one subtree so it cannot drift.

Two things about it are editorial rather than faithful, and are commented as such where they live. The real overlay runs two docks — chat left, inspector right — while the hero folds them into one panel with the agent as the front tab (design-dock.tsx); and the hero's glyphs are hand-drawn rather than the vendored set the editor uses. Everything inside those panels is transcribed 1:1.

?frame=62 freezes the loop at any percent for inspection, and prefers-reduced-motion pins it at the frame that explains the most.

Page copy lives in apps/web/src/content/*.json so it can be edited without opening a component, and content/resolve.ts is the only place that turns a {{token}} or a link key into a real value. That indirection is why the install command appears once, in site.json, and 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:

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:

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:

make web:og      # rewrites apps/web/public/og.png, then commit it

apps/web/scripts/og.mjs renders an HTML card in headless Chromium at a fixed 1200×630 and screenshots it. The heading and the install command are read from hero.json and site.json — the same files the page renders from — because those are the two strings that drift. The card spent a while advertising a headline the page had stopped using and an install command that installed somebody else's package, which is what a picture of copy gets you when nothing checks it. The one line the script owns is the blurb, and there is a comment saying why.

It needs @airship/site-tokens built, since it embeds Inter and JetBrains Mono from that package's dist/fonts; the make target builds it first.

Storybook

make storybook       # the catalogue on :6006
make test:browser    # every story, as a test, in real Chromium
make browsers        # one-time: download that Chromium

packages/overlay carries a Storybook of the editor's own chrome — the inspector's controls, its sections, the whole Design panel, the chat timeline, diffs, toasts and the device presets. Stories are colocated next to the code they cover (src/**/*.stories.ts), the same convention the .test.ts files already follow.

It exists for two reasons that are really one.

The panel is hard to look at. The only other way is make web:dev plus make run, then picking an element and hoping it lands in the state you wanted. States that matter and are awkward to reach by hand — Mixed across a multi-selection, a token-bound field, a locked aspect ratio, a six-stop gradient, an element matched by fourteen rules, a Bash that failed next to an Edit still running — are effectively unreviewable. Each of those is now a URL.

It is the real-browser half of the test suite. packages/overlay/vitest.config.ts runs on happy-dom, and its own docstring — plus inspector/test-support.ts's — lists what that costs: no layout, no native CSS nesting, @layer dropped outright, no CSSStyleDeclaration iterator. Those are the four things the inspector reads. make test:browser runs the same stories under @vitest/browser where all four are real, which is why the CSS pane has a story at all — style-model.ts notes it "could never be rendered in a test".

Two things are worth knowing before you touch it.

  • Start it through turbo. .storybook/main.ts maps @airship/editor-tokens' dist/fonts onto /__airship/fonts, which is where the overlay's @font-face rules point; Storybook refuses to start when a staticDirs source is missing, so a bare pnpm --filter @airship/overlay storybook fails on a clean checkout.
  • The browser tier is deliberately outside pnpm test. It has its own config (vitest.browser.config.ts) and its own script, because a test tier that launches Chromium fails on any machine that has not downloaded it. It is also outside CI for now — @storybook/addon-a11y fails the run on axe violations and the overlay still has a backlog of them, so the lane would start red. make test:browser runs it on demand; make browsers fetches the Chromium it needs.

Section stories render one section inside a real DesignPanel rather than against a stand-in SectionContext, by shadowing one private method. The reasoning, and the six things that go quietly wrong if you do it the other way, are in src/stories/story-panel.ts.

Testing

pnpm test runs vitest across the workspace — around sixty suites, concentrated in packages/overlay (the inspector's CSS reasoning), packages/core (provider event mapping, diff capture, the sandbox), and apps/cli/src/lib (argument parsing, config resolution, port detection, help rendering).

The CLI's own tests are worth reading before changing args.ts: citty parses but does not validate, and args.test.ts pins the behaviour that covers the difference.

The browser tier is separate — see Storybook above.

CI

Every PR into main runs:

  • checks.yml — lint, then turbo typecheck test scoped with --affected against the PR base. make preflight is the local equivalent, minus the scoping: locally there is no base, so it runs repo-wide, and it layers on a route-tree drift check.
  • branch-policy.yml — only release/*, hotfix/*, security/* and dependabot/* branches may target main. Releases are cut from release/*, so this is what keeps the release lane legible, and what makes dependabot/* an explicit exception rather than an accident.

The site's deploy is not in this repo. apps/web ships through Cloudflare Workers Builds, configured in the Cloudflare dashboard and wired to GitHub by the Cloudflare Workers & Pages GitHub App — so there is no web-deploy.yml to find, and no CLOUDFLARE_* secret on the repo. A push to main touching apps/web/, packages/site-tokens/ or packages/editor-tokens/ runs pnpm -w run build:web and then wrangler deploy, updating the airship-web worker. Any other branch runs wrangler versions upload instead, which publishes a version rather than promoting it: the app posts that version's preview URL onto the PR, and production is untouched until the merge. Build logs live in the dashboard, not in the Actions tab.

This is why apps/web/wrangler.jsonc declares no env block — one worker, and the branch decides the command. make web:deploy remains as a manual override that authenticates as you.

What gets deployed is prerendered, not server-rendered. vite.config.ts passes prerender: { enabled: true } to tanstackStart(), so the build runs the server bundle once and writes dist/client/index.html — which is the worker's assets directory, so Cloudflare serves the page as a static file and the worker is never invoked for a normal view. It is still built and still deployed: it answers whatever the assets do not match, which is what renders the 404.

That has a consequence worth knowing before you reach for one. A server function or route loader added to this app will run at build time, not per request — its result gets baked into the HTML. That is correct for this site, which has one route and imports all of its copy from src/content/*.json, but the day the page genuinely needs request-dependent output, turning prerendering off is the change to make, not working around it.

Releases

@airshiplabs/cli is the only package published to npm. Everything else in the workspace is private and gets inlined into the CLI bundle at build time, so the published tarball declares no @airship/* dependency.

The overlay IIFEs and the editor fonts cannot be inlined — they are assets the server resolves at runtime — so apps/cli/scripts/vendor-assets.mjs copies them into dist/vendor/, and packages/server/src/proxy.ts falls back to that copy when require.resolve finds nothing. That fallback is the whole reason a published install works; if the overlay ever 404s from npm but not from source, start there.

Two ways to cut a release, and they never both publish:

make release              # local: bump, validate packaging, commit, tag — then
                          # `git push --follow-tags` fires release.yml
make release:ci BUMP=minor  # all-in-CI: publish.yml does the whole thing
make release DRY=1        # validate everything, write nothing

publish.yml pushes its tag with GITHUB_TOKEN, which by design does not trigger other workflows — so release.yml stays dormant for CI-cut releases. Both need an NPM_TOKEN secret on the repo.

Design decisions

Conscious engineering choices, not oversights:

  • Diff rendering is a self-contained renderer, not @pierre/diffs + shiki. Keeps the overlay IIFE small and dependency-light (no shiki in the browser bundle). The server still computes patches with diff.
  • Undo is content-restore first (instant, from the before-state captured by the SDK PreToolUse hooks on Claude, or reconstructed from git on Codex). SDK rewindFiles is implemented (core.rewindEdit) as the SDK-native alternative on Claude; the two are not run concurrently. Codex has no native checkpointing, and rewindEdit says so rather than silently doing nothing.
  • Session persistence uses the SDK's own persistSession plus Airship's ~/.airship/history bundles (which record each sessionId), so resume survives a daemon restart — without a custom SessionStore. A SessionStore adapter (S3/Redis) is the path for multi-host hosted mode.
  • The minimap draws rectangles, not thumbnails. Every frame is a live same-origin iframe running a full instance of the user's app. There is no paint capture anywhere in the overlay and no cheap way to add one — FrameAgent exposes DOM, not pixels — an iframe cannot be duplicated, and cloning one would boot a ninth app against a cap of eight. So canvas/minimap.ts positions plain divs, the way chrome-layer.ts draws every other piece of canvas chrome.
  • Frame reorder publishes z-index; it never moves DOM nodes. Moving an iframe in the document tears down and rebuilds its browsing context, so the obvious implementation of drag-to-restack would reload the app inside whichever frame you dragged, losing its route and scroll position. FrameManager.reorder splices the array and applyOrder writes the index as z-index instead; frames.test.ts asserts that no element moves.
  • A feedback: "none" draggable must be registered POINTER_ONLY. dnd-kit's KeyboardSensor needs dragOperation.shape, which only the Feedback plugin publishes — so a "none" draggable starts a keyboard drag that no arrow key can move, and swallows the Tab out of it. Nine of the overlay's draggables have no keyboard route for this reason; where one is wanted it is a real command beside the drag, not a keyboard drag (see FramesPanel.moveBy, and num-field.ts's stepBy beside its scrub).
  • Deferred for now: multi-element select, startup() pre-warm (a latency optimization that requires threading a warm handle through each edit), and the Vite/Next plugin wrappers plus hosted mode.

reference/

reference/ holds the original spidey-sense, layrr, element-source, and diffity projects for context — Airship unifies the ideas behind the first two on a single core. It is git-ignored and never built.