Skip to content

Infrastructure TUI Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a dashboard-first Ink (React) terminal UI that wraps the Java MCP server over stdio, giving users full manual infrastructure management without an AI agent.

Architecture: The TUI lives at tui/ in the infrastructure-mcp repo. It spawns the Java MCP server as a child process and speaks JSON-RPC over stdio. All business logic stays in Java — the TUI is purely presentation. Config loads from ~/.infrastructure-mcp.json with fallback to ~/.claude.json.

Tech Stack: TypeScript, React 18, Ink 5, Vitest, ink-testing-library, string-width, meow


Task 1: Project scaffold and build tooling

Files: - Create: tui/package.json - Create: tui/tsconfig.json - Create: tui/vitest.config.ts - Create: tui/bin/cli.ts - Create: tui/src/app.tsx

  • [ ] Step 1: Initialize project
cd /home/matt/mcp/infrastructure-mcp
mkdir -p tui/bin tui/src/screens tui/src/components tui/src/hooks tui/tests/screens tui/tests/components
  • [ ] Step 2: Create package.json

Create tui/package.json:

{
  "name": "infrastructure-tui",
  "version": "1.2.0",
  "description": "Terminal UI for infrastructure management via MCP",
  "type": "module",
  "bin": {
    "infrastructure-tui": "./dist/bin/cli.js"
  },
  "scripts": {
    "build": "tsc",
    "start": "tsx bin/cli.ts",
    "dev": "tsx watch bin/cli.ts",
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "dependencies": {
    "ink": "^5.2.0",
    "ink-select-input": "^6.0.0",
    "ink-spinner": "^5.0.0",
    "ink-text-input": "^6.0.0",
    "meow": "^13.0.0",
    "react": "^18.3.0",
    "string-width": "^7.0.0"
  },
  "devDependencies": {
    "@types/react": "^18.3.0",
    "ink-testing-library": "^4.0.0",
    "tsx": "^4.0.0",
    "typescript": "^5.7.0",
    "vitest": "^3.0.0"
  }
}
  • [ ] Step 3: Create tsconfig.json

Create tui/tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "jsx": "react-jsx",
    "outDir": "dist",
    "rootDir": ".",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "sourceMap": true
  },
  "include": ["bin/**/*", "src/**/*"],
  "exclude": ["node_modules", "dist", "tests"]
}
  • [ ] Step 4: Create vitest.config.ts

Create tui/vitest.config.ts:

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
    include: ["tests/**/*.test.{ts,tsx}"],
    globals: true,
  },
});
  • [ ] Step 5: Create stub app and CLI entry point

Create tui/src/app.tsx:

import React, { useState } from "react";
import { Box, Text } from "ink";

type Screen = "dashboard" | "setup" | "zone-detail" | "onboard" | "fleet" | "audit" | "settings";

interface AppProps {
  initialScreen?: Screen;
}

export default function App({ initialScreen = "dashboard" }: AppProps) {
  const [screen, setScreen] = useState<Screen>(initialScreen);

  return (
    <Box flexDirection="column">
      <Text>Infrastructure MCP — screen: {screen}</Text>
    </Box>
  );
}

Create tui/bin/cli.ts:

#!/usr/bin/env node
import meow from "meow";
import { render } from "ink";
import React from "react";
import App from "../src/app.js";

const cli = meow(
  `
  Usage
    $ infrastructure-tui

  Options
    --jar <path>     Path to infrastructure-mcp JAR
    --config <path>  Config file path (default: ~/.infrastructure-mcp.json)
    --setup          Force re-run setup wizard
    --version        Show version
`,
  {
    importMeta: import.meta,
    flags: {
      jar: { type: "string" },
      config: { type: "string" },
      setup: { type: "boolean", default: false },
    },
  }
);

const initialScreen = cli.flags.setup ? "setup" : "dashboard";
render(React.createElement(App, { initialScreen }));
  • [ ] Step 6: Install dependencies and verify build
cd tui && npm install && npx tsc --noEmit

Expected: no errors.

  • [ ] Step 7: Commit
git add tui/package.json tui/package-lock.json tui/tsconfig.json tui/vitest.config.ts tui/bin/cli.ts tui/src/app.tsx
git commit -m "feat(tui): scaffold Ink project with CLI entry point"

Task 2: MCP Client

Files: - Create: tui/src/mcp-client.ts - Create: tui/tests/mcp-client.test.ts

  • [ ] Step 1: Write failing tests for MCP client

Create tui/tests/mcp-client.test.ts:

import { describe, it, expect, vi, beforeEach } from "vitest";
import { McpClient, ToolResult, createMcpClient } from "../src/mcp-client.js";

// Mock child_process.spawn
vi.mock("child_process", () => {
  const EventEmitter = require("events");

  function createMockProcess() {
    const proc = new EventEmitter();
    proc.stdin = { write: vi.fn(), end: vi.fn() };
    proc.stdout = new EventEmitter();
    proc.stderr = new EventEmitter();
    proc.kill = vi.fn();
    proc.pid = 12345;
    return proc;
  }

  return {
    spawn: vi.fn(() => createMockProcess()),
  };
});

import { spawn } from "child_process";

function getMockProcess() {
  return (spawn as any).mock.results.at(-1)?.value;
}

function sendResponse(proc: any, response: object) {
  const json = JSON.stringify(response);
  const msg = `Content-Length: ${Buffer.byteLength(json)}\r\n\r\n${json}`;
  proc.stdout.emit("data", Buffer.from(msg));
}

describe("McpClient", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("spawns java process with correct args", async () => {
    const client = createMcpClient("/path/to/server.jar", { FOO: "bar" });
    const connectPromise = client.connect();

    const proc = getMockProcess();
    // Respond to initialize
    sendResponse(proc, { jsonrpc: "2.0", id: 1, result: { capabilities: {} } });

    await connectPromise;

    expect(spawn).toHaveBeenCalledWith(
      "java",
      ["-jar", "/path/to/server.jar"],
      expect.objectContaining({
        env: expect.objectContaining({ FOO: "bar" }),
      })
    );
  });

  it("sends initialize on connect", async () => {
    const client = createMcpClient("/path/to/server.jar", {});
    const connectPromise = client.connect();

    const proc = getMockProcess();
    const written = proc.stdin.write.mock.calls[0]?.[0] as string;
    expect(written).toContain('"method":"initialize"');

    sendResponse(proc, { jsonrpc: "2.0", id: 1, result: { capabilities: {} } });
    await connectPromise;
  });

  it("callTool sends tools/call and returns result", async () => {
    const client = createMcpClient("/path/to/server.jar", {});
    const connectPromise = client.connect();
    const proc = getMockProcess();
    sendResponse(proc, { jsonrpc: "2.0", id: 1, result: { capabilities: {} } });
    await connectPromise;

    const toolPromise = client.callTool("cloudflare_list_zones", {});

    sendResponse(proc, {
      jsonrpc: "2.0",
      id: 2,
      result: { content: [{ type: "text", text: '[{"name":"example.com"}]' }] },
    });

    const result = await toolPromise;
    expect(result.content).toBe('[{"name":"example.com"}]');
    expect(result.isError).toBe(false);
  });

  it("callTool returns error when isError is true", async () => {
    const client = createMcpClient("/path/to/server.jar", {});
    const connectPromise = client.connect();
    const proc = getMockProcess();
    sendResponse(proc, { jsonrpc: "2.0", id: 1, result: { capabilities: {} } });
    await connectPromise;

    const toolPromise = client.callTool("bad_tool", {});

    sendResponse(proc, {
      jsonrpc: "2.0",
      id: 2,
      result: { content: [{ type: "text", text: "not found" }], isError: true },
    });

    const result = await toolPromise;
    expect(result.isError).toBe(true);
  });

  it("strips content sanitization markers from responses", async () => {
    const client = createMcpClient("/path/to/server.jar", {});
    const connectPromise = client.connect();
    const proc = getMockProcess();
    sendResponse(proc, { jsonrpc: "2.0", id: 1, result: { capabilities: {} } });
    await connectPromise;

    const toolPromise = client.callTool("test_tool", {});
    const boundary = "----UNTRUSTED_CONTENT_abc123";
    const sanitized = `${boundary}\nexample.com\n${boundary}`;

    sendResponse(proc, {
      jsonrpc: "2.0",
      id: 2,
      result: { content: [{ type: "text", text: sanitized }] },
    });

    const result = await toolPromise;
    expect(result.content).not.toContain("UNTRUSTED_CONTENT");
    expect(result.content).toContain("example.com");
  });

  it("disconnect kills the process", async () => {
    const client = createMcpClient("/path/to/server.jar", {});
    const connectPromise = client.connect();
    const proc = getMockProcess();
    sendResponse(proc, { jsonrpc: "2.0", id: 1, result: { capabilities: {} } });
    await connectPromise;

    await client.disconnect();
    expect(proc.kill).toHaveBeenCalled();
  });
});
  • [ ] Step 2: Run test to verify it fails
cd tui && npx vitest run tests/mcp-client.test.ts

Expected: FAIL — module ../src/mcp-client.js not found.

  • [ ] Step 3: Implement MCP client

Create tui/src/mcp-client.ts:

import { spawn, ChildProcess } from "child_process";

export interface ToolResult {
  content: string;
  isError: boolean;
}

export interface ToolInfo {
  name: string;
  description: string;
}

export interface McpClient {
  connect(): Promise<void>;
  callTool(name: string, args?: Record<string, unknown>): Promise<ToolResult>;
  listTools(): Promise<ToolInfo[]>;
  isConnected(): boolean;
  disconnect(): Promise<void>;
}

const SANITIZATION_PATTERN = /----UNTRUSTED_CONTENT_[a-f0-9]+\n?/g;

function stripSanitization(text: string): string {
  return text.replace(SANITIZATION_PATTERN, "").trim();
}

