- apps/cli/ is now the canonical CLI (was apps/cli-v2/). - apps/cli/ legacy v0 archived as branch 'legacy-cli-archive' and tag 'cli-v0-legacy-final' before deletion; git history preserves it too. - .github/workflows/release-cli.yml paths updated. - pnpm-lock.yaml regenerated. Broker-side peer-grant enforcement (spec: 2026-04-15-per-peer-capabilities): - 0020_peer-grants.sql adds peer_grants jsonb + GIN index on mesh.member. - handleSend in broker fetches recipient grant maps once per send, drops messages silently when sender lacks the required capability. - POST /cli/mesh/:slug/grants to update from CLI; broker_messages_dropped_by_grant_total metric. - CLI grant/revoke/block now mirror to broker via syncToBroker. Auto-migrate on broker startup: - apps/broker/src/migrate.ts runs drizzle migrate with pg_advisory_lock before the HTTP server binds. Exits non-zero on failure so Coolify healthcheck fails closed. - Dockerfile copies packages/db/migrations into /app/migrations. - postgres 3.4.5 added as direct broker dep. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
10 KiB
CLI Wizard Architecture Refactor
Status: backlog
Created: 2026-04-10
Source: Reverse-engineered from @posthog/wizard (npm cache), applied to apps/cli/src/commands/launch.ts
Why
Launch wizard has three compounding problems:
- Imperative branching —
launch.tschecks account → mesh → name → role → exec in hardcoded order. Adding a screen requires touching existing code. Hard to reason about--resume,--non-interactive, and skip conditions. - Terminal bleed-through on handoff — wizard→
claudeexec corrupts Ink's TUI state (garbled word wraps, tool labels overwritten, spinner fragments fused to paths). Root cause is spread across multiple exit paths instead of one choke point. - Inconsistent visual design — ad-hoc colors per file, no central palette, no shared icon set, no shared layout primitives. Every screen reinvents status rows, centering, and spacing.
PostHog's wizard solves all three with one architectural pattern: declarative flow pipelines + session-as-store + shared visual primitives. This artifact captures the plan to port that pattern.
What PostHog does (the reference)
Flow pipeline (flows.ts + router.ts)
Each wizard flow is an array of screen entries:
export const FLOWS = {
[Flow.Wizard]: [
{ screen: Screen.Intro, isComplete: s => s.setupConfirmed },
{ screen: Screen.HealthCheck, isComplete: s => s.readinessResult !== null },
{ screen: Screen.Setup, show: needsSetup, isComplete: s => !needsSetup(s) },
{ screen: Screen.Auth, isComplete: s => s.credentials !== null },
{ screen: Screen.Run, isComplete: s => s.runPhase === RunPhase.Completed },
{ screen: Screen.Outro, isComplete: s => s.outroDismissed },
],
};
The router walks the array, skips entries where show(s) === false or isComplete(s) === true, and returns the first remaining entry. Zero switch statements. Zero hardcoded transitions. Adding a screen = appending an object.
Overlay stack
Separate from the linear flow cursor. Interrupts (port conflict, auth expired, managed settings) are pushed onto overlays[] from anywhere and popped when dismissed. Active screen = top of overlay stack OR flow cursor. Flows never need to know about interrupts.
Session as single source of truth
One WizardStore holds all session state. Screens subscribe via React 18 useSyncExternalStore. Completion predicates read session; imperative code writes session; the router re-resolves on every change.
Visual primitives
styles.ts— 6-color palette (Colors), 9-icon set (Icons), alignment enums (HAlign,VAlign)CardLayout— semantic centering wrapper used by every screenPickerMenu— the only selection primitive, used for every choicescreen-registry.ts— mapsScreenenum → React component- Brand mark: three colored
█blocks next to the wizard name on every screen header
What claudemesh should do
Target file layout
apps/cli/src/
├── commands/
│ └── launch.ts # thin entrypoint: parse flags → start TUI
└── ui/
├── styles.ts # palette, icons, alignment enums
├── store.ts # LaunchStore (session + subscribe)
├── router.ts # flow cursor + overlay stack
├── flows.ts # FLOWS = { Launch: [...], Join: [...] }
├── screen-registry.ts # Screen enum → component
├── primitives/
│ ├── CardLayout.tsx
│ ├── PickerMenu.tsx
│ ├── StatusRows.tsx # new: "Directory ✓ /claudemesh" pattern
│ ├── BrandMark.tsx # new: 3 colored squares + label
│ └── LoadingBox.tsx
└── screens/
├── WelcomeScreen.tsx
├── AccountScreen.tsx
├── MeshPickerScreen.tsx
├── NameRoleScreen.tsx
├── ConfirmScreen.tsx
└── HandoffScreen.tsx # last screen; its unmount triggers exec claude
Flow definition
export const FLOWS = {
[Flow.Launch]: [
{ screen: Screen.Welcome, isComplete: s => s.welcomed },
{ screen: Screen.Account, show: s => !s.hasAccount, isComplete: s => s.hasAccount },
{ screen: Screen.MeshPicker, show: s => s.meshes.length > 1, isComplete: s => s.meshSlug !== null },
{ screen: Screen.NameRole, isComplete: s => s.displayName !== null && s.role !== null },
{ screen: Screen.Confirm, isComplete: s => s.confirmed },
{ screen: Screen.Handoff, isComplete: () => false }, // terminal screen
],
};
--resume works for free
--resume <id> populates the session from saved state; every satisfied predicate auto-skips. The wizard renders only the screens that still need input. No special --resume branches in screen code.
--non-interactive works for free
Non-interactive mode: walk the flow, for each incomplete entry check if its required session fields can be sourced from CLI flags. If yes, populate and continue. If no, fail fast with a clear message naming the missing flag. Never silently guess defaults.
$ claudemesh launch --non-interactive --name Alexis
✗ Missing --mesh (required in non-interactive mode when >1 mesh joined)
Available meshes: alexis-mou, dev, staging
Overlay interrupts claudemesh needs
BrokerDisconnect— WS dropped mid-wizard, retry countdownInviteInvalid— paste invite screen rejected tokenMeshNotFound—--mesh foopassed but not joinedRateLimit— broker rate limited the CLI, backoff timerUpdateAvailable— newer CLI version on npm, non-blocking banner
Terminal handoff choke point
The last flow entry (Screen.Handoff) renders a brief "Launching Claude Code…" card, then:
// apps/cli/src/ui/screens/HandoffScreen.tsx (on mount)
useEffect(() => {
(async () => {
await inkApp.unmount();
await inkApp.waitUntilExit();
resetTerminal(); // single choke point for ANSI teardown
await flushStdout();
execa('claude', claudeArgs, { stdio: 'inherit' });
})();
}, []);
resetTerminal() lives in apps/cli/src/ui/terminal.ts:
export function resetTerminal() {
process.stdout.write(
'\x1b[0m' + // reset SGR
'\x1b[?25h' + // show cursor
'\x1b[?1049l' + // exit alt-screen
'\x1b[?1000l' + // disable mouse tracking
'\x1b[?1002l' +
'\x1b[?1003l' +
'\x1b[?1006l' +
'\x1b[?2004l' + // disable bracketed paste
'\x1b[2J' + // clear screen
'\x1b[H' // cursor home
);
if (process.stdin.isTTY) process.stdin.setRawMode(false);
}
PostHog only does SGR reset + clear + home on unmount — they don't hand off to another full-screen app, so that's enough for them. Claudemesh needs the full mode-reset because Claude Code takes over the TTY.
Visual design system
apps/cli/src/ui/styles.ts:
export const Colors = {
primary: 'cyan',
accent: '#7C3AED', // claudemesh purple
title: '#4C1D95',
success: 'green',
error: 'red',
warning: 'yellow',
muted: 'gray',
} as const;
export const Icons = {
check: '✔',
cross: '✘',
warning: '⚠',
arrow: '▶',
smallArrow: '▸',
bullet: '•',
diamond: '◆',
square: '█',
} as const;
export enum HAlign { Left = 'flex-start', Center = 'center', Right = 'flex-end' }
export enum VAlign { Top = 'flex-start', Center = 'center', Bottom = 'flex-end' }
Every screen imports from here. No inline color strings allowed.
Status rows pattern
Replaces the current plain-text banner:
██ claudemesh launch
Directory ✔ /claudemesh
Account ✔ agutierrez@mineryreport.com
Mesh ✔ alexis-mou (9 peers online)
Name ✔ Alexis
Role ▸ (pick one)
▸ Continue
Change mesh
Cancel
Implementation order
| # | Impact | Effort | Scope |
|---|---|---|---|
| 1 | High | S | ui/styles.ts — palette + icons + alignment enums; migrate existing screens |
| 2 | High | S | ui/primitives/StatusRows.tsx + BrandMark.tsx |
| 3 | High | M | ui/store.ts + ui/router.ts + ui/flows.ts (flow pipeline core) |
| 4 | High | M | Refactor launch.ts to render through router; port existing screens |
| 5 | High | S | HandoffScreen + resetTerminal() choke point — fixes TUI bleed bug |
| 6 | High | S | Preselect "Continue" on every confirmation screen (one-keypress happy path) |
| 7 | Med | M | Overlay stack + first two overlays (BrokerDisconnect, InviteInvalid) |
| 8 | Med | M | --non-interactive mode using flow walker + fail-fast flag check |
| 9 | Med | S | Per-mesh/per-role preRunNotice extension point |
| 10 | Low | L | DissolveTransition / ContentSequencer polish primitives |
Steps 1–5 are the atomic unit of value: they fix the bleed-through bug, establish the visual system, and unblock everything else. Should ship as one PR. Steps 6–9 can each ship independently. Step 10 is polish — defer until after v0.2.
Open questions
- Ink version: current CLI uses Ink 4.x? PostHog is on Ink 5 with
useSyncExternalStore. Checkapps/cli/package.jsonbefore porting the store pattern — Ink 4 needs a different subscription approach. - React version:
useSyncExternalStoreis React 18+. Confirm. - Flow granularity: should
Join(paste invite) be a separate flow fromLaunch, or an overlay insideLaunch? PostHog-style: separate flow triggered from the welcome screen. Simpler. - Resume semantics: does
--resume <id>resume the Claude session only, or also restore the wizard's last mesh/name/role choice? If the latter, need a~/.claudemesh/sessions/<id>.jsonalongside Claude's own session file.
References
- PostHog wizard source:
~/.npm/_npx/b48b11b34a0cada0/node_modules/@posthog/wizard/dist/src/ui/tui/start-tui.js— Ink bootstrap + cleanuprouter.js— flow cursor + overlay stackflows.js— declarative pipeline definitionstyles.js— palette + iconsscreens/IntroScreen.js— reference for status rows + pickerprimitives/CardLayout.js— semantic centering