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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
Expected: all tests PASS.
- [ ] Step 4: Test manually
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
Expected: all tests PASS.
- [ ] Step 2: Build TUI
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
- [ ] 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
- [ ] Step 8: Commit