export function createMcpClient(
  jarPath: string,
  env: Record<string, string>
): McpClient {
  let process: ChildProcess | null = null;
  let connected = false;
  let nextId = 1;
  let buffer = "";
  const pending = new Map<
    number,
    { resolve: (value: any) => void; reject: (reason: any) => void }
  >();

  function sendRequest(method: string, params?: object): Promise<any> {
    const id = nextId++;
    const message = JSON.stringify({
      jsonrpc: "2.0",
      id,
      method,
      params: params ?? {},
    });
    const frame = `Content-Length: ${Buffer.byteLength(message)}\r\n\r\n${message}`;

    return new Promise((resolve, reject) => {
      pending.set(id, { resolve, reject });
      process!.stdin!.write(frame);
    });
  }

  function handleData(data: Buffer) {
    buffer += data.toString();

    while (true) {
      const headerEnd = buffer.indexOf("\r\n\r\n");
      if (headerEnd === -1) break;

      const header = buffer.slice(0, headerEnd);
      const match = header.match(/Content-Length:\s*(\d+)/i);
      if (!match) {
        buffer = buffer.slice(headerEnd + 4);
        continue;
      }

      const contentLength = parseInt(match[1], 10);
      const bodyStart = headerEnd + 4;

      if (buffer.length < bodyStart + contentLength) break;

      const body = buffer.slice(bodyStart, bodyStart + contentLength);
      buffer = buffer.slice(bodyStart + contentLength);

      try {
        const msg = JSON.parse(body);
        if (msg.id != null && pending.has(msg.id)) {
          const { resolve, reject } = pending.get(msg.id)!;
          pending.delete(msg.id);
          if (msg.error) {
            reject(new Error(msg.error.message ?? "MCP error"));
          } else {
            resolve(msg.result);
          }
        }
      } catch {
        // Ignore malformed JSON
      }
    }
  }

  return {
    async connect() {
      process = spawn("java", ["-jar", jarPath], {
        stdio: ["pipe", "pipe", "pipe"],
        env: { ...globalThis.process.env, ...env },
      });

      process.stdout!.on("data", handleData);

      process.on("error", (err) => {
        connected = false;
        for (const { reject } of pending.values()) {
          reject(err);
        }
        pending.clear();
      });

      process.on("exit", () => {
        connected = false;
      });

      await sendRequest("initialize", {
        protocolVersion: "2024-11-05",
        capabilities: {},
        clientInfo: { name: "infrastructure-tui", version: "1.2.0" },
      });

      connected = true;
    },

    async callTool(name, args = {}) {
      const result = await sendRequest("tools/call", { name, arguments: args });
      const texts: string[] = [];
      for (const item of result.content ?? []) {
        if (item.type === "text") {
          texts.push(item.text);
        }
      }
      return {
        content: stripSanitization(texts.join("\n")),
        isError: result.isError === true,
      };
    },

    async listTools() {
      const result = await sendRequest("tools/list", {});
      return (result.tools ?? []).map((t: any) => ({
        name: t.name,
        description: t.description ?? "",
      }));
    },

    isConnected() {
      return connected;
    },

    async disconnect() {
      connected = false;
      for (const { reject } of pending.values()) {
        reject(new Error("Disconnected"));
      }
      pending.clear();
      process?.kill();
      process = null;
    },
  };
}
  • [ ] Step 4: Run tests to verify they pass
cd tui && npx vitest run tests/mcp-client.test.ts

Expected: all 6 tests PASS.

  • [ ] Step 5: Commit
git add tui/src/mcp-client.ts tui/tests/mcp-client.test.ts
git commit -m "feat(tui): add MCP client with JSON-RPC over stdio"

Task 3: Config loader

Files: - Create: tui/src/config.ts - Create: tui/tests/config.test.ts

  • [ ] Step 1: Write failing tests

Create tui/tests/config.test.ts:

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import fs from "fs";
import path from "path";
import os from "os";
import {
  loadConfig,
  saveConfig,
  maskSecret,
  findJar,
  type TuiConfig,
} from "../src/config.js";

vi.mock("fs");
vi.mock("os", async () => {
  const actual = await vi.importActual("os");
  return { ...actual, homedir: vi.fn(() => "/mock/home") };
});

describe("config", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe("loadConfig", () => {
    it("loads from own config file when it exists", () => {
      const ownConfig: TuiConfig = {
        jarPath: "/path/to/jar",
        env: { CLOUDFLARE_API_KEY: "key123", CLOUDFLARE_EMAIL: "[email protected]", CLOUDFLARE_ACCOUNT_ID: "acc1", NAMECHEAP_API_USER: "user", NAMECHEAP_API_KEY: "nckey", NAMECHEAP_CLIENT_IP: "1.2.3.4" },
        experienceLevel: "comfortable",
      };
      vi.mocked(fs.existsSync).mockImplementation((p) =>
        String(p).includes(".infrastructure-mcp.json") ? true : false
      );
      vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(ownConfig));

      const result = loadConfig();
      expect(result).toEqual(ownConfig);
    });

    it("falls back to claude.json when own config missing", () => {
      const claudeConfig = {
        mcpServers: {
          "infrastructure-mcp": {
            args: ["-jar", "/claude/path.jar"],
            env: { CLOUDFLARE_API_KEY: "cfkey", CLOUDFLARE_EMAIL: "[email protected]", CLOUDFLARE_ACCOUNT_ID: "acc2", NAMECHEAP_API_USER: "user2", NAMECHEAP_API_KEY: "nk2", NAMECHEAP_CLIENT_IP: "5.6.7.8" },
          },
        },
      };
      vi.mocked(fs.existsSync).mockImplementation((p) =>
        String(p).includes(".claude.json") ? true : false
      );
      vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(claudeConfig));

      const result = loadConfig();
      expect(result).not.toBeNull();
      expect(result!.env.CLOUDFLARE_API_KEY).toBe("cfkey");
      expect(result!.jarPath).toBe("/claude/path.jar");
    });

    it("returns null when no config found", () => {
      vi.mocked(fs.existsSync).mockReturnValue(false);
      const result = loadConfig();
      expect(result).toBeNull();
    });
  });

  describe("saveConfig", () => {
    it("writes config to own file", () => {
      const config: TuiConfig = {
        jarPath: "/path.jar",
        env: { CLOUDFLARE_API_KEY: "k", CLOUDFLARE_EMAIL: "e", CLOUDFLARE_ACCOUNT_ID: "a", NAMECHEAP_API_USER: "u", NAMECHEAP_API_KEY: "n", NAMECHEAP_CLIENT_IP: "1.1.1.1" },
        experienceLevel: "learner",
      };
      saveConfig(config);

      expect(fs.writeFileSync).toHaveBeenCalledWith(
        expect.stringContaining(".infrastructure-mcp.json"),
        expect.any(String),
        "utf-8"
      );

      const written = vi.mocked(fs.writeFileSync).mock.calls[0][1] as string;
      const parsed = JSON.parse(written);
      expect(parsed.jarPath).toBe("/path.jar");
    });
  });

  describe("maskSecret", () => {
    it("masks long secrets showing last 4 chars", () => {
      expect(maskSecret("abcdefghijklmnop")).toBe("••••••••••••mnop");
    });

    it("fully masks short secrets", () => {
      expect(maskSecret("abc")).toBe("•••");
    });

    it("returns empty for empty string", () => {
      expect(maskSecret("")).toBe("");
    });
  });

  describe("findJar", () => {
    it("returns explicit path when file exists", () => {
      vi.mocked(fs.existsSync).mockReturnValue(true);
      expect(findJar("/explicit/path.jar")).toBe("/explicit/path.jar");
    });

    it("returns null when no jar found", () => {
      vi.mocked(fs.existsSync).mockReturnValue(false);
      vi.mocked(fs.readdirSync).mockReturnValue([]);
      expect(findJar()).toBeNull();
    });
  });
});
  • [ ] Step 2: Run test to verify it fails
cd tui && npx vitest run tests/config.test.ts

Expected: FAIL — module not found.

  • [ ] Step 3: Implement config loader

Create tui/src/config.ts:

import fs from "fs";
import path from "path";
import os from "os";

export interface TuiConfig {
  jarPath: string;
  env: Record<string, string>;
  experienceLevel: "learner" | "comfortable" | "professional";
}

const OWN_CONFIG_NAME = ".infrastructure-mcp.json";
const CLAUDE_CONFIG_NAME = ".claude.json";

function ownConfigPath(): string {
  return path.join(os.homedir(), OWN_CONFIG_NAME);
}

function claudeConfigPath(): string {
  return path.join(os.homedir(), CLAUDE_CONFIG_NAME);
}

export function loadConfig(configPath?: string): TuiConfig | null {
  // 1. Own config
  const ownPath = configPath ?? ownConfigPath();
  if (fs.existsSync(ownPath)) {
    try {
      const raw = fs.readFileSync(ownPath, "utf-8");
      return JSON.parse(raw) as TuiConfig;
    } catch {
      // Fall through
    }
  }

  // 2. Claude Code config
  const claudePath = claudeConfigPath();
  if (fs.existsSync(claudePath)) {
    try {
      const raw = fs.readFileSync(claudePath, "utf-8");
      const claude = JSON.parse(raw);
      const server = claude?.mcpServers?.["infrastructure-mcp"];
      if (server?.env) {
        const args: string[] = server.args ?? [];
        const jarIndex = args.indexOf("-jar");
        const jarPath = jarIndex >= 0 && jarIndex + 1 < args.length ? args[jarIndex + 1] : "";
        return {
          jarPath,
          env: server.env,
          experienceLevel: "comfortable",
        };
      }
    } catch {
      // Fall through
    }
  }

  // 3. No config found
  return null;
}

export function saveConfig(config: TuiConfig, configPath?: string): void {
  const targetPath = configPath ?? ownConfigPath();
  fs.writeFileSync(targetPath, JSON.stringify(config, null, 2), "utf-8");
}

export function maskSecret(value: string): string {
  if (!value) return "";
  if (value.length <= 4) return "•".repeat(value.length);
  return "•".repeat(value.length - 4) + value.slice(-4);
}

export function findJar(explicit?: string): string | null {
  if (explicit && fs.existsSync(explicit)) return explicit;

  const searchDirs = [
    path.resolve("target"),
    path.resolve("..", "infrastructure-mcp", "target"),
  ];

  for (const dir of searchDirs) {
    if (!fs.existsSync(dir)) continue;
    try {
      const files = fs.readdirSync(dir);
      const jar = files.find(
        (f) => f.startsWith("infrastructure-mcp-") && f.endsWith(".jar") && !f.includes("sources")
      );
      if (jar) return path.join(dir, jar);
    } catch {
      continue;
    }
  }

  return null;
}
  • [ ] Step 4: Run tests
cd tui && npx vitest run tests/config.test.ts

