terminal-pilot
v0.0.9
Published
Playwright-like SDK and CLI for automating interactive CLI apps through a real PTY
Readme
terminal-pilot
terminal-pilot is a Playwright-like SDK for automating interactive CLI apps through a real pseudoterminal (PTY).
Use it when plain stdio is not enough: menus, prompts, arrow-key navigation, confirmations, and terminal redraws such as poe-code configure.
For design rationale and scope, see docs/plans/terminal-pilot.md.
For the MCP server, see terminal-pilot-mcp.
CLI
The terminal-pilot package ships a CLI binary. After installing globally or via npx, all SDK commands and the skill installer are available from the command line.
Install globally
npm install -g terminal-pilotOr run directly with npx:
npx terminal-pilot <command> [options]Skill installation
The CLI can install a Claude Code skill that teaches the agent how to use terminal-pilot's MCP tools. Supported agents: claude-code, codex, opencode.
Install the skill (local project, default):
terminal-pilot install claude-codeInstall the skill globally (user home):
terminal-pilot install claude-code --globalUninstall the skill:
terminal-pilot uninstall claude-codeBy default, install targets the current project (--local). Use --global to install in the user's home directory. You cannot pass both --local and --global.
What it includes
- SDK:
TerminalPilot→TerminalSession→TerminalScreen - Real PTY execution: works with interactive CLIs that expect a terminal
- Headless by default: optional
observe: truemirrors PTY output for debugging
Entry points
import { TerminalPilot } from "terminal-pilot";SDK API
TerminalPilot
Creates and tracks active terminal sessions.
import { TerminalPilot } from "terminal-pilot";
const pilot = await TerminalPilot.launch();
const session = await pilot.newSession({
command: "poe-code",
args: ["configure"],
cwd: process.cwd(),
env: process.env,
cols: 120,
rows: 40,
observe: false
});
console.log(session.id, session.pid);
console.log(pilot.sessions().map(({ id, pid }) => ({ id, pid })));
await pilot.close();type NewSessionOptions = {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
cols?: number; // default: 120
rows?: number; // default: 40
observe?: boolean; // default: false
};
class TerminalPilot {
static launch(): Promise<TerminalPilot>;
newSession(options: NewSessionOptions): Promise<TerminalSession>;
getSession(id: string): TerminalSession; // throws if not in map; works for exited sessions until deleteSession
deleteSession(id: string): void; // explicit removal from session map
sessions(): TerminalSession[]; // running sessions only (exitCode === null)
close(): Promise<void>;
}TerminalSession
Represents one PTY-backed CLI process.
await session.waitFor(/Pick an agent to configure:/);
await session.press("ArrowDown");
await session.press("Enter");
await session.waitFor(/Waiting for authorization|default model|configured/i);
const screen = await session.screen();
const history = await session.history({ last: 20 });
console.log(screen.text);
console.log(history.join("\n"));
await session.resize(100, 30);
await session.signal("SIGINT");
await session.close();type WaitForOptions = {
timeout?: number; // default: 10000
};
type HistoryOptions = {
last?: number;
};
type TerminalKey =
| "Enter"
| "Tab"
| "Escape"
| "Backspace"
| "Delete"
| "ArrowUp"
| "ArrowDown"
| "ArrowLeft"
| "ArrowRight"
| "Home"
| "End"
| "PageUp"
| "PageDown"
| "Space"
| `Control+${string}`
| `Alt+${string}`;
class TerminalSession {
readonly id: string;
readonly command: string;
readonly pid: number;
exitCode: number | null;
type(text: string): Promise<void>; // character-by-character with delay
fill(text: string): Promise<void>; // bulk write (\n → \r)
press(key: TerminalKey): Promise<void>;
send(raw: string): Promise<void>; // raw bytes / escape sequences
signal(signal: string): Promise<void>;
waitFor(pattern: string | RegExp, options?: WaitForOptions): Promise<string>;
waitForExit(options?: { timeout?: number }): Promise<number>; // throws on timeout
waitForQuiet(ms: number): Promise<void>;
screen(): Promise<TerminalScreen>;
history(options?: HistoryOptions): Promise<string[]>;
resize(cols: number, rows: number): Promise<void>;
close(): Promise<number>;
on(event: "exit", cb: (code: number) => void): void;
}Common key names:
- Navigation:
ArrowUp,ArrowDown,ArrowLeft,ArrowRight,Home,End - Editing:
Enter,Tab,Backspace,Delete,Space - Control/meta:
Control+c,Control+d,Alt+x
TerminalScreen
Normalized visible terminal state.
const screen = await session.screen();
screen.lines; // ANSI-stripped visible lines
screen.rawLines; // raw visible lines
screen.cursor; // { row, col }
screen.size; // { rows, cols }
screen.text; // lines joined with \n
screen.contains("Configured");
screen.line(0);
screen.line(-1);class TerminalScreen {
readonly lines: readonly string[];
readonly rawLines: readonly string[];
readonly cursor: { row: number; col: number };
readonly size: { rows: number; cols: number };
get text(): string;
contains(substring: string): boolean;
line(index: number): string; // negative indexes supported
}Environment variables
There are no terminal-pilot-specific environment variables.
Runtime environment is controlled per session via newSession({ env }). There are no package-level config files or config options beyond the per-session options.
Testing
Interactive fixtures live under packages/terminal-pilot/src/testing/:
test-cli.ts- prompt + text entry fixturemenu-cli.ts- arrow-key menu fixturefixtures.test.ts- examples of driving both fixtures
Run just the fixture tests:
npx vitest run packages/terminal-pilot/src/testing/fixtures.test.tsRun the whole package test suite:
npx vitest run packages/terminal-pilot/srcTypical fixture workflow:
- Spawn the fixture in a
TerminalSession waitFor(...)the prompttype(...),fill(...),press(...), orsignal(...)- Assert with
screen()orhistory() close()the session
Example fixture-based test:
import path from "node:path";
import { TerminalPilot } from "terminal-pilot";
const pilot = await TerminalPilot.launch();
const tsxPath = path.join(process.cwd(), "node_modules", ".bin", "tsx");
const fixturePath = path.join(process.cwd(), "packages/terminal-pilot/src/testing/menu-cli.ts");
try {
const session = await pilot.newSession({
command: tsxPath,
args: [fixturePath]
});
await session.waitFor("Select an option:");
await session.press("ArrowDown");
await session.press("ArrowDown");
await session.press("Enter");
await session.waitFor("You selected: Option 3");
} finally {
await pilot.close();
}Example: test poe-code configure
Use a temporary home directory so you do not touch your real config while testing the interactive flow.
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { TerminalPilot } from "terminal-pilot";
const tmpHome = mkdtempSync(path.join(os.tmpdir(), "poe-configure-test-"));
const pilot = await TerminalPilot.launch();
try {
const session = await pilot.newSession({
command: "npm",
args: ["run", "dev", "--silent", "--", "configure"],
cwd: process.cwd(),
env: {
...process.env,
HOME: tmpHome,
XDG_CONFIG_HOME: path.join(tmpHome, ".config"),
XDG_DATA_HOME: path.join(tmpHome, ".local", "share")
},
cols: 120,
rows: 40
});
await session.waitFor(/Pick an agent to configure:/);
await session.press("ArrowDown"); // choose Codex, Kimi, etc.
await session.press("Enter");
await session.waitFor(/Waiting for authorization|default model|configured/i, {
timeout: 15000
});
const screen = await session.screen();
const history = await session.history({ last: 40 });
console.log(screen.text);
console.log(history.join("\n"));
await session.signal("SIGINT");
await session.close();
} finally {
await pilot.close();
rmSync(tmpHome, { recursive: true, force: true });
}That gives you a real end-to-end test of the interactive configure handoff. If the selected provider requires OAuth or API-key input, keep the temp environment and continue the flow with additional type(...), press(...), and waitFor(...) calls.
