diff --git a/README.md b/README.md index 9cab3f7..f3bfc8c 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,8 @@ To test local development changes with Claude Code, add the local build as an MC # From the shellwright repo root - build first! npm run build claude mcp add shellwright-dev --scope project -- node "${PWD}/dist/index.js" +# remove with: +claude mcp remove shellwright-dev --scope project ``` This registers your local build so you can test changes before publishing. diff --git a/docs/shell-attach.md b/docs/shell-attach.md new file mode 100644 index 0000000..6608bcb --- /dev/null +++ b/docs/shell-attach.md @@ -0,0 +1,104 @@ +# shell_attach + +The `shell_attach` tool allows you to connect Shellwright to an existing terminal session. This makes it easier to create recordings from your own interactive sessions (rather than instructing an AI agent to do it). + +Being able to attach to a shell allows you to write commands and scripts that start recording your screen - which can be trigged by things like [Claude Code slash commands](https://code.claude.com/docs/en/slash-commands). + +Shell sessions must be running in [`tmux`](https://github.com/tmux/tmux/wiki) for this to work. When tmux wraps a shell it allows content to be captures - which is how this shell attach command works. Support for `screen` and vanilla shells is tracked in [#51](https://github.com/dwmkerr/shellwright/issues/51). + +Technically, the process is: + +```bash +shell_attach # walk up process tree to find PID with tty +ps -p $PID -o tty= # get tty device for that process +tmux list-panes -a | grep $TTY # find tmux pane for the tty +tmux capture-pane -t $PANE -p -e # capture pane content with ANSI codes +``` + +For a screenshot or recording: + +```bash +shell_attach {tty} # starts capture loop (tmux → xterm buffer, polls every 100ms) +shell_screenshot # reads xterm buffer once +shell_record_start # starts recording loop (xterm → PNG frames at configured FPS) +shell_record_stop # stops recording loop, renders GIF +shell_detach # stops capture loop, cleans up +``` + +## Usage + +Start a tmux session, then run something like Claude code: + +```bash +# Start tmux, then run Claude Code inside it +tmux new -s main +claude +``` + +Then attach to the terminal: + +```bash +# Initialize MCP session (see scripts/mcp-init.sh) +SESSION=$(./scripts/mcp-init.sh) + +# Attach to current terminal - pass your shell's TTY +./scripts/mcp-tool-call.sh $SESSION shell_attach "{\"tty\":\"$(tty)\"}" +``` + +Example response: + +```json +{ "session_id": "shell-session-abc123" } +``` + +Note: `shell_attach` will only work when the Shellwright MCP server is running on the same host as the target terminal. Using the `stdio` transport should always work, and the `http` transport will work if running on the same host. The `http` transport on a remote host will not be able to access the local shell session. + +The `session_id` can be used with `shell_screenshot`, `shell_record_start`, `shell_record_stop`, and `shell_read`. + +Take a screenshot: + +```bash +./scripts/mcp-tool-call.sh $SESSION shell_screenshot '{"session_id": "shell-session-abc123"}' +``` + +Example response: + +```json +{ "filename": "screenshot.png", "download_url": "http://localhost:7498/files/..." } +``` + +Start a recording: + +```bash +./scripts/mcp-tool-call.sh $SESSION shell_record_start '{"session_id": "shell-session-abc123"}' +``` + +Example response: + +```json +{ "recording": true, "fps": 10 } +``` + +To stop recording: + +```bash +./scripts/mcp-tool-call.sh $SESSION shell_record_stop '{"session_id": "shell-session-abc123"}' +``` + +Example response: + +```json +{ "filename": "recording_1234567890.gif", "download_url": "http://localhost:7498/files/..." } +``` + +Detach the session with: + +```bash +./scripts/mcp-tool-call.sh $SESSION shell_detach '{"session_id": "shell-session-abc123"}' +``` + +Example response: + +```json +{ "success": true } +``` diff --git a/package.json b/package.json index cc22510..5720c31 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "test:k9s": "tsx tests/04-k9s/run.ts", "lint": "eslint src/**/*.ts", "eval": "cd evaluations && npm start", - "eval:compare": "cd evaluations && npm run compare" + "eval:compare": "cd evaluations && npm run compare", + "inspect": "npx @modelcontextprotocol/inspector" }, "repository": { "type": "git", diff --git a/scripts/mcp-init.sh b/scripts/mcp-init.sh new file mode 100755 index 0000000..5683cc4 --- /dev/null +++ b/scripts/mcp-init.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Initialize MCP session and output session ID +# Usage: ./scripts/mcp-init.sh [port] + +PORT="${1:-7498}" +URL="http://localhost:${PORT}/mcp" + +SESSION_ID=$(curl -s -D - -X POST "$URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"mcp-cli","version":"1.0"}}}' \ + 2>/dev/null | grep -i "mcp-session-id:" | cut -d' ' -f2 | tr -d '\r') + +if [ -z "$SESSION_ID" ]; then + echo "[mcp-init] error: failed to get session ID" >&2 + exit 1 +fi + +echo "$SESSION_ID" diff --git a/scripts/mcp-tool-call.sh b/scripts/mcp-tool-call.sh new file mode 100755 index 0000000..469de9c --- /dev/null +++ b/scripts/mcp-tool-call.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Call an MCP tool +# Usage: ./scripts/mcp-tool-call.sh [args_json] +# Example: ./scripts/mcp-tool-call.sh $SESSION shell_start '{"command":"bash"}' + +SESSION_ID="$1" +TOOL_NAME="$2" +ARGS_JSON="${3:-{}}" +PORT="${4:-7498}" + +if [ -z "$SESSION_ID" ] || [ -z "$TOOL_NAME" ]; then + echo "Usage: $0 [args_json] [port]" >&2 + exit 1 +fi + +URL="http://localhost:${PORT}/mcp" + +REQUEST=$(cat </dev/null | grep "^data:" | sed 's/^data: //' | jq -r '.result.content[0].text // .error.message // .' diff --git a/src/index.ts b/src/index.ts index c62815e..afd91ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import express, { Request, Response } from "express"; import { randomUUID } from "crypto"; +import { execSync } from "child_process"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; @@ -147,18 +148,80 @@ interface RecordingState { interface Session { id: string; - pty: pty.IPty; + pty?: pty.IPty; cols: number; rows: number; buffer: string[]; terminal: InstanceType; theme: Theme; recording?: RecordingState; + attached?: { + tmuxPane: string; + tty: string; + captureInterval?: ReturnType; + }; } const sessions = new Map(); const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; +// Find TTY by walking up the process tree +function findParentTty(): string | null { + try { + let pid = process.ppid; + for (let i = 0; i < 10; i++) { + const tty = execSync(`ps -p ${pid} -o tty=`, { encoding: "utf-8" }).trim(); + if (tty && tty !== "??" && !tty.startsWith("?")) { + return tty.startsWith("/dev/") ? tty : `/dev/${tty}`; + } + const ppid = execSync(`ps -p ${pid} -o ppid=`, { encoding: "utf-8" }).trim(); + if (!ppid || ppid === "0" || ppid === "1") break; + pid = parseInt(ppid, 10); + } + } catch { + return null; + } + return null; +} + +// Find tmux pane for a TTY +function findTmuxPane(tty: string): string | null { + try { + const ttyShort = tty.replace("/dev/", ""); + const panes = execSync("tmux list-panes -a -F '#{pane_tty} #{session_name}:#{window_index}.#{pane_index}'", { encoding: "utf-8" }); + for (const line of panes.split("\n")) { + const [paneTty, paneId] = line.trim().split(" "); + if (paneTty === tty || paneTty === `/dev/${ttyShort}` || paneTty.endsWith(ttyShort)) { + return paneId; + } + } + } catch { + return null; + } + return null; +} + +// Get tmux pane dimensions +function getTmuxPaneDimensions(pane: string): { cols: number; rows: number } | null { + try { + const output = execSync(`tmux display-message -t "${pane}" -p "#{pane_width} #{pane_height}"`, { encoding: "utf-8" }).trim(); + const [cols, rows] = output.split(" ").map(Number); + if (cols && rows) return { cols, rows }; + } catch { + return null; + } + return null; +} + +// Capture tmux pane content with ANSI codes +function captureTmuxPane(pane: string): string { + try { + return execSync(`tmux capture-pane -t "${pane}" -p -e`, { encoding: "utf-8" }); + } catch { + return ""; + } +} + // Build temp path: /tmp/shellwright/mcp-session-{mcpId}/{shellId} function getSessionDir(mcpSessionId: string | undefined, shellSessionId: string): string { const mcpPart = mcpSessionId ? `mcp-session-${mcpSessionId}` : "mcp-session-unknown"; @@ -299,7 +362,11 @@ Tips: async ({ session_id, input, delay_ms }) => { const session = sessions.get(session_id); if (!session) { - throw new Error(`Session not found: ${session_id}`); + throw new Error(`[shell_send] error: session not found: ${session_id}`); + } + + if (session.attached || !session.pty) { + throw new Error(`[shell_send] error: cannot send input to attached sessions`); } const bufferBefore = bufferToText(session.terminal, session.cols, session.rows); @@ -407,14 +474,18 @@ Tips: server.tool( "shell_stop", - "Stop a PTY session", + "Stop a PTY session. For attached sessions, use shell_detach instead.", { session_id: z.string().describe("Session ID"), }, async ({ session_id }) => { const session = sessions.get(session_id); if (!session) { - throw new Error(`Session not found: ${session_id}`); + throw new Error(`[shell_stop] error: session not found: ${session_id}`); + } + + if (session.attached) { + throw new Error(`[shell_stop] error: use shell_detach for attached sessions`); } // Stop recording if active @@ -422,7 +493,7 @@ Tips: clearInterval(session.recording.interval); } - session.pty.kill(); + session.pty?.kill(); sessions.delete(session_id); log(`[shellwright] Stopped session ${session_id}`); @@ -435,6 +506,112 @@ Tips: } ); + server.tool( + "shell_attach", + "Attach to a terminal for screenshots and recordings. Requires tmux. Pass the TTY of the target terminal.", + { + tty: z.string().describe("TTY device path (e.g., $(tty) in bash gives /dev/ttys001)"), + }, + async ({ tty }) => { + if (!tty) { + throw new Error(`[shell_attach] error: tty parameter required`); + } + + // Find tmux pane + const tmuxPane = findTmuxPane(tty); + if (!tmuxPane) { + throw new Error(`[shell_attach] error: tmux not detected. Run Claude Code inside tmux.`); + } + + // Get pane dimensions + const dims = getTmuxPaneDimensions(tmuxPane); + const cols = dims?.cols || COLS; + const rows = dims?.rows || ROWS; + + const id = `shell-session-${randomUUID().slice(0, 6)}`; + + const terminal = new Terminal({ + cols, + rows, + allowProposedApi: true, + }); + + const session: Session = { + id, + cols, + rows, + buffer: [], + terminal, + theme: currentTheme, + attached: { + tmuxPane, + tty, + }, + }; + + // Start continuous capture from tmux pane + const captureAndUpdate = () => { + const content = captureTmuxPane(tmuxPane); + if (content) { + session.buffer = [content]; + terminal.reset(); + terminal.write(content); + } + }; + + captureAndUpdate(); + session.attached!.captureInterval = setInterval(captureAndUpdate, 100); + + sessions.set(id, session); + log(`[shellwright] Attached session ${id} to ${tmuxPane} (${tty})`); + + const output = { session_id: id }; + logToolCall("shell_attach", {}, output); + + return { + content: [{ type: "text" as const, text: JSON.stringify(output) }], + }; + } + ); + + server.tool( + "shell_detach", + "Detach from an attached terminal session. For spawned sessions, use shell_stop instead.", + { + session_id: z.string().describe("Session ID"), + }, + async ({ session_id }) => { + const session = sessions.get(session_id); + if (!session) { + throw new Error(`[shell_detach] error: session not found: ${session_id}`); + } + + if (!session.attached) { + throw new Error(`[shell_detach] error: use shell_stop for spawned sessions`); + } + + // Stop capture interval + if (session.attached.captureInterval) { + clearInterval(session.attached.captureInterval); + } + + // Stop recording if active + if (session.recording) { + clearInterval(session.recording.interval); + } + + sessions.delete(session_id); + log(`[shellwright] Detached session ${session_id}`); + + const output = { success: true }; + logToolCall("shell_detach", { session_id }, output); + + return { + content: [{ type: "text" as const, text: JSON.stringify(output) }], + }; + } + ); + server.tool( "shell_record_start", "Start recording a terminal session (captures frames for GIF/video export)",