Skip to content

Developing pdum_aiui

Setting up to work on this repo. (To use the tools, start at Getting Started instead.)

Requirements

  • Node 24+
  • pnpm 11+
sh
pnpm install

Activate your shell (optional, venv-style)

sh
eval "$(./aiui env)"

This is the repo's . .venv/bin/activate: it prepends bin/ (the source-run aiui shim — no build needed) and the workspace node_modules/.bin (tsx, vite, vitest, biome) to your PATH, and exports the root .env / .env.dev files (OPENAI_API_KEY, GEMINI_API_KEY) into the current shell — so aiui claude picks the keys up without a manual export. (A source checkout is exactly where the environment still wins for vendor keys; installed aiui reads the OS vault instead.) Undo with aiui_deactivate. The script is idempotent and prints what it did (key names only) to stderr.

There's no pnpm-native equivalent (pnpm exec scopes one command, pnpm bin just prints the path). For automatic activation on cd — the zsh-friendly version — install direnv, hook it into your shell (eval "$(direnv hook zsh)" in ~/.zshrc), and run direnv allow once: the checked-in .envrc does the same PATH + dotenv setup, and undoes it when you leave the directory.

Working in the repo

sh
pnpm build           # build every package (Vite library mode + tsc .d.ts)
pnpm test            # run all tests (Vitest)
pnpm typecheck       # tsc --noEmit across packages
pnpm lint            # Biome (lint + format check)
pnpm test:packaging  # pack every publishable package, install into a scratch npm project, smoke the CLIs
pnpm test:e2e        # live Claude Code session e2e (spends subscription usage)

(The intent overlay's offline lab — the workbench — has been retired; packages/aiui-test-app and the in-repo demos are the places to exercise the overlay now. Its findings ledgers are in git history, and the interaction fixtures it recorded live on as the intent pipeline's regression corpus in packages/aiui-lowering-pipeline/fixtures/.)

Editable workspace dependencies (source-first)

Cross-package dependencies in this monorepo behave like Python editable installs: edit a package, and everything that consumes it — the demo's dev server, a sibling package's tests, the tsx-run CLI — picks the change up immediately, no build step. This works because every package's dev manifest points at source, while the published dist/ mapping lives in publishConfig, which pnpm swaps in at pack/publish time:

json
"exports": { ".": "./src/index.ts" },
"publishConfig": {
  "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }
}

