Files
claudemesh/.artifacts/backlog/2026-04-10-cli-wizard-architecture-refactor.md
Alejandro Gutiérrez ee12510ef1
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
refactor: rename cli-v2 → cli, archive legacy cli, plus broker-side grants + auto-migrate
- 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>
2026-04-15 08:44:52 +01:00

10 KiB
Raw Permalink Blame History

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:

  1. Imperative branchinglaunch.ts checks 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.
  2. Terminal bleed-through on handoff — wizard→claude exec 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.
  3. 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 screen
  • PickerMenu — the only selection primitive, used for every choice
  • screen-registry.ts — maps Screen enum → 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 countdown
  • InviteInvalid — paste invite screen rejected token
  • MeshNotFound--mesh foo passed but not joined
  • RateLimit — broker rate limited the CLI, backoff timer
  • UpdateAvailable — 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 15 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 69 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. Check apps/cli/package.json before porting the store pattern — Ink 4 needs a different subscription approach.
  • React version: useSyncExternalStore is React 18+. Confirm.
  • Flow granularity: should Join (paste invite) be a separate flow from Launch, or an overlay inside Launch? 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>.json alongside 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 + cleanup
    • router.js — flow cursor + overlay stack
    • flows.js — declarative pipeline definition
    • styles.js — palette + icons
    • screens/IntroScreen.js — reference for status rows + picker
    • primitives/CardLayout.js — semantic centering