Expected: all 7 tests PASS.

  • [ ] Step 5: Commit
git add tui/src/config.ts tui/tests/config.test.ts
git commit -m "feat(tui): add config loader with claude.json fallback"

Task 4: MCP Context provider and useMcp hook

Files: - Create: tui/src/hooks/use-mcp.ts - Create: tui/src/hooks/use-config.ts

  • [ ] Step 1: Create MCP context and hook

Create tui/src/hooks/use-mcp.ts:

import React, { createContext, useContext, useState, useCallback } from "react";
import type { McpClient, ToolResult } from "../mcp-client.js";

interface McpContextValue {
  client: McpClient | null;
  callTool: (name: string, args?: Record<string, unknown>) => Promise<ToolResult>;
  loading: boolean;
  error: string | null;
}

export const McpContext = createContext<McpContextValue>({
  client: null,
  callTool: async () => ({ content: "", isError: true }),
  loading: false,
  error: null,
});

export function useMcp() {
  return useContext(McpContext);
}

export function useToolCall() {
  const { callTool } = useMcp();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [data, setData] = useState<string | null>(null);

  const execute = useCallback(
    async (name: string, args?: Record<string, unknown>) => {
      setLoading(true);
      setError(null);
      setData(null);
      try {
        const result = await callTool(name, args);
        if (result.isError) {
          setError(result.content);
        } else {
          setData(result.content);
        }
        return result;
      } catch (err: any) {
        setError(err.message ?? "Unknown error");
        return { content: err.message, isError: true };
      } finally {
        setLoading(false);
      }
    },
    [callTool]
  );

  return { execute, loading, error, data };
}
  • [ ] Step 2: Create config hook

Create tui/src/hooks/use-config.ts:

import { useState, useEffect } from "react";
import { loadConfig, saveConfig, type TuiConfig } from "../config.js";

export function useConfig(configPath?: string) {
  const [config, setConfig] = useState<TuiConfig | null>(null);
  const [loaded, setLoaded] = useState(false);

  useEffect(() => {
    const result = loadConfig(configPath);
    setConfig(result);
    setLoaded(true);
  }, [configPath]);

  function save(newConfig: TuiConfig) {
    saveConfig(newConfig, configPath);
    setConfig(newConfig);
  }

  return { config, loaded, needsSetup: loaded && config === null, save };
}
  • [ ] Step 3: Commit
git add tui/src/hooks/use-mcp.ts tui/src/hooks/use-config.ts
git commit -m "feat(tui): add MCP context/hooks and config hook"

Task 5: Table component

Files: - Create: tui/src/components/table.tsx - Create: tui/tests/components/table.test.tsx

  • [ ] Step 1: Write failing tests

Create tui/tests/components/table.test.tsx:

import { describe, it, expect } from "vitest";
import React from "react";
import { render } from "ink-testing-library";
import Table from "../../src/components/table.js";

describe("Table", () => {
  const columns = [
    { key: "name", header: "Name" },
    { key: "status", header: "Status" },
  ];

  const rows = [
    { name: "example.com", status: "active" },
    { name: "test.co.uk", status: "pending" },
  ];

  it("renders header row", () => {
    const { lastFrame } = render(
      <Table columns={columns} rows={rows} />
    );
    const frame = lastFrame()!;
    expect(frame).toContain("Name");
    expect(frame).toContain("Status");
  });

  it("renders all data rows", () => {
    const { lastFrame } = render(
      <Table columns={columns} rows={rows} />
    );
    const frame = lastFrame()!;
    expect(frame).toContain("example.com");
    expect(frame).toContain("test.co.uk");
    expect(frame).toContain("active");
    expect(frame).toContain("pending");
  });

  it("uses box-drawing characters for borders", () => {
    const { lastFrame } = render(
      <Table columns={columns} rows={rows} />
    );
    const frame = lastFrame()!;
    expect(frame).toContain("┌");
    expect(frame).toContain("┐");
    expect(frame).toContain("└");
    expect(frame).toContain("┘");
    expect(frame).toContain("│");
    expect(frame).toContain("─");
    expect(frame).toContain("├");
    expect(frame).toContain("┤");
  });

  it("highlights selected row", () => {
    const { lastFrame } = render(
      <Table columns={columns} rows={rows} selectedIndex={0} />
    );
    // Selected row should exist — visual check via inverse
    const frame = lastFrame()!;
    expect(frame).toContain("example.com");
  });

  it("renders empty state when no rows", () => {
    const { lastFrame } = render(
      <Table columns={columns} rows={[]} />
    );
    const frame = lastFrame()!;
    expect(frame).toContain("No data");
  });

  it("aligns columns consistently across rows", () => {
    const wideRows = [
      { name: "a", status: "ok" },
      { name: "a-very-long-domain-name.co.uk", status: "active" },
    ];
    const { lastFrame } = render(
      <Table columns={columns} rows={wideRows} />
    );
    const frame = lastFrame()!;
    const lines = frame.split("\n").filter((l) => l.includes("│"));
    // Every line with │ should have the same number of │ characters
    const counts = lines.map((l) => (l.match(/│/g) ?? []).length);
    const uniqueCounts = [...new Set(counts)];
    expect(uniqueCounts.length).toBe(1);
  });
});
  • [ ] Step 2: Run test to verify it fails
cd tui && npx vitest run tests/components/table.test.tsx

Expected: FAIL — module not found.

  • [ ] Step 3: Implement Table component

Create tui/src/components/table.tsx:

import React from "react";
import { Box, Text } from "ink";
import stringWidth from "string-width";

interface Column {
  key: string;
  header: string;
  width?: number;
  align?: "left" | "right";
}

interface TableProps {
  columns: Column[];
  rows: Record<string, string | number>[];
  selectedIndex?: number;
  maxWidth?: number;
}

function pad(text: string, width: number, align: "left" | "right" = "left"): string {
  const textWidth = stringWidth(text);
  const diff = width - textWidth;
  if (diff <= 0) return text;
  if (align === "right") return " ".repeat(diff) + text;
  return text + " ".repeat(diff);
}

function truncate(text: string, maxWidth: number): string {
  if (stringWidth(text) <= maxWidth) return text;
  let result = "";
  for (const char of text) {
    if (stringWidth(result + char + "…") > maxWidth) break;
    result += char;
  }
  return result + "…";
}

function computeWidths(columns: Column[], rows: Record<string, string | number>[]): number[] {
  return columns.map((col) => {
    const headerWidth = stringWidth(col.header);
    let maxContent = headerWidth;
    for (const row of rows) {
      const cellWidth = stringWidth(String(row[col.key] ?? ""));
      if (cellWidth > maxContent) maxContent = cellWidth;
    }
    return col.width ?? maxContent;
  });
}

export default function Table({ columns, rows, selectedIndex, maxWidth }: TableProps) {
  if (rows.length === 0) {
    return (
      <Box flexDirection="column">
        <Text dimColor>No data</Text>
      </Box>
    );
  }

  const widths = computeWidths(columns, rows);
  const termWidth = maxWidth ?? process.stdout.columns ?? 120;

  // Check if we need to truncate
  const totalWidth = widths.reduce((sum, w) => sum + w + 3, 0) + 1; // 3 = " │ " per col, +1 for last │
  if (totalWidth > termWidth) {
    const widestIdx = widths.indexOf(Math.max(...widths));
    const excess = totalWidth - termWidth;
    widths[widestIdx] = Math.max(widths[widestIdx] - excess, stringWidth(columns[widestIdx].header));
  }

  function buildLine(left: string, mid: string, right: string, fill: string): string {
    return left + widths.map((w) => fill.repeat(w + 2)).join(mid) + right;
  }

  function buildRow(cells: string[], highlight: boolean): React.ReactNode {
    const content =
      "│" +
      cells
        .map((cell, i) => {
          const truncated = truncate(cell, widths[i]);
          return " " + pad(truncated, widths[i], columns[i].align) + " ";
        })
        .join("│") +
      "│";

    return highlight ? (
      <Text backgroundColor="blue" color="white">{content}</Text>
    ) : (
      <Text>{content}</Text>
    );
  }

  const topBorder = buildLine("┌", "┬", "┐", "─");
  const headerSep = buildLine("├", "┼", "┤", "─");
  const bottomBorder = buildLine("└", "┴", "┘", "─");

  const headerCells = columns.map((col) => col.header);

  return (
    <Box flexDirection="column">
      <Text>{topBorder}</Text>
      {buildRow(headerCells, false)}
      <Text>{headerSep}</Text>
      {rows.map((row, idx) => {
        const cells = columns.map((col) => String(row[col.key] ?? ""));
        return <React.Fragment key={idx}>{buildRow(cells, idx === selectedIndex)}</React.Fragment>;
      })}
      <Text>{bottomBorder}</Text>
    </Box>
  );
}
  • [ ] Step 4: Run tests
cd tui && npx vitest run tests/components/table.test.tsx

Expected: all 6 tests PASS.

  • [ ] Step 5: Commit
git add tui/src/components/table.tsx tui/tests/components/table.test.tsx
git commit -m "feat(tui): add Table component with Unicode borders and alignment"

Task 6: Shared components (StatusBadge, Spinner, Confirm, Header, KeyHint, TextInput)

Files: - Create: tui/src/components/status-badge.tsx - Create: tui/src/components/spinner.tsx - Create: tui/src/components/confirm.tsx - Create: tui/src/components/header.tsx - Create: tui/src/components/key-hint.tsx - Create: tui/src/components/text-input.tsx - Create: tui/tests/components/confirm.test.tsx

  • [ ] Step 1: Create StatusBadge

Create tui/src/components/status-badge.tsx:

import React from "react";
import { Text } from "ink";

interface StatusBadgeProps {
  status: "active" | "pending" | "error" | "ok";
  label?: string;
}

export default function StatusBadge({ status, label }: StatusBadgeProps) {
  switch (status) {
    case "active":
    case "ok":
      return <Text color="green">● {label ?? status}</Text>;
    case "pending":
      return <Text dimColor>○ {label ?? status}</Text>;
    case "error":
      return <Text color="red">✗ {label ?? status}</Text>;
  }
}
  • [ ] Step 2: Create Spinner

Create tui/src/components/spinner.tsx:

import React from "react";
import { Box, Text } from "ink";
import InkSpinner from "ink-spinner";

