npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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-pilot

Or 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-code

Install the skill globally (user home):

terminal-pilot install claude-code --global

Uninstall the skill:

terminal-pilot uninstall claude-code

By 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: TerminalPilotTerminalSessionTerminalScreen
  • Real PTY execution: works with interactive CLIs that expect a terminal
  • Headless by default: optional observe: true mirrors 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 fixture
  • menu-cli.ts - arrow-key menu fixture
  • fixtures.test.ts - examples of driving both fixtures

Run just the fixture tests:

npx vitest run packages/terminal-pilot/src/testing/fixtures.test.ts

Run the whole package test suite:

npx vitest run packages/terminal-pilot/src

Typical fixture workflow:

  1. Spawn the fixture in a TerminalSession
  2. waitFor(...) the prompt
  3. type(...), fill(...), press(...), or signal(...)
  4. Assert with screen() or history()
  5. 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.