feat(cli): add paseo terminal command group for managing workspace terminals

New CLI commands: ls, create, kill, capture (tmux capture-pane style with
line range slicing and ANSI stripping), and send-keys (with special token
interpretation). Server-side adds capture_terminal_request RPC and
list-all-terminals support (optional CWD). Includes e2e tests and skill docs.
This commit is contained in:
Mohamed Boudra
2026-03-30 17:05:14 +07:00
parent 5b22aedaff
commit 0ba2f6b194
15 changed files with 938 additions and 9 deletions
+4
View File
@@ -8,6 +8,7 @@ import { createPermitCommand } from "./commands/permit/index.js";
import { createProviderCommand } from "./commands/provider/index.js";
import { createScheduleCommand } from "./commands/schedule/index.js";
import { createSpeechCommand } from "./commands/speech/index.js";
import { createTerminalCommand } from "./commands/terminal/index.js";
import { createWorktreeCommand } from "./commands/worktree/index.js";
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js";
@@ -143,6 +144,9 @@ export function createCli(): Command {
// Chat commands
program.addCommand(createChatCommand());
// Terminal commands
program.addCommand(createTerminalCommand());
// Loop commands
program.addCommand(createLoopCommand());
@@ -0,0 +1,99 @@
import type { Command } from "commander";
import { renderError, toCommandError } from "../../output/render.js";
import {
connectTerminalClient,
resolveTerminalId,
toTerminalCommandError,
type TerminalCommandOptions,
} from "./shared.js";
export interface TerminalCaptureOptions extends TerminalCommandOptions {
start?: string;
end?: string;
scrollback?: boolean;
ansi?: boolean;
}
export async function runCaptureCommand(
terminalId: string,
_options: TerminalCaptureOptions,
command: Command,
): Promise<void> {
const options = command.optsWithGlobals() as TerminalCaptureOptions;
try {
const payload = await executeCaptureCommand(terminalId, options);
if (options.json) {
process.stdout.write(
JSON.stringify(
{
terminalId: payload.terminalId,
lines: payload.lines,
totalLines: payload.totalLines,
},
null,
2,
) + "\n",
);
return;
}
if (payload.lines.length > 0) {
process.stdout.write(payload.lines.join("\n") + "\n");
}
} catch (err) {
const output = renderError(toCommandError(err), {
format: options.json ? "json" : "table",
noColor: options.color === false,
});
process.stderr.write(output + "\n");
process.exit(1);
}
}
async function executeCaptureCommand(
terminalId: string,
options: TerminalCaptureOptions,
): Promise<{ terminalId: string; lines: string[]; totalLines: number }> {
const { client } = await connectTerminalClient(options.host);
try {
const resolvedId = await resolveTerminalId(client, terminalId);
if (!resolvedId) {
throw {
code: "TERMINAL_NOT_FOUND",
message: `No terminal found matching: ${terminalId}`,
details: "Use `paseo terminal ls --all` to list available terminals.",
};
}
const start = options.scrollback ? 0 : parseLineNumber("--start", options.start);
const end = parseLineNumber("--end", options.end);
return await client.captureTerminal(resolvedId, {
...(start === undefined ? {} : { start }),
...(end === undefined ? {} : { end }),
stripAnsi: !options.ansi,
});
} catch (err) {
throw toTerminalCommandError("TERMINAL_CAPTURE_FAILED", "capture terminal output", err);
} finally {
await client.close().catch(() => {});
}
}
function parseLineNumber(flag: string, value?: string): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed)) {
throw {
code: "INVALID_LINE_NUMBER",
message: `Invalid ${flag} value: ${value}`,
details: "Use an integer line number.",
};
}
return parsed;
}
@@ -0,0 +1,41 @@
import type { Command } from "commander";
import type { SingleResult, CommandError } from "../../output/index.js";
import {
connectTerminalClient,
toTerminalCommandError,
type TerminalCommandOptions,
} from "./shared.js";
import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js";
export interface TerminalCreateOptions extends TerminalCommandOptions {
cwd?: string;
name?: string;
}
export async function runCreateCommand(
options: TerminalCreateOptions,
_command: Command,
): Promise<SingleResult<TerminalRow>> {
const { client } = await connectTerminalClient(options.host);
const cwd = options.cwd ?? process.cwd();
try {
const payload = await client.createTerminal(cwd, options.name);
if (!payload.terminal) {
const error: CommandError = {
code: "TERMINAL_CREATE_FAILED",
message: payload.error ?? "Failed to create terminal",
};
throw error;
}
return {
type: "single",
data: toTerminalRow(payload.terminal),
schema: terminalSchema,
};
} catch (err) {
throw toTerminalCommandError("TERMINAL_CREATE_FAILED", "create terminal", err);
} finally {
await client.close().catch(() => {});
}
}
@@ -0,0 +1,59 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addDaemonHostOption, addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { runCaptureCommand } from "./capture.js";
import { runCreateCommand } from "./create.js";
import { runKillCommand } from "./kill.js";
import { runLsCommand } from "./ls.js";
import { runSendKeysCommand } from "./send-keys.js";
export function createTerminalCommand(): Command {
const terminal = new Command("terminal").description("Manage workspace terminals");
addJsonAndDaemonHostOptions(
terminal
.command("ls")
.description("List terminals")
.option("--all", "List terminals across all workspaces")
.option("--cwd <path>", "Workspace directory"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
terminal
.command("create")
.description("Create a terminal")
.option("--cwd <path>", "Workspace directory")
.option("--name <name>", "Terminal name"),
).action(withOutput(runCreateCommand));
addJsonAndDaemonHostOptions(
terminal
.command("kill")
.description("Kill a terminal")
.argument("<terminal-id>", "Terminal ID, ID prefix, or name"),
).action(withOutput(runKillCommand));
addDaemonHostOption(
terminal
.command("capture")
.description("Capture terminal output")
.argument("<terminal-id>", "Terminal ID, ID prefix, or name")
.option("--start <n>", "Capture start line")
.option("--end <n>", "Capture end line")
.option("-S, --scrollback", "Capture from the beginning of scrollback")
.option("--ansi", "Preserve ANSI escape codes")
.option("--json", "Output in JSON format"),
).action(runCaptureCommand);
addDaemonHostOption(
terminal
.command("send-keys")
.description("Send keys to a terminal")
.argument("<terminal-id>", "Terminal ID, ID prefix, or name")
.argument("<keys...>", "Keys to send")
.option("-l, --literal", "Send raw keys without interpreting special tokens")
.option("--json", "Output in JSON format"),
).action(runSendKeysCommand);
return terminal;
}
@@ -0,0 +1,51 @@
import type { Command } from "commander";
import type { CommandError, SingleResult } from "../../output/index.js";
import {
connectTerminalClient,
resolveTerminalId,
toTerminalCommandError,
type TerminalCommandOptions,
} from "./shared.js";
import { terminalKillSchema, type TerminalKillRow } from "./schema.js";
export async function runKillCommand(
terminalId: string,
options: TerminalCommandOptions,
_command: Command,
): Promise<SingleResult<TerminalKillRow>> {
const { client } = await connectTerminalClient(options.host);
try {
const resolvedId = await requireTerminalId(client, terminalId);
const payload = await client.killTerminal(resolvedId);
return {
type: "single",
data: {
terminalId: payload.terminalId,
success: payload.success,
},
schema: terminalKillSchema,
};
} catch (err) {
throw toTerminalCommandError("TERMINAL_KILL_FAILED", "kill terminal", err);
} finally {
await client.close().catch(() => {});
}
}
async function requireTerminalId(
client: Awaited<ReturnType<typeof connectTerminalClient>>["client"],
terminalId: string,
): Promise<string> {
const resolvedId = await resolveTerminalId(client, terminalId);
if (resolvedId) {
return resolvedId;
}
const error: CommandError = {
code: "TERMINAL_NOT_FOUND",
message: `No terminal found matching: ${terminalId}`,
details: "Use `paseo terminal ls --all` to list available terminals.",
};
throw error;
}
+34
View File
@@ -0,0 +1,34 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import {
connectTerminalClient,
toTerminalCommandError,
type TerminalCommandOptions,
} from "./shared.js";
import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js";
export interface TerminalLsOptions extends TerminalCommandOptions {
all?: boolean;
cwd?: string;
}
export async function runLsCommand(
options: TerminalLsOptions,
_command: Command,
): Promise<ListResult<TerminalRow>> {
const { client } = await connectTerminalClient(options.host);
const cwd = options.all ? undefined : (options.cwd ?? process.cwd());
try {
const payload = cwd === undefined ? await client.listTerminals() : await client.listTerminals(cwd);
return {
type: "list",
data: payload.terminals.map((terminal) => toTerminalRow(terminal, payload.cwd ?? cwd)),
schema: terminalSchema,
};
} catch (err) {
throw toTerminalCommandError("TERMINAL_LIST_FAILED", "list terminals", err);
} finally {
await client.close().catch(() => {});
}
}
@@ -0,0 +1,44 @@
import type { OutputSchema } from "../../output/index.js";
export interface TerminalRow {
id: string;
name: string;
cwd: string;
}
export interface TerminalKillRow {
terminalId: string;
success: boolean;
}
export const terminalSchema: OutputSchema<TerminalRow> = {
idField: "id",
columns: [
{ header: "ID", field: (row) => row.id.slice(0, 8), width: 8 },
{ header: "NAME", field: "name", width: 24 },
{ header: "CWD", field: "cwd", width: 48 },
],
};
export const terminalKillSchema: OutputSchema<TerminalKillRow> = {
idField: "terminalId",
columns: [
{ header: "ID", field: (row) => row.terminalId.slice(0, 8), width: 8 },
{ header: "SUCCESS", field: "success", width: 8 },
],
};
export function toTerminalRow(
terminal: {
id: string;
name: string;
cwd?: string;
},
cwd?: string,
): TerminalRow {
return {
id: terminal.id,
name: terminal.name,
cwd: terminal.cwd ?? cwd ?? "-",
};
}
@@ -0,0 +1,99 @@
import type { Command } from "commander";
import { renderError, toCommandError } from "../../output/render.js";
import {
connectTerminalClient,
resolveTerminalId,
toTerminalCommandError,
type TerminalCommandOptions,
} from "./shared.js";
export interface TerminalSendKeysOptions extends TerminalCommandOptions {
literal?: boolean;
}
export async function runSendKeysCommand(
terminalId: string,
keys: string[],
_options: TerminalSendKeysOptions,
command: Command,
): Promise<void> {
const options = command.optsWithGlobals() as TerminalSendKeysOptions;
try {
const payload = await executeSendKeysCommand(terminalId, keys, options);
if (options.json) {
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
}
} catch (err) {
const output = renderError(toCommandError(err), {
format: options.json ? "json" : "table",
noColor: options.color === false,
});
process.stderr.write(output + "\n");
process.exit(1);
}
}
async function executeSendKeysCommand(
terminalId: string,
keys: string[],
options: TerminalSendKeysOptions,
): Promise<{ terminalId: string; keysSent: number }> {
const { client } = await connectTerminalClient(options.host);
try {
const resolvedId = await resolveTerminalId(client, terminalId);
if (!resolvedId) {
throw {
code: "TERMINAL_NOT_FOUND",
message: `No terminal found matching: ${terminalId}`,
details: "Use `paseo terminal ls --all` to list available terminals.",
};
}
const data = keys.map((key) => resolveKeyToken(key, options.literal === true)).join("");
client.sendTerminalInput(resolvedId, { type: "input", data });
return {
terminalId: resolvedId,
keysSent: data.length,
};
} catch (err) {
throw toTerminalCommandError("TERMINAL_SEND_KEYS_FAILED", "send terminal keys", err);
} finally {
await client.close().catch(() => {});
}
}
function resolveKeyToken(key: string, literal: boolean): string {
if (literal) {
return key;
}
switch (key) {
case "Enter":
return "\r";
case "Tab":
return "\t";
case "Escape":
return "\u001b";
case "Space":
return " ";
case "BSpace":
return "\u007f";
case "C-c":
return "\u0003";
case "C-d":
return "\u0004";
case "C-z":
return "\u001a";
case "C-l":
return "\u000c";
case "C-a":
return "\u0001";
case "C-e":
return "\u0005";
default:
return key;
}
}
@@ -0,0 +1,87 @@
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
export interface TerminalCommandOptions extends CommandOptions {
host?: string;
}
interface TerminalLike {
id: string;
name?: string | null;
}
export async function connectTerminalClient(host?: string) {
const daemonHost = getDaemonHost({ host });
try {
const client = await connectToDaemon({ host });
return { client, daemonHost };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${daemonHost}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
}
export function toTerminalCommandError(code: string, action: string, err: unknown): CommandError {
if (err && typeof err === "object" && "code" in err && "message" in err) {
return err as CommandError;
}
const message = err instanceof Error ? err.message : String(err);
const rpcCode =
typeof err === "object" && err !== null && "code" in err && typeof err.code === "string"
? err.code
: undefined;
return {
code: rpcCode ?? code,
message: `Failed to ${action}: ${message}`,
};
}
export async function resolveTerminalId(
client: Awaited<ReturnType<typeof connectToDaemon>>,
idOrName: string,
): Promise<string | null> {
const payload = await client.listTerminals();
return resolveTerminalIdentifier(idOrName, payload.terminals);
}
function resolveTerminalIdentifier(idOrName: string, terminals: TerminalLike[]): string | null {
if (!idOrName || terminals.length === 0) {
return null;
}
const query = idOrName.toLowerCase();
const exactMatch = terminals.find((terminal) => terminal.id === idOrName);
if (exactMatch) {
return exactMatch.id;
}
const prefixMatches = terminals.filter((terminal) => terminal.id.toLowerCase().startsWith(query));
if (prefixMatches.length === 1 && prefixMatches[0]) {
return prefixMatches[0].id;
}
if (prefixMatches.length > 1) {
return null;
}
const nameMatches = terminals.filter((terminal) => terminal.name?.toLowerCase() === query);
if (nameMatches.length === 1 && nameMatches[0]) {
return nameMatches[0].id;
}
const partialNameMatches = terminals.filter((terminal) =>
terminal.name?.toLowerCase().includes(query),
);
if (partialNameMatches.length === 1 && partialNameMatches[0]) {
return partialNameMatches[0].id;
}
return null;
}