interface SpinnerProps {
  label: string;
}

export default function Spinner({ label }: SpinnerProps) {
  return (
    <Box>
      <Text color="cyan">
        <InkSpinner type="dots" />
      </Text>
      <Text> {label}</Text>
    </Box>
  );
}
  • [ ] Step 3: Create Confirm with test

Create tui/src/components/confirm.tsx:

import React from "react";
import { Box, Text, useInput } from "ink";

interface ConfirmProps {
  message: string;
  onConfirm: () => void;
  onCancel: () => void;
}

export default function Confirm({ message, onConfirm, onCancel }: ConfirmProps) {
  useInput((input) => {
    if (input === "y" || input === "Y") onConfirm();
    if (input === "n" || input === "N" || input === "q") onCancel();
  });

  return (
    <Box flexDirection="column">
      <Text>{message}</Text>
      <Text dimColor>Press y to confirm, n to cancel</Text>
    </Box>
  );
}

Create tui/tests/components/confirm.test.tsx:

import { describe, it, expect, vi } from "vitest";
import React from "react";
import { render } from "ink-testing-library";
import Confirm from "../../src/components/confirm.js";

describe("Confirm", () => {
  it("renders message and hint", () => {
    const { lastFrame } = render(
      <Confirm message="Delete zone?" onConfirm={vi.fn()} onCancel={vi.fn()} />
    );
    expect(lastFrame()).toContain("Delete zone?");
    expect(lastFrame()).toContain("y to confirm");
  });

  it("calls onConfirm when y is pressed", () => {
    const onConfirm = vi.fn();
    const { stdin } = render(
      <Confirm message="OK?" onConfirm={onConfirm} onCancel={vi.fn()} />
    );
    stdin.write("y");
    expect(onConfirm).toHaveBeenCalled();
  });

  it("calls onCancel when n is pressed", () => {
    const onCancel = vi.fn();
    const { stdin } = render(
      <Confirm message="OK?" onConfirm={vi.fn()} onCancel={onCancel} />
    );
    stdin.write("n");
    expect(onCancel).toHaveBeenCalled();
  });
});
  • [ ] Step 4: Create Header, KeyHint, TextInput

Create tui/src/components/header.tsx:

import React from "react";
import { Box, Text } from "ink";

interface HeaderProps {
  title: string;
  breadcrumb?: string;
  version?: string;
}

export default function Header({ title, breadcrumb, version }: HeaderProps) {
  return (
    <Box flexDirection="column">
      <Box justifyContent="space-between" width="100%">
        <Text bold>
          {breadcrumb ? `← ${breadcrumb}  ` : ""}{title}
          {version ? <Text dimColor> v{version}</Text> : null}
        </Text>
        <Text dimColor>q quit  s settings  ? help</Text>
      </Box>
      <Text dimColor>{"─".repeat(process.stdout.columns ?? 80)}</Text>
    </Box>
  );
}

Create tui/src/components/key-hint.tsx:

import React from "react";
import { Box, Text } from "ink";

interface Hint {
  key: string;
  label: string;
}

interface KeyHintProps {
  hints: Hint[];
}

export default function KeyHint({ hints }: KeyHintProps) {
  return (
    <Box>
      {hints.map((h, i) => (
        <Text key={h.key}>
          {i > 0 ? "   " : ""}
          <Text bold>{h.key}</Text>
          <Text dimColor> {h.label}</Text>
        </Text>
      ))}
    </Box>
  );
}

Create tui/src/components/text-input.tsx:

import React from "react";
import { Box, Text } from "ink";
import InkTextInput from "ink-text-input";

interface TextInputProps {
  label: string;
  value: string;
  onChange: (value: string) => void;
  onSubmit?: (value: string) => void;
  mask?: boolean;
  hint?: string;
}

export default function TextInput({ label, value, onChange, onSubmit, mask, hint }: TextInputProps) {
  const displayValue = mask && value.length > 4
    ? "•".repeat(value.length - 4) + value.slice(-4)
    : mask && value.length > 0
    ? "•".repeat(value.length)
    : value;

  return (
    <Box flexDirection="column">
      <Box>
        <Text bold>{label}: </Text>
        {mask ? (
          <Box>
            <Text>{displayValue}</Text>
            <InkTextInput value={value} onChange={onChange} onSubmit={onSubmit} mask="•" />
          </Box>
        ) : (
          <InkTextInput value={value} onChange={onChange} onSubmit={onSubmit} />
        )}
      </Box>
      {hint ? <Text dimColor>  {hint}</Text> : null}
    </Box>
  );
}
  • [ ] Step 5: Run all component tests
cd tui && npx vitest run tests/components/

Expected: all PASS.

  • [ ] Step 6: Commit
git add tui/src/components/ tui/tests/components/confirm.test.tsx
git commit -m "feat(tui): add shared components (StatusBadge, Spinner, Confirm, Header, KeyHint, TextInput)"

Task 7: Setup wizard screen

Files: - Create: tui/src/screens/setup.tsx - Create: tui/tests/screens/setup.test.tsx

  • [ ] Step 1: Write failing tests

Create tui/tests/screens/setup.test.tsx:

import { describe, it, expect, vi } from "vitest";
import React from "react";
import { render } from "ink-testing-library";
import Setup from "../../src/screens/setup.js";

describe("Setup", () => {
  it("renders welcome step initially", () => {
    const { lastFrame } = render(
      <Setup onComplete={vi.fn()} />
    );
    const frame = lastFrame()!;
    expect(frame).toContain("Infrastructure MCP");
    expect(frame).toContain("Manage your Cloudflare");
  });

  it("advances to experience level on Enter", () => {
    const { lastFrame, stdin } = render(
      <Setup onComplete={vi.fn()} />
    );
    stdin.write("\r");
    const frame = lastFrame()!;
    expect(frame).toContain("What best describes you");
  });

  it("shows source code review for learner", () => {
    const { lastFrame, stdin } = render(
      <Setup onComplete={vi.fn()} />
    );
    stdin.write("\r"); // past welcome
    stdin.write("\r"); // select first option (learner)
    const frame = lastFrame()!;
    expect(frame).toContain("review the source code");
  });

  it("skips source code review for professional", () => {
    const { lastFrame, stdin } = render(
      <Setup onComplete={vi.fn()} />
    );
    stdin.write("\r"); // past welcome
    // Navigate to third option
    stdin.write("\u001B[B"); // down
    stdin.write("\u001B[B"); // down
    stdin.write("\r"); // select professional
    const frame = lastFrame()!;
    expect(frame).not.toContain("review the source code");
  });
});
  • [ ] Step 2: Run test to verify it fails
cd tui && npx vitest run tests/screens/setup.test.tsx

Expected: FAIL.

  • [ ] Step 3: Implement Setup screen

Create tui/src/screens/setup.tsx:

import React, { useState } from "react";
import { Box, Text, useInput } from "ink";
import SelectInput from "ink-select-input";
import TextInput from "../components/text-input.js";
import type { TuiConfig } from "../config.js";

type ExperienceLevel = "learner" | "comfortable" | "professional";
type Step = "welcome" | "experience" | "review" | "cloudflare-auth" | "cloudflare-fields" | "namecheap" | "jar" | "summary";

interface SetupProps {
  onComplete: (config: TuiConfig) => void;
}