Everything in the dev loop (Vite, Vitest, tsx) transpiles TypeScript from linked packages, so resolving to .ts is free; npm consumers see only the publishConfig (dist) form. This extends to packages shipping JSX (aiui-viz exports .tsx in dev): vite-plugin-solid compiles any served .tsx and only suppresses solid-refresh for /node_modules/ ids — workspace links resolve to real packages/* paths, so linked component sources get compilation and HMR in consumers. Three things keep the scheme honest:

  • bin entries still point at dist/ — bins run under plain node from installed tarballs. In-workspace, CLIs are spawned from source via tsx (./aiui, and resolvePackageCli for package-to-package spawning), so this costs nothing.
  • The packaging test is the guard for the published form. Source-first dev means the workspace never exercises dist/ — and some bugs only exist there. The classic: import.meta.env.* is substituted when a package is built, so prebuilt code can never read its consumer's env (the reason runtime configuration for prebuilt code travels through runtime channels — injected globals, plugin-generated modules). Run pnpm test:packaging whenever packaging fields or build config change.
  • Never optimizeDeps.include a workspace package in a Vite config — the dep-optimizer cache is keyed by the lockfile, not file contents, so the linked package would be served stale after every edit.

New packages inherit all of this from the pnpm new-package skeleton. When adding a subpath export (like the overlay's ./vite), add it to both the dev exports and the publishConfig.exports.

Hot-reloading the running channel

Source-first dev picks up edits everywhere the code is transpiled on demand — but the aiui-claude-channel MCP server is a long-lived process behind a live Claude Code session, so its already-loaded lowering code doesn't update on edit like a Vite page does. It can reload in place instead: the channel_reload MCP tool (or POST /debug/api/reload, or AIUI_CHANNEL_WATCH=1 for auto-reload on save) rebuilds the format registry from the code now on disk without restarting the process. Live websockets drop and reconnect on their own; the session and the web port stay up. Reload reaches the format entry modules (processors.ts, intent-v1.ts) and their edits — deeper changes still want a relaunch. See the channel's websocket-protocol doc for what survives and what drops.

The packaging test

This repo's tools assume they can be consumed — added as a dependency of your own project — but the unit suite only ever exercises the workspace layout, where src/ exists, workspace links resolve, and gitignored build output may or may not be present. pnpm test:packaging (scripts/packaging-test.mjs, also a CI job) closes that gap: it builds, pnpm packs every publishable package, installs the tarballs into a scratch npm project (tarballs satisfy each other's @habemus-papadum/* ranges; the registry only serves third-party deps), and drives the installed bins the way a consumer would — aiui --help, aiui chrome extension/status, aiui mcp --help, and aiui claude failing politely without claude on the PATH. It's the test that catches a missing files entry (like the devtools extension shipping without its built js/), a bin that only resolves in the workspace, or a dependency that should have been a devDependency. It doesn't launch Claude Code or a browser.

Working on the docs

The documentation site is powered by VitePress with an TypeDoc API-reference step. Four scripts drive it:

sh
pnpm docs:gen      # regenerate the package pages + API reference + sidebar
pnpm docs:dev      # generate, then serve with hot reload at http://localhost:5173
pnpm docs:build    # generate, then build the static site into docs/.vitepress/dist
pnpm docs:preview  # serve the built static site locally

docs:dev and docs:build run docs:gen for you first, so the everyday loop is just:

sh
pnpm docs:dev

Then open the printed local URL. Edit any Markdown under docs/ or any package README.md and the site hot-reloads. (Changes to a package's API — its TypeScript source — need a pnpm docs:gen re-run to re-extract.)

How the site is generated

A small "MkDocs for a pnpm monorepo": VitePress renders, TypeDoc extracts the API reference, and scripts/docs-gen.mjs is the glue — it discovers packages with the same packages/* glob everything else uses, copies each README.md in as the overview, each packages/<slug>/docs/*.md as guides, runs TypeDoc per package, and writes the sidebar. Two altitudes:

  • Top-level conceptual docs — hand-written under docs/guide/, listed in the curated sidebar in docs-gen.mjs. Generation fails if a page has no sidebar link or a link has no page, so every add/delete is also a sidebar edit. A note that has served its purpose is deleted outright — git history is the archive.
  • Per-package docs — assembled from the package itself (README + docs/*.md + TSDoc), with no curation: a new package, or a new page beside one, appears on the next docs:gen.

Only hand-written content is committed. docs/packages/** and docs/.vitepress/sidebar.generated.json (plus VitePress cache//dist/) are generated, gitignored, and rebuilt from scratch each run — never edit them by hand. Two shipped Claude skills link into the docs with relative paths; pnpm skills:check verifies every link resolves (CI runs it), and pnpm docs:lint catches the bare <placeholder> tags that break VitePress's Vue compile.

Adding a new package

Nothing special is required for docs. Create the package as usual:

sh
pnpm new-package my-lib --public

The scaffold ships a README.md and a docs/ folder with a starter page. The next pnpm docs:gen (or docs:dev) automatically picks the package up: its README becomes the overview, its docs/*.md become guides, and its exports become an API reference — no doc config to touch.

Releasing & publishing

Publishing is CI-only: a manual workflow_dispatch on .github/workflows/release.yml, authenticated by the NPM_TOKEN repo secret (an npm token from the owner's account — gh secret set NPM_TOKEN --repo habemus-papadum/pdum_aiui to set or rotate; it must be allowed to publish without an OTP). A new package needs no provisioning — its first release simply publishes it. (--provenance attestation rides the workflow's id-token: write permission, independent of auth. Until 2026-08-11 auth was npm trusted publishing/OIDC; it was retired because OIDC needs a per-package, website-only registration on a passkey account.)

sh
gh workflow run release.yml -f bump=minor   # cut a release
pnpm npm:list                               # what release.yml would publish
pnpm npm:reserve [slug…]                    # optional: claim a name early (placeholder publish)

The workflow gates on green CI, writes the lockstep version across every package.json, tags, builds, and runs one pnpm -r publish --provenance --no-git-checks (pnpm skips private: true members, applies the publishConfig dist swap, rewrites workspace:^ to the real version). Never pnpm publish locally — see AGENTS.md and CLAUDE.md's publication convention (per-package levels, --public/--private/--no-publish) for the guardrails.

Every string boundary is a compiler you can't see

When program text crosses into another language's string literal — JS inside a TS template literal, shell emitted by aiui env, JSON riding a CLI flag — escape processing happens once per layer, and the inner program sits in the blind spot of every tool: tsc doesn't parse it, Biome doesn't lint it, your editor doesn't highlight it. This bit for real: the iPad paint client once lived in a TS template literal, and a "\n" typed into its inline JS shipped a raw newline inside a string literal — a SyntaxError that left every viewer stuck on "Connecting…", with nothing anywhere to say why.

The rules, in order of preference:

  1. Don't embed — load a real file of the target language. The paint client is now packages/aiui-paint/assets/ipad-client.html, read at import with a path computed from import.meta.url and shipped via the package's files (an eager, module-level read makes a missing asset fail at import, where pnpm test:packaging catches it). Note: Vite's ?raw import is not an option for anything that must also run under tsx (aiui claude) — tsx doesn't support it.
  2. If you must emit code as strings (the generator is the point, e.g. aiui env's shell output), keep the generator trivial and test the emitted artifact by parsing or executing itaiui env's output round-trips through a real sh in its tests; the paint client's inline script is parsed with new Function in its.
  3. If you must hand-author inside a literal, state the escaping rules in a comment at the top of the literal and enforce them with a test. A comment alone already failed once.