export default function Setup({ onComplete }: SetupProps) {
  const [step, setStep] = useState<Step>("welcome");
  const [experience, setExperience] = useState<ExperienceLevel>("learner");
  const [cfAuthType, setCfAuthType] = useState<"global" | "token">("global");
  const [values, setValues] = useState({
    cloudflareApiKey: "",
    cloudflareEmail: "",
    cloudflareApiToken: "",
    cloudflareAccountId: "",
    namecheapApiUser: "",
    namecheapApiKey: "",
    namecheapClientIp: "",
    jarPath: "",
  });
  const [fieldIndex, setFieldIndex] = useState(0);

  const isLearner = experience === "learner";

  function updateValue(key: string, value: string) {
    setValues((prev) => ({ ...prev, [key]: value }));
  }

  function nextStep() {
    const flow: Step[] = [
      "welcome",
      "experience",
      ...(isLearner ? ["review" as Step] : []),
      "cloudflare-auth",
      "cloudflare-fields",
      "namecheap",
      "jar",
      "summary",
    ];
    const idx = flow.indexOf(step);
    if (idx < flow.length - 1) {
      setStep(flow[idx + 1]);
      setFieldIndex(0);
    }
  }

  function finish() {
    const env: Record<string, string> = {
      CLOUDFLARE_ACCOUNT_ID: values.cloudflareAccountId,
      NAMECHEAP_API_USER: values.namecheapApiUser,
      NAMECHEAP_API_KEY: values.namecheapApiKey,
      NAMECHEAP_CLIENT_IP: values.namecheapClientIp,
    };
    if (cfAuthType === "global") {
      env.CLOUDFLARE_API_KEY = values.cloudflareApiKey;
      env.CLOUDFLARE_EMAIL = values.cloudflareEmail;
    } else {
      env.CLOUDFLARE_API_TOKEN = values.cloudflareApiToken;
    }
    onComplete({ jarPath: values.jarPath, env, experienceLevel: experience });
  }

  useInput((input, key) => {
    if (step === "welcome" && key.return) nextStep();
    if (step === "summary" && key.return) finish();
  });

  if (step === "welcome") {
    return (
      <Box flexDirection="column" padding={1}>
        <Text bold color="cyan">Infrastructure MCP</Text>
        <Text />
        <Text>Manage your Cloudflare zones, DNS records, and Fleet apps</Text>
        <Text>from the terminal. This tool talks to the Cloudflare and</Text>
        <Text>Namecheap APIs on your behalf to onboard domains, migrate</Text>
        <Text>DNS, and apply security hardening.</Text>
        <Text />
        <Text dimColor>Source: https://github.com/wrxck/infrastructure-mcp</Text>
        <Text />
        <Text dimColor>[Enter] Continue</Text>
      </Box>
    );
  }

  if (step === "experience") {
    const items = [
      { label: "I'm learning about infrastructure and want guidance", value: "learner" as const },
      { label: "I'm comfortable managing DNS and cloud services", value: "comfortable" as const },
      { label: "I'm a DevOps professional — just give me the fields", value: "professional" as const },
    ];
    return (
      <Box flexDirection="column" padding={1}>
        <Text bold>What best describes you?</Text>
        <Text />
        <SelectInput
          items={items}
          onSelect={(item) => {
            setExperience(item.value);
            // Need to set experience before computing next step
            if (item.value === "learner") {
              setStep("review");
            } else {
              setStep("cloudflare-auth");
            }
          }}
        />
      </Box>
    );
  }

  if (step === "review") {
    const items = [
      { label: "I've reviewed the source code", value: "reviewed" },
      { label: "I'll review it later", value: "later" },
      { label: "Skip", value: "skip" },
    ];
    return (
      <Box flexDirection="column" padding={1}>
        <Text>Before entering API keys, we'd encourage you to review the</Text>
        <Text>source code to understand exactly what this tool does with</Text>
        <Text>your credentials. This is good security practice, and you'll</Text>
        <Text>learn a lot about how infrastructure APIs work.</Text>
        <Text />
        <Text bold>Key files to review:</Text>
        <Text dimColor>  src/main/java/.../InfrastructureTools.java  — what tools do</Text>
        <Text dimColor>  src/main/java/.../CloudflareRestClient.java — API calls made</Text>
        <Text dimColor>  src/main/java/.../ServerConfig.java         — how creds are used</Text>
        <Text />
        <Text>This tool never sends credentials anywhere except the official</Text>
        <Text>Cloudflare and Namecheap API endpoints. No telemetry, no</Text>
        <Text>analytics, no third-party services.</Text>
        <Text />
        <SelectInput items={items} onSelect={() => setStep("cloudflare-auth")} />
      </Box>
    );
  }

  if (step === "cloudflare-auth") {
    const items = [
      { label: "Global API Key (recommended)", value: "global" as const },
      { label: "Scoped API Token", value: "token" as const },
    ];
    return (
      <Box flexDirection="column" padding={1}>
        <Text bold>Cloudflare Authentication</Text>
        {isLearner && <Text dimColor>Choose how to authenticate with the Cloudflare API.</Text>}
        <Text />
        <SelectInput
          items={items}
          onSelect={(item) => {
            setCfAuthType(item.value);
            setStep("cloudflare-fields");
            setFieldIndex(0);
          }}
        />
      </Box>
    );
  }

  if (step === "cloudflare-fields") {
    const fields = cfAuthType === "global"
      ? [
          { key: "cloudflareApiKey", label: "API Key", mask: true, hint: isLearner ? "Found at My Profile > API Tokens > Global API Key" : undefined },
          { key: "cloudflareEmail", label: "Email", mask: false, hint: isLearner ? "Your Cloudflare account email" : undefined },
          { key: "cloudflareAccountId", label: "Account ID", mask: false, hint: isLearner ? "Found in your Cloudflare dashboard URL" : undefined },
        ]
      : [
          { key: "cloudflareApiToken", label: "API Token", mask: true, hint: isLearner ? "Create at My Profile > API Tokens" : undefined },
          { key: "cloudflareAccountId", label: "Account ID", mask: false, hint: isLearner ? "Found in your Cloudflare dashboard URL" : undefined },
        ];

    const field = fields[fieldIndex];
    return (
      <Box flexDirection="column" padding={1}>
        <Text bold>Cloudflare — {cfAuthType === "global" ? "Global API Key" : "API Token"}</Text>
        <Text dimColor>Field {fieldIndex + 1} of {fields.length}</Text>
        <Text />
        <TextInput
          label={field.label}
          value={(values as any)[field.key]}
          onChange={(v) => updateValue(field.key, v)}
          onSubmit={() => {
            if (fieldIndex < fields.length - 1) {
              setFieldIndex(fieldIndex + 1);
            } else {
              nextStep();
            }
          }}
          mask={field.mask}
          hint={field.hint}
        />
      </Box>
    );
  }

  if (step === "namecheap") {
    const fields = [
      { key: "namecheapApiUser", label: "API User", mask: false, hint: isLearner ? "Your Namecheap username" : undefined },
      { key: "namecheapApiKey", label: "API Key", mask: true, hint: isLearner ? "Enable API at Profile > Tools > API Access" : undefined },
      { key: "namecheapClientIp", label: "Client IP", mask: false, hint: isLearner ? "Your server's public IP (must be whitelisted)" : undefined },
    ];

    const field = fields[fieldIndex];
    return (
      <Box flexDirection="column" padding={1}>
        <Text bold>Namecheap</Text>
        <Text dimColor>Field {fieldIndex + 1} of {fields.length}</Text>
        <Text />
        <TextInput
          label={field.label}
          value={(values as any)[field.key]}
          onChange={(v) => updateValue(field.key, v)}
          onSubmit={() => {
            if (fieldIndex < fields.length - 1) {
              setFieldIndex(fieldIndex + 1);
            } else {
              nextStep();
            }
          }}
          mask={field.mask}
          hint={field.hint}
        />
      </Box>
    );
  }

  if (step === "jar") {
    return (
      <Box flexDirection="column" padding={1}>
        <Text bold>MCP Server JAR</Text>
        <Text />
        <TextInput
          label="JAR Path"
          value={values.jarPath}
          onChange={(v) => updateValue("jarPath", v)}
          onSubmit={() => nextStep()}
          hint={isLearner ? "Path to infrastructure-mcp-*.jar (auto-discovered if left empty)" : undefined}
        />
      </Box>
    );
  }

  if (step === "summary") {
    return (
      <Box flexDirection="column" padding={1}>
        <Text bold>Configuration Summary</Text>
        <Text />
        <Text>Cloudflare: {cfAuthType === "global" ? "Global API Key" : "API Token"}</Text>
        <Text>Account ID: {values.cloudflareAccountId}</Text>
        <Text>Namecheap User: {values.namecheapApiUser}</Text>
        <Text>JAR: {values.jarPath || "(auto-discover)"}</Text>
        <Text />
        <Text dimColor>[Enter] Save and continue</Text>
      </Box>
    );
  }

  return null;
}
  • [ ] Step 4: Run tests
cd tui && npx vitest run tests/screens/setup.test.tsx

Expected: all 4 tests PASS.

  • [ ] Step 5: Commit
git add tui/src/screens/setup.tsx tui/tests/screens/setup.test.tsx
git commit -m "feat(tui): add Setup wizard with experience levels and code review nudge"

Task 8: Dashboard screen

Files: - Create: tui/src/screens/dashboard.tsx - Create: tui/tests/screens/dashboard.test.tsx

  • [ ] Step 1: Write failing tests

Create tui/tests/screens/dashboard.test.tsx:

import { describe, it, expect, vi } from "vitest";
import React from "react";
import { render } from "ink-testing-library";
import { McpContext } from "../../src/hooks/use-mcp.js";
import Dashboard from "../../src/screens/dashboard.js";

const mockZones = JSON.stringify([
  { id: "z1", name: "example.com", status: "active", nameServers: ["ns1.cf.com"] },
  { id: "z2", name: "test.co.uk", status: "pending", nameServers: ["ns2.cf.com"] },
]);

const mockFleet = JSON.stringify({
  domains: ["example.com", "www.example.com"],
  rootDomains: ["example.com"],
});

function createMockMcp() {
  const callTool = vi.fn(async (name: string) => {
    if (name === "cloudflare_list_zones") return { content: mockZones, isError: false };
    if (name === "fleet_list_domains") return { content: mockFleet, isError: false };
    return { content: "[]", isError: false };
  });
  return {
    client: null,
    callTool,
    loading: false,
    error: null,
  };
}

function renderWithMcp(ui: React.ReactElement) {
  const mcp = createMockMcp();
  return {
    ...render(
      <McpContext.Provider value={mcp}>{ui}</McpContext.Provider>
    ),
    mcp,
  };
}

describe("Dashboard", () => {
  it("renders zone names after loading", async () => {
    const { lastFrame } = renderWithMcp(
      <Dashboard onNavigate={vi.fn()} />
    );
    // Allow useEffect to fire
    await new Promise((r) => setTimeout(r, 50));
    const frame = lastFrame()!;
    expect(frame).toContain("example.com");
    expect(frame).toContain("test.co.uk");
  });

  it("renders Cloudflare Zones header", async () => {
    const { lastFrame } = renderWithMcp(
      <Dashboard onNavigate={vi.fn()} />
    );
    await new Promise((r) => setTimeout(r, 50));
    expect(lastFrame()).toContain("Cloudflare Zones");
  });

  it("renders Fleet Apps header", async () => {
    const { lastFrame } = renderWithMcp(
      <Dashboard onNavigate={vi.fn()} />
    );
    await new Promise((r) => setTimeout(r, 50));
    expect(lastFrame()).toContain("Fleet");
  });

  it("calls cloudflare_list_zones on mount", async () => {
    const { mcp } = renderWithMcp(
      <Dashboard onNavigate={vi.fn()} />
    );
    await new Promise((r) => setTimeout(r, 50));
    expect(mcp.callTool).toHaveBeenCalledWith("cloudflare_list_zones", undefined);
  });
});
  • [ ] Step 2: Run test to verify it fails
cd tui && npx vitest run tests/screens/dashboard.test.tsx

Expected: FAIL.

  • [ ] Step 3: Implement Dashboard

Create tui/src/screens/dashboard.tsx:

import React, { useState, useEffect } from "react";
import { Box, Text, useInput } from "ink";
import { useMcp } from "../hooks/use-mcp.js";
import Table from "../components/table.js";
import Header from "../components/header.js";
import KeyHint from "../components/key-hint.js";
import Spinner from "../components/spinner.js";

interface Zone {
  id: string;
  name: string;
  status: string;
}

interface FleetData {
  domains: string[];
  rootDomains: string[];
}

interface DashboardProps {
  onNavigate: (screen: string, params?: Record<string, unknown>) => void;
}

export default function Dashboard({ onNavigate }: DashboardProps) {
  const { callTool } = useMcp();
  const [zones, setZones] = useState<Zone[]>([]);
  const [fleet, setFleet] = useState<FleetData | null>(null);
  const [loading, setLoading] = useState(true);
  const [selectedIndex, setSelectedIndex] = useState(0);
  const [error, setError] = useState<string | null>(null);

  async function loadData() {
    setLoading(true);
    setError(null);
    try {
      const [zonesResult, fleetResult] = await Promise.all([
        callTool("cloudflare_list_zones"),
        callTool("fleet_list_domains"),
      ]);
      if (!zonesResult.isError) {
        setZones(JSON.parse(zonesResult.content));
      }
      if (!fleetResult.isError) {
        setFleet(JSON.parse(fleetResult.content));
      }
    } catch (err: any) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => {
    loadData();
  }, []);

  useInput((input, key) => {
    if (key.upArrow && selectedIndex > 0) setSelectedIndex(selectedIndex - 1);
    if (key.downArrow && selectedIndex < zones.length - 1) setSelectedIndex(selectedIndex + 1);
    if (key.return && zones[selectedIndex]) {
      onNavigate("zone-detail", { zone: zones[selectedIndex] });
    }
    if (input === "o") onNavigate("onboard");
    if (input === "a") onNavigate("audit");
    if (input === "r") loadData();
    if (input === "s") onNavigate("settings");
    if (input === "f") onNavigate("fleet");
  });

  if (loading) {
    return (
      <Box flexDirection="column">
        <Header title="Infrastructure MCP" version="1.2.0" />
        <Spinner label="Loading infrastructure data..." />
      </Box>
    );
  }

  if (error) {
    return (
      <Box flexDirection="column">
        <Header title="Infrastructure MCP" version="1.2.0" />
        <Text color="red">Error: {error}</Text>
        <Text dimColor>Press r to retry</Text>
      </Box>
    );
  }

  const zoneColumns = [
    { key: "name", header: "Domain" },
    { key: "statusDisplay", header: "Status" },
    { key: "records", header: "Records" },
    { key: "ssl", header: "SSL" },
  ];

  const zoneRows = zones.map((z) => ({
    name: z.name,
    statusDisplay: z.status === "active" ? "● active" : "○ pend.",
    records: "—",
    ssl: "—",
  }));

  return (
    <Box flexDirection="column">
      <Header title="Infrastructure MCP" version="1.2.0" />
      <Box marginTop={1} />
      <Text bold> Cloudflare Zones</Text>
      <Table columns={zoneColumns} rows={zoneRows} selectedIndex={selectedIndex} />
      {fleet && (
        <>
          <Box marginTop={1} />
          <Text bold> Fleet Apps</Text>
          <Text dimColor>  {fleet.rootDomains.length} root domains, {fleet.domains.length} total endpoints</Text>
        </>
      )}
      <Box marginTop={1} />
      <KeyHint
        hints={[
          { key: "↑↓", label: "select" },
          { key: "Enter", label: "details" },
          { key: "o", label: "onboard" },
          { key: "a", label: "audit all" },
          { key: "r", label: "refresh" },
        ]}
      />
    </Box>
  );
}
  • [ ] Step 4: Run tests
cd tui && npx vitest run tests/screens/dashboard.test.tsx

Expected: all 4 tests PASS.

  • [ ] Step 5: Commit
git add tui/src/screens/dashboard.tsx tui/tests/screens/dashboard.test.tsx
git commit -m "feat(tui): add Dashboard screen with zones table and Fleet summary"

Task 9: Zone Detail screen

Files: - Create: tui/src/screens/zone-detail.tsx - Create: tui/tests/screens/zone-detail.test.tsx

  • [ ] Step 1: Write failing test

Create tui/tests/screens/zone-detail.test.tsx:

import { describe, it, expect, vi } from "vitest";
import React from "react";
import { render } from "ink-testing-library";
import { McpContext } from "../../src/hooks/use-mcp.js";
import ZoneDetail from "../../src/screens/zone-detail.js";

const mockDns = JSON.stringify([
  { id: "r1", type: "A", name: "example.com", content: "1.2.3.4", proxied: true, ttl: 1 },
  { id: "r2", type: "CNAME", name: "www.example.com", content: "example.com", proxied: true, ttl: 1 },
]);

const mockProtection = JSON.stringify([
  { setting: "ssl", category: "SSL", desiredValue: "strict", currentValue: "strict", status: "ok" },
  { setting: "always_use_https", category: "SSL", desiredValue: "on", currentValue: "on", status: "ok" },
]);

function renderWithMcp(ui: React.ReactElement) {
  const callTool = vi.fn(async (name: string) => {
    if (name === "cloudflare_get_dns") return { content: mockDns, isError: false };
    if (name === "cloudflare_get_protection_status") return { content: mockProtection, isError: false };
    return { content: "[]", isError: false };
  });
  return render(
    <McpContext.Provider value={{ client: null, callTool, loading: false, error: null }}>
      {ui}
    </McpContext.Provider>
  );
}

describe("ZoneDetail", () => {
  const zone = { id: "z1", name: "example.com", status: "active" };

  it("renders zone name", async () => {
    const { lastFrame } = renderWithMcp(
      <ZoneDetail zone={zone} onBack={vi.fn()} />
    );
    await new Promise((r) => setTimeout(r, 50));
    expect(lastFrame()).toContain("example.com");
  });

  it("renders DNS records table", async () => {
    const { lastFrame } = renderWithMcp(
      <ZoneDetail zone={zone} onBack={vi.fn()} />
    );
    await new Promise((r) => setTimeout(r, 50));
    const frame = lastFrame()!;
    expect(frame).toContain("1.2.3.4");
    expect(frame).toContain("CNAME");
  });

  it("renders protection audit", async () => {
    const { lastFrame } = renderWithMcp(
      <ZoneDetail zone={zone} onBack={vi.fn()} />
    );
    await new Promise((r) => setTimeout(r, 50));
    expect(lastFrame()).toContain("ssl");
    expect(lastFrame()).toContain("strict");
  });
});
  • [ ] Step 2: Implement ZoneDetail

Create tui/src/screens/zone-detail.tsx:

import React, { useState, useEffect } from "react";
import { Box, Text, useInput } from "ink";
import { useMcp } from "../hooks/use-mcp.js";
import Table from "../components/table.js";
import Header from "../components/header.js";
import KeyHint from "../components/key-hint.js";
import Spinner from "../components/spinner.js";

interface Zone {
  id: string;
  name: string;
  status: string;
}

interface ZoneDetailProps {
  zone: Zone;
  onBack: () => void;
}

export default function ZoneDetail({ zone, onBack }: ZoneDetailProps) {
  const { callTool } = useMcp();
  const [dns, setDns] = useState<any[]>([]);
  const [protection, setProtection] = useState<any[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function load() {
      const [dnsResult, protResult] = await Promise.all([
        callTool("cloudflare_get_dns", { domain: zone.name }),
        callTool("cloudflare_get_protection_status", { domain: zone.name }),
      ]);
      if (!dnsResult.isError) setDns(JSON.parse(dnsResult.content));
      if (!protResult.isError) setProtection(JSON.parse(protResult.content));
      setLoading(false);
    }
    load();
  }, [zone.name]);

  useInput((input, key) => {
    if (key.escape || input === "q") onBack();
    if (input === "p") {
      callTool("apply_protection", { domain: zone.name });
    }
  });

  if (loading) {
    return (
      <Box flexDirection="column">
        <Header title={zone.name} breadcrumb="Back" />
        <Spinner label={`Loading ${zone.name}...`} />
      </Box>
    );
  }

  const dnsColumns = [
    { key: "type", header: "Type" },
    { key: "name", header: "Name" },
    { key: "content", header: "Content" },
    { key: "proxiedDisplay", header: "Proxied" },
  ];

  const dnsRows = dns.map((r) => ({
    type: r.type,
    name: r.name,
    content: r.content,
    proxiedDisplay: r.proxied ? "●" : "○",
  }));

  const protColumns = [
    { key: "setting", header: "Setting" },
    { key: "category", header: "Category" },
    { key: "desiredValue", header: "Expected" },
    { key: "statusDisplay", header: "Status" },
  ];

  const protRows = protection.map((p) => ({
    setting: p.setting,
    category: p.category,
    desiredValue: String(p.desiredValue),
    statusDisplay: p.status === "ok" ? "✓ ok" : `✗ ${p.currentValue}`,
  }));

  const statusIcon = zone.status === "active" ? "●" : "○";

  return (
    <Box flexDirection="column">
      <Header title={`${zone.name} (${statusIcon} ${zone.status})`} breadcrumb="Back" />
      <Box marginTop={1} />
      <Text bold> DNS Records ({dns.length})</Text>
      <Table columns={dnsColumns} rows={dnsRows} />
      <Box marginTop={1} />
      <Text bold> Protection Audit</Text>
      <Table columns={protColumns} rows={protRows} />
      <Box marginTop={1} />
      <KeyHint hints={[
        { key: "Esc", label: "back" },
        { key: "p", label: "apply protection" },
      ]} />
    </Box>
  );
}
  • [ ] Step 3: Run tests
cd tui && npx vitest run tests/screens/zone-detail.test.tsx

Expected: all 3 tests PASS.

  • [ ] Step 4: Commit
git add tui/src/screens/zone-detail.tsx tui/tests/screens/zone-detail.test.tsx
git commit -m "feat(tui): add Zone Detail screen with DNS and protection audit"

Task 10: Onboard wizard screen

Files: - Create: tui/src/screens/onboard.tsx - Create: tui/tests/screens/onboard.test.tsx

  • [ ] Step 1: Write failing test

Create tui/tests/screens/onboard.test.tsx:

import { describe, it, expect, vi } from "vitest";
import React from "react";
import { render } from "ink-testing-library";
import { McpContext } from "../../src/hooks/use-mcp.js";
import Onboard from "../../src/screens/onboard.js";

function renderWithMcp(ui: React.ReactElement) {
  const callTool = vi.fn(async () => ({
    content: JSON.stringify({ domain: "new.com", recordsMigrated: 5, protectionApplied: 30 }),
    isError: false,
  }));
  return {
    ...render(
      <McpContext.Provider value={{ client: null, callTool, loading: false, error: null }}>
        {ui}
      </McpContext.Provider>
    ),
    callTool,
  };
}

describe("Onboard", () => {
  it("shows domain input initially", () => {
    const { lastFrame } = renderWithMcp(
      <Onboard onComplete={vi.fn()} onBack={vi.fn()} />
    );
    expect(lastFrame()).toContain("Domain");
  });

  it("shows confirmation after entering domain", () => {
    const { lastFrame, stdin } = renderWithMcp(
      <Onboard onComplete={vi.fn()} onBack={vi.fn()} />
    );
    stdin.write("example.com");
    stdin.write("\r");
    const frame = lastFrame()!;
    expect(frame).toContain("example.com");
    expect(frame).toContain("confirm");
  });
});
  • [ ] Step 2: Implement Onboard

Create tui/src/screens/onboard.tsx:

import React, { useState } from "react";
import { Box, Text, useInput } from "ink";
import { useMcp } from "../hooks/use-mcp.js";
import TextInput from "../components/text-input.js";
import Confirm from "../components/confirm.js";
import Spinner from "../components/spinner.js";
import Header from "../components/header.js";

type Step = "input" | "confirm" | "running" | "done";

interface OnboardProps {
  onComplete: () => void;
  onBack: () => void;
}

export default function Onboard({ onComplete, onBack }: OnboardProps) {
  const { callTool } = useMcp();
  const [step, setStep] = useState<Step>("input");
  const [domain, setDomain] = useState("");
  const [migrateRecords, setMigrateRecords] = useState(true);
  const [result, setResult] = useState<any>(null);
  const [error, setError] = useState<string | null>(null);

  async function runOnboard() {
    setStep("running");
    try {
      const res = await callTool("onboard_domain", {
        domain,
        migrateRecords,
        applyProtection: true,
      });
      if (res.isError) {
        setError(res.content);
      } else {
        setResult(JSON.parse(res.content));
      }
    } catch (err: any) {
      setError(err.message);
    }
    setStep("done");
  }

  useInput((input, key) => {
    if (key.escape) onBack();
    if (step === "done" && key.return) onComplete();
  });

  if (step === "input") {
    return (
      <Box flexDirection="column" padding={1}>
        <Header title="Onboard Domain" breadcrumb="Back" />
        <Box marginTop={1} />
        <TextInput
          label="Domain"
          value={domain}
          onChange={setDomain}
          onSubmit={() => { if (domain.trim()) setStep("confirm"); }}
          hint="e.g. example.com"
        />
      </Box>
    );
  }

  if (step === "confirm") {
    return (
      <Box flexDirection="column" padding={1}>
        <Header title="Onboard Domain" breadcrumb="Back" />
        <Box marginTop={1} />
        <Confirm
          message={`Onboard ${domain}? This will create a Cloudflare zone, ${migrateRecords ? "migrate DNS from Namecheap, update nameservers, " : ""}and apply all protection settings.`}
          onConfirm={runOnboard}
          onCancel={onBack}
        />
      </Box>
    );
  }

  if (step === "running") {
    return (
      <Box flexDirection="column" padding={1}>
        <Header title="Onboard Domain" breadcrumb="Back" />
        <Spinner label={`Onboarding ${domain}...`} />
      </Box>
    );
  }

  return (
    <Box flexDirection="column" padding={1}>
      <Header title="Onboard Domain" breadcrumb="Back" />
      <Box marginTop={1} />
      {error ? (
        <Text color="red">Error: {error}</Text>
      ) : result ? (
        <>
          <Text color="green">Onboarded {result.domain}</Text>
          <Text>Records migrated: {result.recordsMigrated ?? "n/a"}</Text>
          <Text>Protection applied: {result.protectionApplied ?? "n/a"}</Text>
          {result.protectionFailed > 0 && (
            <Text color="yellow">Protection failures: {result.protectionFailed}</Text>
          )}
        </>
      ) : null}
      <Box marginTop={1} />
      <Text dimColor>[Enter] Back to dashboard</Text>
    </Box>
  );
}
  • [ ] Step 3: Run tests
cd tui && npx vitest run tests/screens/onboard.test.tsx

Expected: both tests PASS.

  • [ ] Step 4: Commit
git add tui/src/screens/onboard.tsx tui/tests/screens/onboard.test.tsx
git commit -m "feat(tui): add Onboard wizard with confirmation"

Task 11: Audit, Fleet, and Settings screens

Files: - Create: tui/src/screens/audit.tsx - Create: tui/src/screens/fleet.tsx - Create: tui/src/screens/settings.tsx - Create: tui/tests/screens/audit.test.tsx - Create: tui/tests/screens/fleet.test.tsx - Create: tui/tests/screens/settings.test.tsx

  • [ ] Step 1: Implement Audit screen

Create tui/src/screens/audit.tsx:

import React, { useState, useEffect } from "react";
import { Box, Text, useInput } from "ink";
import { useMcp } from "../hooks/use-mcp.js";
import Table from "../components/table.js";
import Header from "../components/header.js";
import Spinner from "../components/spinner.js";

interface AuditProps {
  onBack: () => void;
}

interface ZoneAudit {
  domain: string;
  total: number;
  ok: number;
  failed: number;
}

export default function Audit({ onBack }: AuditProps) {
  const { callTool } = useMcp();
  const [results, setResults] = useState<ZoneAudit[]>([]);
  const [loading, setLoading] = useState(true);
  const [progress, setProgress] = useState("");

  useEffect(() => {
    async function run() {
      const zonesRes = await callTool("cloudflare_list_zones");
      if (zonesRes.isError) { setLoading(false); return; }
      const zones = JSON.parse(zonesRes.content);
      const audits: ZoneAudit[] = [];
      for (const zone of zones) {
        setProgress(zone.name);
        const res = await callTool("cloudflare_get_protection_status", { domain: zone.name });
        if (!res.isError) {
          const settings = JSON.parse(res.content);
          const ok = settings.filter((s: any) => s.status === "ok").length;
          audits.push({ domain: zone.name, total: settings.length, ok, failed: settings.length - ok });
        }
      }
      setResults(audits);
      setLoading(false);
    }
    run();
  }, []);

  useInput((_, key) => { if (key.escape) onBack(); });

  if (loading) {
    return (
      <Box flexDirection="column">
        <Header title="Protection Audit" breadcrumb="Back" />
        <Spinner label={`Auditing ${progress}...`} />
      </Box>
    );
  }

  const columns = [
    { key: "domain", header: "Domain" },
    { key: "statusDisplay", header: "Status" },
    { key: "score", header: "Score" },
  ];
  const rows = results.map((r) => ({
    domain: r.domain,
    statusDisplay: r.failed === 0 ? "✓ pass" : `✗ ${r.failed} issues`,
    score: `${r.ok}/${r.total}`,
  }));

  return (
    <Box flexDirection="column">
      <Header title="Protection Audit" breadcrumb="Back" />
      <Box marginTop={1} />
      <Table columns={columns} rows={rows} />
      <Box marginTop={1} />
      <Text dimColor>Esc back</Text>
    </Box>
  );
}
  • [ ] Step 2: Implement Fleet screen

Create tui/src/screens/fleet.tsx:

import React, { useState, useEffect } from "react";
import { Box, Text, useInput } from "ink";
import { useMcp } from "../hooks/use-mcp.js";
import Table from "../components/table.js";
import Header from "../components/header.js";
import Spinner from "../components/spinner.js";

interface FleetProps {
  onBack: () => void;
}

export default function Fleet({ onBack }: FleetProps) {
  const { callTool } = useMcp();
  const [apps, setApps] = useState<any[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function load() {
      const res = await callTool("fleet_list_apps");
      if (!res.isError) {
        try { setApps(JSON.parse(res.content)); } catch { setApps([]); }
      }
      setLoading(false);
    }
    load();
  }, []);

  useInput((_, key) => { if (key.escape) onBack(); });

  if (loading) {
    return (
      <Box flexDirection="column">
        <Header title="Fleet Apps" breadcrumb="Back" />
        <Spinner label="Loading Fleet apps..." />
      </Box>
    );
  }

  const columns = [
    { key: "name", header: "App" },
    { key: "dir", header: "Directory" },
  ];
  const rows = apps.map((a: any) => ({
    name: a.name ?? a,
    dir: a.dir ?? a.directory ?? "—",
  }));

  return (
    <Box flexDirection="column">
      <Header title="Fleet Apps" breadcrumb="Back" />
      <Box marginTop={1} />
      <Table columns={columns} rows={rows} />
      <Box marginTop={1} />
      <Text dimColor>Esc back</Text>
    </Box>
  );
}
  • [ ] Step 3: Implement Settings screen

Create tui/src/screens/settings.tsx:

import React from "react";
import { Box, Text, useInput } from "ink";
import SelectInput from "ink-select-input";
import { maskSecret, type TuiConfig } from "../config.js";
import Header from "../components/header.js";

interface SettingsProps {
  config: TuiConfig | null;
  onRunSetup: () => void;
  onBack: () => void;
}

export default function Settings({ config, onRunSetup, onBack }: SettingsProps) {
  useInput((_, key) => { if (key.escape) onBack(); });

  const items = [
    { label: "Re-run setup wizard", value: "setup" },
    { label: "Back to dashboard", value: "back" },
  ];

  return (
    <Box flexDirection="column">
      <Header title="Settings" breadcrumb="Back" />
      <Box marginTop={1} flexDirection="column" paddingLeft={1}>
        {config ? (
          <>
            <Text bold>Current Configuration</Text>
            <Text>JAR: {config.jarPath || "(auto-discover)"}</Text>
            <Text>Experience: {config.experienceLevel}</Text>
            <Text>Cloudflare Key: {maskSecret(config.env.CLOUDFLARE_API_KEY ?? config.env.CLOUDFLARE_API_TOKEN ?? "")}</Text>
            <Text>Cloudflare Email: {config.env.CLOUDFLARE_EMAIL ?? "—"}</Text>
            <Text>Account ID: {config.env.CLOUDFLARE_ACCOUNT_ID}</Text>
            <Text>Namecheap User: {config.env.NAMECHEAP_API_USER}</Text>
          </>
        ) : (
          <Text color="yellow">No configuration found.</Text>
        )}
        <Box marginTop={1} />
        <SelectInput
          items={items}
          onSelect={(item) => {
            if (item.value === "setup") onRunSetup();
            else onBack();
          }}
        />
      </Box>
    </Box>
  );
}
  • [ ] Step 4: Write tests for all three

Create tui/tests/screens/audit.test.tsx:

import { describe, it, expect, vi } from "vitest";
import React from "react";
import { render } from "ink-testing-library";
import { McpContext } from "../../src/hooks/use-mcp.js";
import Audit from "../../src/screens/audit.js";

describe("Audit", () => {
  it("renders Protection Audit header", () => {
    const callTool = vi.fn(async () => ({ content: "[]", isError: false }));
    const { lastFrame } = render(
      <McpContext.Provider value={{ client: null, callTool, loading: false, error: null }}>
        <Audit onBack={vi.fn()} />
      </McpContext.Provider>
    );
    expect(lastFrame()).toContain("Protection Audit");
  });
});

Create tui/tests/screens/fleet.test.tsx:

import { describe, it, expect, vi } from "vitest";
import React from "react";
import { render } from "ink-testing-library";
import { McpContext } from "../../src/hooks/use-mcp.js";
import Fleet from "../../src/screens/fleet.js";

describe("Fleet", () => {
  it("renders Fleet Apps header", () => {
    const callTool = vi.fn(async () => ({ content: "[]", isError: false }));
    const { lastFrame } = render(
      <McpContext.Provider value={{ client: null, callTool, loading: false, error: null }}>
        <Fleet onBack={vi.fn()} />
      </McpContext.Provider>
    );
    expect(lastFrame()).toContain("Fleet Apps");
  });
});

Create tui/tests/screens/settings.test.tsx:

import { describe, it, expect, vi } from "vitest";
import React from "react";
import { render } from "ink-testing-library";
import Settings from "../../src/screens/settings.js";

describe("Settings", () => {
  it("renders settings with config", () => {
    const config = {
      jarPath: "/path.jar",
      env: { CLOUDFLARE_API_KEY: "key12345678", CLOUDFLARE_EMAIL: "[email protected]", CLOUDFLARE_ACCOUNT_ID: "acc", NAMECHEAP_API_USER: "user", NAMECHEAP_API_KEY: "nk", NAMECHEAP_CLIENT_IP: "1.1.1.1" },
      experienceLevel: "comfortable" as const,
    };
    const { lastFrame } = render(
      <Settings config={config} onRunSetup={vi.fn()} onBack={vi.fn()} />
    );
    const frame = lastFrame()!;
    expect(frame).toContain("Settings");
    expect(frame).toContain("••••••••5678");
    expect(frame).toContain("[email protected]");
  });

  it("shows warning when no config", () => {
    const { lastFrame } = render(
      <Settings config={null} onRunSetup={vi.fn()} onBack={vi.fn()} />
    );
    expect(lastFrame()).toContain("No configuration found");
  });
});
  • [ ] Step 5: Run all screen tests
cd tui && npx vitest run tests/screens/

Expected: all PASS.

  • [ ] Step 6: Commit
git add tui/src/screens/audit.tsx tui/src/screens/fleet.tsx tui/src/screens/settings.tsx tui/tests/screens/
git commit -m "feat(tui): add Audit, Fleet, and Settings screens"

Task 12: Wire up App router and CLI

Files: - Modify: tui/src/app.tsx - Modify: tui/bin/cli.ts

  • [ ] Step 1: Update App with screen router and MCP provider

Replace tui/src/app.tsx:

import React, { useState, useEffect } from "react";
import { Box, Text, useApp, useInput } from "ink";
import { McpContext } from "./hooks/use-mcp.js";
import { useConfig } from "./hooks/use-config.js";
import { createMcpClient, type McpClient } from "./mcp-client.js";
import { findJar, saveConfig, type TuiConfig } from "./config.js";
import Dashboard from "./screens/dashboard.js";
import Setup from "./screens/setup.js";
import ZoneDetail from "./screens/zone-detail.js";
import Onboard from "./screens/onboard.js";
import Audit from "./screens/audit.js";
import Fleet from "./screens/fleet.js";
import Settings from "./screens/settings.js";
import Spinner from "./components/spinner.js";

type Screen = "loading" | "setup" | "dashboard" | "zone-detail" | "onboard" | "audit" | "fleet" | "settings";

interface AppProps {
  initialScreen?: string;
  jarFlag?: string;
  configPath?: string;
}

export default function App({ initialScreen, jarFlag, configPath }: AppProps) {
  const { exit } = useApp();
  const { config, loaded, needsSetup, save } = useConfig(configPath);
  const [screen, setScreen] = useState<Screen>("loading");
  const [screenParams, setScreenParams] = useState<Record<string, unknown>>({});
  const [client, setClient] = useState<McpClient | null>(null);
  const [error, setError] = useState<string | null>(null);

  useInput((input) => {
    if (input === "q" && screen !== "setup") exit();
  });

  // Determine initial screen once config is loaded
  useEffect(() => {
    if (!loaded) return;
    if (initialScreen === "setup" || needsSetup) {
      setScreen("setup");
    } else {
      connectAndGo(config!);
    }
  }, [loaded]);

  async function connectAndGo(cfg: TuiConfig) {
    setScreen("loading");
    const jar = jarFlag ?? cfg.jarPath ?? findJar() ?? "";
    if (!jar) {
      setError("Could not find infrastructure-mcp JAR. Use --jar flag or run --setup.");
      return;
    }
    try {
      const mcpClient = createMcpClient(jar, cfg.env);
      await mcpClient.connect();
      setClient(mcpClient);
      setScreen("dashboard");
    } catch (err: any) {
      setError(`Failed to connect to MCP server: ${err.message}`);
    }
  }

  function handleSetupComplete(cfg: TuiConfig) {
    const jar = jarFlag ?? cfg.jarPath ?? findJar();
    if (jar) cfg.jarPath = jar;
    save(cfg);
    connectAndGo(cfg);
  }

  function navigate(target: string, params?: Record<string, unknown>) {
    setScreen(target as Screen);
    setScreenParams(params ?? {});
  }

  const mcpValue = {
    client,
    callTool: client?.callTool.bind(client) ?? (async () => ({ content: "", isError: true })),
    loading: false,
    error,
  };

  if (error) {
    return (
      <Box flexDirection="column" padding={1}>
        <Text color="red">Error: {error}</Text>
        <Text dimColor>Check that Java 21+ is installed and the JAR path is correct.</Text>
      </Box>
    );
  }

  if (screen === "loading") {
    return <Spinner label="Connecting to MCP server..." />;
  }

  if (screen === "setup") {
    return <Setup onComplete={handleSetupComplete} />;
  }

  return (
    <McpContext.Provider value={mcpValue}>
      {screen === "dashboard" && (
        <Dashboard onNavigate={navigate} />
      )}
      {screen === "zone-detail" && (
        <ZoneDetail
          zone={screenParams.zone as any}
          onBack={() => setScreen("dashboard")}
        />
      )}
      {screen === "onboard" && (
        <Onboard
          onComplete={() => setScreen("dashboard")}
          onBack={() => setScreen("dashboard")}
        />
      )}
      {screen === "audit" && (
        <Audit onBack={() => setScreen("dashboard")} />
      )}
      {screen === "fleet" && (
        <Fleet onBack={() => setScreen("dashboard")} />
      )}
      {screen === "settings" && (
        <Settings
          config={config}
          onRunSetup={() => setScreen("setup")}
          onBack={() => setScreen("dashboard")}
        />
      )}
    </McpContext.Provider>
  );
}
  • [ ] Step 2: Update CLI to pass flags

Replace tui/bin/cli.ts:

#!/usr/bin/env node
import meow from "meow";
import { render } from "ink";
import React from "react";
import App from "../src/app.js";

const cli = meow(
  `
  Usage
    $ infrastructure-tui

  Options
    --jar <path>     Path to infrastructure-mcp JAR
    --config <path>  Config file path (default: ~/.infrastructure-mcp.json)
    --setup          Force re-run setup wizard
    --version        Show version
    --help           Show help
`,
  {
    importMeta: import.meta,
    flags: {
      jar: { type: "string" },
      config: { type: "string" },
      setup: { type: "boolean", default: false },
    },
  }
);

render(
  React.createElement(App, {
    initialScreen: cli.flags.setup ? "setup" : undefined,
    jarFlag: cli.flags.jar,
    configPath: cli.flags.config,
  })
);
  • [ ] Step 3: Run full test suite
cd tui && npx vitest run

Expected: all tests PASS.

  • [ ] Step 4: Test manually
cd tui && npm start -- --setup

Expected: setup wizard launches in terminal.

  • [ ] Step 5: Commit
git add tui/src/app.tsx tui/bin/cli.ts
git commit -m "feat(tui): wire up App router with all screens and MCP connection"

Task 13: Final integration test and version bump

Files: - Modify: pom.xml (bump to 1.2.0) - Modify: docs/reference/changelog.md - Modify: docs/index.md

  • [ ] Step 1: Run full TUI test suite
cd tui && npx vitest run

Expected: all tests PASS.

  • [ ] Step 2: Build TUI
cd tui && npx tsc --noEmit

Expected: no TypeScript errors.

  • [ ] Step 3: Bump Java version to 1.2.0

Edit pom.xml line 9: change <version>1.1.2</version> to <version>1.2.0</version>.

  • [ ] Step 4: Rebuild Java JAR
cd /home/matt/mcp/infrastructure-mcp && mvn clean package -DskipTests -q
  • [ ] Step 5: Update changelog

Add to top of docs/reference/changelog.md:

## v1.2.0 (2026-03-29)

### Added
- **Interactive TUI** built with Ink (React for CLIs) — full infrastructure management without an AI agent
  - Dashboard-first interface showing all Cloudflare zones and Fleet apps
  - Domain onboarding wizard with confirmation before destructive actions
  - Zone detail view with DNS records and protection audit
  - Bulk protection audit across all zones
  - Fleet apps viewer
  - Settings screen with masked credential display
- **Setup wizard** with three experience levels (learner/comfortable/professional)
  - Learner mode encourages source code review before entering API keys
  - Adaptive credential input with contextual help
- **MCP-over-stdio client** — TUI communicates with Java MCP server via JSON-RPC, zero API duplication
- **Config system** — own config file with fallback to Claude Code's `~/.claude.json`
- **Table component** with Unicode box-drawing, dynamic column widths, and proper alignment
  • [ ] Step 6: Update index.md version

Change Current release: **v1.1.2** to Current release: **v1.2.0** in docs/index.md.

  • [ ] Step 7: Rebuild docs
cd /home/matt/mcp/infrastructure-mcp && mkdocs build --clean --quiet
  • [ ] Step 8: Commit
git add pom.xml docs/reference/changelog.md docs/index.md tui/
git commit -m "feat: Infrastructure TUI v1.2.0 — interactive terminal UI with Ink"