Initial monorepo setup with mcp-server and web packages
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
|
||||
interface HttpServerOptions {
|
||||
port: number;
|
||||
password: string;
|
||||
server: McpServer;
|
||||
}
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
isAuthenticated?: boolean;
|
||||
}
|
||||
|
||||
export function startHttpServer({ port, password, server }: HttpServerOptions): void {
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Password authentication middleware
|
||||
function authenticate(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
|
||||
const providedPassword = req.query.password as string;
|
||||
|
||||
if (!providedPassword || providedPassword !== password) {
|
||||
res.status(401).json({ error: 'Unauthorized: Invalid or missing password' });
|
||||
return;
|
||||
}
|
||||
|
||||
req.isAuthenticated = true;
|
||||
next();
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/', (req: Request, res: Response) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'tmux-mcp',
|
||||
transport: 'streamable-http'
|
||||
});
|
||||
});
|
||||
|
||||
// Streamable HTTP endpoint (modern MCP transport)
|
||||
app.post('/mcp', authenticate, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
// Create a new transport for each request to prevent request ID collisions
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true
|
||||
});
|
||||
|
||||
res.on('close', () => {
|
||||
transport.close();
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
console.error('Error handling MCP request:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32603,
|
||||
message: 'Internal server error'
|
||||
},
|
||||
id: null
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const httpServer = app.listen(port, () => {
|
||||
console.error(`MCP HTTP server listening on port ${port}`);
|
||||
console.error(`Streamable HTTP endpoint: http://localhost:${port}/mcp?password=****`);
|
||||
});
|
||||
|
||||
httpServer.on('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
console.error(`Error: Port ${port} is already in use`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.error(`Server error:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { parseArgs } from "node:util";
|
||||
import {
|
||||
McpServer,
|
||||
ResourceTemplate,
|
||||
} from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import * as tmux from "./tmux.js";
|
||||
import { startHttpServer } from "./http-server.js";
|
||||
|
||||
// Create MCP server
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: "tmux-mcp",
|
||||
version: "0.3.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
resources: {
|
||||
subscribe: true,
|
||||
listChanged: true,
|
||||
},
|
||||
tools: {
|
||||
listChanged: true,
|
||||
},
|
||||
logging: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// List tmux hierarchy - Tool
|
||||
server.tool(
|
||||
"list",
|
||||
"List tmux sessions, windows, and panes. Use scope='all' for full hierarchy, or drill down with specific scopes. Examples: scope='all' returns nested tree, scope='sessions' returns all sessions, scope='session' with target='$0' returns windows in that session, scope='window' with target='@1' returns panes in that window.",
|
||||
{
|
||||
scope: z
|
||||
.enum(["all", "sessions", "session", "window", "pane"])
|
||||
.describe(
|
||||
"Scope of listing: 'all' (full tree), 'sessions' (all sessions), 'session' (windows in a session), 'window' (panes in a window), 'pane' (details of a pane)"
|
||||
),
|
||||
target: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Target ID (required for session/window/pane scopes): session ID (e.g., '$0'), window ID (e.g., '@1'), or pane ID (e.g., '%2')"
|
||||
),
|
||||
},
|
||||
async ({ scope, target }) => {
|
||||
try {
|
||||
const result = await tmux.list({ scope, target });
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error listing: ${error}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Capture pane content - Tool
|
||||
server.tool(
|
||||
"capture-pane",
|
||||
"Capture content from a tmux pane with configurable lines count and optional color preservation. Optionally wait before capturing to allow commands to complete.",
|
||||
{
|
||||
paneId: z.string().describe("ID of the tmux pane"),
|
||||
lines: z.string().optional().describe("Number of lines to capture"),
|
||||
colors: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Include color/escape sequences for text and background attributes in output"
|
||||
),
|
||||
wait: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Milliseconds to wait before capturing output. Useful for long-running commands that need time to complete."
|
||||
),
|
||||
},
|
||||
async ({ paneId, lines, colors, wait }) => {
|
||||
try {
|
||||
// Wait if specified
|
||||
if (wait) {
|
||||
await new Promise((resolve) => setTimeout(resolve, wait));
|
||||
}
|
||||
|
||||
// Parse lines parameter if provided
|
||||
const linesCount = lines ? parseInt(lines, 10) : undefined;
|
||||
const includeColors = colors || false;
|
||||
const content = await tmux.capturePaneContent(
|
||||
paneId,
|
||||
linesCount,
|
||||
includeColors
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: content || "No content captured",
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error capturing pane content: ${error}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Create new session - Tool
|
||||
server.tool(
|
||||
"create-session",
|
||||
"Create a new tmux session",
|
||||
{
|
||||
name: z.string().describe("Name for the new tmux session"),
|
||||
},
|
||||
async ({ name }) => {
|
||||
try {
|
||||
const session = await tmux.createSession(name);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: session
|
||||
? `Session created: ${JSON.stringify(session, null, 2)}`
|
||||
: `Failed to create session: ${name}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error creating session: ${error}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Create new window - Tool
|
||||
server.tool(
|
||||
"create-window",
|
||||
"Create a new window in a tmux session. Returns the window ID and the default pane ID. Optionally execute a command in the new window immediately after creation - the command can use any bash operators (&&, ||, |, ;, etc.).",
|
||||
{
|
||||
sessionId: z.string().describe("ID of the tmux session"),
|
||||
name: z.string().describe("Name for the new window"),
|
||||
command: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional shell command to execute in the new window. Supports bash operators: && (chain), || (or), | (pipe), ; (sequential), etc. After sending the command, sleeps 1 second and returns the captured output."
|
||||
),
|
||||
},
|
||||
async ({ sessionId, name, command }) => {
|
||||
try {
|
||||
const window = await tmux.createWindow(sessionId, name, command);
|
||||
|
||||
if (!window) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to create window: ${name}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let text = `Window created: ${JSON.stringify(
|
||||
{
|
||||
id: window.id,
|
||||
name: window.name,
|
||||
active: window.active,
|
||||
sessionId: window.sessionId,
|
||||
paneId: window.paneId,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}`;
|
||||
|
||||
if (command && window.output) {
|
||||
text += `\n\nCommand executed: ${command}\n\n--- Output ---\n${window.output}`;
|
||||
} else if (command) {
|
||||
text += `\n\nCommand sent: ${command}`;
|
||||
} else {
|
||||
text += `\n\nYou can now execute commands directly in pane ${window.paneId}`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error creating window: ${error}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Rename window - Tool
|
||||
server.tool(
|
||||
"rename-window",
|
||||
"Rename a tmux window by its window ID (e.g., @380)",
|
||||
{
|
||||
windowId: z.string().describe("ID of the tmux window (e.g., '@380')"),
|
||||
name: z.string().describe("New name for the window"),
|
||||
},
|
||||
async ({ windowId, name }) => {
|
||||
try {
|
||||
await tmux.renameWindow(windowId, name);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Window ${windowId} renamed to "${name}"`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error renaming window: ${error}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Kill tmux resources - Tool
|
||||
server.tool(
|
||||
"kill",
|
||||
"Kill a tmux session, window, or pane. Examples: kill(scope='session', target='$0'), kill(scope='window', target='@1'), kill(scope='pane', target='%2'). No 'all' scope for safety.",
|
||||
{
|
||||
scope: z
|
||||
.enum(["session", "window", "pane"])
|
||||
.describe("Type of resource to kill: 'session', 'window', or 'pane'"),
|
||||
target: z
|
||||
.string()
|
||||
.describe(
|
||||
"Target ID to kill: session ID (e.g., '$0'), window ID (e.g., '@1'), or pane ID (e.g., '%2')"
|
||||
),
|
||||
},
|
||||
async ({ scope, target }) => {
|
||||
try {
|
||||
await tmux.kill({ scope, target });
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${
|
||||
scope.charAt(0).toUpperCase() + scope.slice(1)
|
||||
} ${target} has been killed`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error killing ${scope}: ${error}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Split pane - Tool
|
||||
server.tool(
|
||||
"split-pane",
|
||||
"Split a tmux pane horizontally or vertically",
|
||||
{
|
||||
paneId: z.string().describe("ID of the tmux pane to split"),
|
||||
direction: z
|
||||
.enum(["horizontal", "vertical"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Split direction: 'horizontal' (side by side) or 'vertical' (top/bottom). Default is 'vertical'"
|
||||
),
|
||||
size: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(99)
|
||||
.optional()
|
||||
.describe("Size of the new pane as percentage (1-99). Default is 50%"),
|
||||
},
|
||||
async ({ paneId, direction, size }) => {
|
||||
try {
|
||||
const newPane = await tmux.splitPane(
|
||||
paneId,
|
||||
direction || "vertical",
|
||||
size
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: newPane
|
||||
? `Pane split successfully. New pane: ${JSON.stringify(
|
||||
newPane,
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
: `Failed to split pane ${paneId}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error splitting pane: ${error}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Execute shell command - Tool
|
||||
server.tool(
|
||||
"execute-shell-command",
|
||||
"Execute a shell command in a tmux pane synchronously and return results immediately. Waits for command completion (default 30s timeout). Returns output, exit code, and status. Use for quick commands (ls, grep, npm test, etc.). Avoid heredoc syntax (cat << EOF) and multi-line constructs. IMPORTANT: For long-running commands (npm start, servers, watch processes), use send-text with pressEnter=true instead, then monitor output with capture-pane. For interactive apps or special keys, use send-keys.",
|
||||
{
|
||||
paneId: z.string().describe("ID of the tmux pane"),
|
||||
command: z.string().describe("Shell command to execute"),
|
||||
timeout: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Timeout in milliseconds (default: 30000)"),
|
||||
},
|
||||
async ({ paneId, command, timeout }) => {
|
||||
try {
|
||||
const result = await tmux.executeShellCommand({
|
||||
paneId,
|
||||
command,
|
||||
timeout,
|
||||
});
|
||||
|
||||
let statusText =
|
||||
result.status === "completed" ? "✅ Completed" : "❌ Error";
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${statusText}\nCommand: ${result.command}\nExit code: ${
|
||||
result.exitCode
|
||||
}\n\n--- Output ---\n${result.output || "(no output)"}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error executing command: ${error}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Send keys - Tool
|
||||
server.tool(
|
||||
"send-keys",
|
||||
"Send special keys or key combinations to a tmux pane. Use for TUI navigation and control sequences. Examples: 'Up', 'Down', 'Enter', 'Escape', 'C-c' (Ctrl+C), 'M-x' (Alt+X), 'C-b' (tmux prefix). For typing regular text, use send-text instead. Supports repeating key presses and optionally capturing output after sending keys.",
|
||||
{
|
||||
paneId: z.string().describe("ID of the tmux pane"),
|
||||
keys: z
|
||||
.string()
|
||||
.describe(
|
||||
"Special key name or key combination: 'Up', 'Down', 'Left', 'Right', 'Enter', 'Escape', 'Tab', 'Space', 'C-c', 'M-x', etc."
|
||||
),
|
||||
repeat: z
|
||||
.number()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Number of times to repeat the key press (default: 1)"),
|
||||
return_output: z
|
||||
.object({
|
||||
lines: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Number of lines to capture (default: 200)"),
|
||||
wait: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Milliseconds to wait before capturing output"),
|
||||
})
|
||||
.optional()
|
||||
.describe(
|
||||
"Capture pane output after sending keys. Specify 'wait' for slow commands."
|
||||
),
|
||||
},
|
||||
async ({ paneId, keys, repeat, return_output }) => {
|
||||
try {
|
||||
const output = await tmux.sendKeys({
|
||||
paneId,
|
||||
keys,
|
||||
repeat,
|
||||
return_output,
|
||||
});
|
||||
|
||||
if (return_output && output) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Keys '${keys}' sent to pane ${paneId}${
|
||||
repeat && repeat > 1 ? ` (repeated ${repeat} times)` : ""
|
||||
}.\n\n--- Output ---\n${output}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Keys '${keys}' sent to pane ${paneId}${
|
||||
repeat && repeat > 1 ? ` (repeated ${repeat} times)` : ""
|
||||
}.\n\nUse capture-pane to verify the result.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error sending keys: ${error}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Send text - Tool
|
||||
server.tool(
|
||||
"send-text",
|
||||
"Type text into a tmux pane. This is the PRIMARY way to execute shell commands with bash operators (&&, ||, |, ;, etc.) - set pressEnter=true to run the command. Also use for interactive applications, REPLs, forms, and text entry. For special keys or control sequences, use send-keys instead.",
|
||||
{
|
||||
paneId: z.string().describe("ID of the tmux pane"),
|
||||
text: z
|
||||
.string()
|
||||
.describe(
|
||||
"Text to type into the pane. For shell commands, can use any bash operators: && (chain), || (or), | (pipe), ; (sequential), etc."
|
||||
),
|
||||
pressEnter: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Press Enter after typing the text (default: false). Set to true to execute shell commands or submit text input."
|
||||
),
|
||||
return_output: z
|
||||
.object({
|
||||
lines: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Number of lines to capture (default: 200)"),
|
||||
wait: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Milliseconds to wait before capturing output"),
|
||||
})
|
||||
.optional()
|
||||
.describe(
|
||||
"Capture pane output after sending text. Specify 'wait' for slow commands."
|
||||
),
|
||||
},
|
||||
async ({ paneId, text, pressEnter, return_output }) => {
|
||||
try {
|
||||
const output = await tmux.sendText({
|
||||
paneId,
|
||||
text,
|
||||
pressEnter,
|
||||
return_output,
|
||||
});
|
||||
|
||||
if (return_output && output) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Text sent to pane ${paneId}${
|
||||
pressEnter ? " (with Enter)" : ""
|
||||
}.\n\n--- Output ---\n${output}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Text sent to pane ${paneId}${
|
||||
pressEnter ? " (with Enter)" : ""
|
||||
}.\n\nUse capture-pane to verify the result.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error sending text: ${error}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Expose tmux session list as a resource
|
||||
server.resource("Tmux Sessions", "tmux://sessions", async () => {
|
||||
try {
|
||||
const sessions = await tmux.listSessions();
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: "tmux://sessions",
|
||||
text: JSON.stringify(
|
||||
sessions.map((session) => ({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
attached: session.attached,
|
||||
windows: session.windows,
|
||||
})),
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: "tmux://sessions",
|
||||
text: `Error listing tmux sessions: ${error}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Expose pane content as a resource
|
||||
server.resource(
|
||||
"Tmux Pane Content",
|
||||
new ResourceTemplate("tmux://pane/{paneId}", {
|
||||
list: async () => {
|
||||
try {
|
||||
// Get all sessions
|
||||
const sessions = await tmux.listSessions();
|
||||
const paneResources = [];
|
||||
|
||||
// For each session, get all windows
|
||||
for (const session of sessions) {
|
||||
const windows = await tmux.listWindows(session.id);
|
||||
|
||||
// For each window, get all panes
|
||||
for (const window of windows) {
|
||||
const panes = await tmux.listPanes(window.id);
|
||||
|
||||
// For each pane, create a resource with descriptive name
|
||||
for (const pane of panes) {
|
||||
paneResources.push({
|
||||
name: `Pane: ${session.name} - ${pane.id} - ${pane.title} ${
|
||||
pane.active ? "(active)" : ""
|
||||
}`,
|
||||
uri: `tmux://pane/${pane.id}`,
|
||||
description: `Content from pane ${pane.id} - ${pane.title} in session ${session.name}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
resources: paneResources,
|
||||
};
|
||||
} catch (error) {
|
||||
server.server.sendLoggingMessage({
|
||||
level: "error",
|
||||
data: `Error listing panes: ${error}`,
|
||||
});
|
||||
|
||||
return { resources: [] };
|
||||
}
|
||||
},
|
||||
}),
|
||||
async (uri, { paneId }) => {
|
||||
try {
|
||||
// Ensure paneId is a string
|
||||
const paneIdStr = Array.isArray(paneId) ? paneId[0] : paneId;
|
||||
// Default to no colors for resources to maintain clean programmatic access
|
||||
const content = await tmux.capturePaneContent(paneIdStr, 200, false);
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: uri.href,
|
||||
text: content || "No content captured",
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: uri.href,
|
||||
text: `Error capturing pane content: ${error}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
"shell-type": { type: "string", default: "bash", short: "s" },
|
||||
http: { type: "boolean", default: false },
|
||||
port: { type: "string" },
|
||||
password: { type: "string" },
|
||||
},
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
// Set shell configuration
|
||||
tmux.setShellConfig({
|
||||
type: values["shell-type"] as string,
|
||||
});
|
||||
|
||||
console.log(values, process.argv);
|
||||
|
||||
// Check if HTTP mode is enabled
|
||||
if (values.http) {
|
||||
if (!values.password) {
|
||||
console.error("Error: --password is required when using --http mode");
|
||||
console.error(
|
||||
"\nUsage: tmux-mcp --http --password your-secret-password"
|
||||
);
|
||||
console.error(
|
||||
"Set PORT environment variable to change port (default: 3000)"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const port = parseInt(values.port || process.env.PORT || "3000", 10);
|
||||
|
||||
// Start HTTP server
|
||||
startHttpServer({
|
||||
port,
|
||||
password: values.password,
|
||||
server,
|
||||
});
|
||||
} else {
|
||||
// Start stdio server (default)
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to start MCP server:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,705 @@
|
||||
import { exec as execCallback } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const exec = promisify(execCallback);
|
||||
|
||||
// Basic interfaces for tmux objects
|
||||
export interface TmuxSession {
|
||||
id: string;
|
||||
name: string;
|
||||
attached: boolean;
|
||||
windows: number;
|
||||
}
|
||||
|
||||
export interface TmuxWindow {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface TmuxPane {
|
||||
id: string;
|
||||
windowId: string;
|
||||
active: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface CommandExecution {
|
||||
id: string;
|
||||
paneId: string;
|
||||
command: string;
|
||||
status: "pending" | "completed" | "error";
|
||||
startTime: Date;
|
||||
result?: string;
|
||||
exitCode?: number;
|
||||
rawMode?: boolean;
|
||||
}
|
||||
|
||||
export type ShellType = "bash" | "zsh" | "fish";
|
||||
|
||||
let shellConfig: { type: ShellType } = { type: "bash" };
|
||||
|
||||
export function setShellConfig(config: { type: string }): void {
|
||||
// Validate shell type
|
||||
const validShells: ShellType[] = ["bash", "zsh", "fish"];
|
||||
|
||||
if (validShells.includes(config.type as ShellType)) {
|
||||
shellConfig = { type: config.type as ShellType };
|
||||
} else {
|
||||
shellConfig = { type: "bash" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tmux command and return the result
|
||||
*/
|
||||
export async function executeTmux(tmuxCommand: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await exec(`tmux ${tmuxCommand}`);
|
||||
return stdout.trim();
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to execute tmux command: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tmux server is running
|
||||
*/
|
||||
export async function isTmuxRunning(): Promise<boolean> {
|
||||
try {
|
||||
await executeTmux("list-sessions -F '#{session_name}'");
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tmux sessions
|
||||
*/
|
||||
export async function listSessions(): Promise<TmuxSession[]> {
|
||||
const format =
|
||||
"#{session_id}:#{session_name}:#{?session_attached,1,0}:#{session_windows}";
|
||||
const output = await executeTmux(`list-sessions -F '${format}'`);
|
||||
|
||||
if (!output) return [];
|
||||
|
||||
return output.split("\n").map((line) => {
|
||||
const [id, name, attached, windows] = line.split(":");
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
attached: attached === "1",
|
||||
windows: parseInt(windows, 10),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a session by name
|
||||
*/
|
||||
export async function findSessionByName(
|
||||
name: string
|
||||
): Promise<TmuxSession | null> {
|
||||
try {
|
||||
const sessions = await listSessions();
|
||||
return sessions.find((session) => session.name === name) || null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List windows in a session
|
||||
*/
|
||||
export async function listWindows(sessionId: string): Promise<TmuxWindow[]> {
|
||||
const format = "#{window_id}:#{window_name}:#{?window_active,1,0}";
|
||||
const output = await executeTmux(
|
||||
`list-windows -t '${sessionId}' -F '${format}'`
|
||||
);
|
||||
|
||||
if (!output) return [];
|
||||
|
||||
return output.split("\n").map((line) => {
|
||||
const [id, name, active] = line.split(":");
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
active: active === "1",
|
||||
sessionId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List panes in a window
|
||||
*/
|
||||
export async function listPanes(windowId: string): Promise<TmuxPane[]> {
|
||||
const format = "#{pane_id}:#{pane_title}:#{?pane_active,1,0}";
|
||||
const output = await executeTmux(
|
||||
`list-panes -t '${windowId}' -F '${format}'`
|
||||
);
|
||||
|
||||
if (!output) return [];
|
||||
|
||||
return output.split("\n").map((line) => {
|
||||
const [id, title, active] = line.split(":");
|
||||
return {
|
||||
id,
|
||||
windowId,
|
||||
title: title,
|
||||
active: active === "1",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture content from a specific pane, by default the latest 200 lines.
|
||||
*/
|
||||
export async function capturePaneContent(
|
||||
paneId: string,
|
||||
lines: number = 200,
|
||||
includeColors: boolean = false
|
||||
): Promise<string> {
|
||||
const colorFlag = includeColors ? "-e" : "";
|
||||
return executeTmux(
|
||||
`capture-pane -p ${colorFlag} -t '${paneId}' -S -${lines} -E -`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tmux session
|
||||
*/
|
||||
export async function createSession(name: string): Promise<TmuxSession | null> {
|
||||
await executeTmux(`new-session -d -s "${name}"`);
|
||||
return findSessionByName(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new window in a session
|
||||
*/
|
||||
export async function createWindow(
|
||||
sessionId: string,
|
||||
name: string,
|
||||
command?: string
|
||||
): Promise<(TmuxWindow & { paneId: string; output?: string }) | null> {
|
||||
const output = await executeTmux(`new-window -t '${sessionId}' -n '${name}'`);
|
||||
const windows = await listWindows(sessionId);
|
||||
const window = windows.find((window) => window.name === name);
|
||||
|
||||
if (!window) return null;
|
||||
|
||||
// Get the default pane created with the window
|
||||
const panes = await listPanes(window.id);
|
||||
const defaultPane = panes[0];
|
||||
|
||||
let commandOutput: string | undefined;
|
||||
|
||||
// If command is provided, execute it in the new pane
|
||||
if (command && defaultPane) {
|
||||
await sendText({
|
||||
paneId: defaultPane.id,
|
||||
text: command,
|
||||
pressEnter: true,
|
||||
});
|
||||
|
||||
// Sleep for 1 second to allow command to execute
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Capture the pane content
|
||||
commandOutput = await capturePaneContent(defaultPane.id, 200, false);
|
||||
}
|
||||
|
||||
return {
|
||||
...window,
|
||||
paneId: defaultPane?.id || "",
|
||||
output: commandOutput,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a tmux session by ID
|
||||
*/
|
||||
export async function killSession(sessionId: string): Promise<void> {
|
||||
await executeTmux(`kill-session -t '${sessionId}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a tmux window by ID
|
||||
*/
|
||||
export async function killWindow(windowId: string): Promise<void> {
|
||||
await executeTmux(`kill-window -t '${windowId}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a tmux pane by ID
|
||||
*/
|
||||
export async function killPane(paneId: string): Promise<void> {
|
||||
await executeTmux(`kill-pane -t '${paneId}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a tmux window by ID
|
||||
*/
|
||||
export async function renameWindow(
|
||||
windowId: string,
|
||||
name: string
|
||||
): Promise<void> {
|
||||
await executeTmux(`rename-window -t '${windowId}' '${name}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a tmux pane horizontally or vertically
|
||||
*/
|
||||
export async function splitPane(
|
||||
targetPaneId: string,
|
||||
direction: "horizontal" | "vertical" = "vertical",
|
||||
size?: number
|
||||
): Promise<TmuxPane | null> {
|
||||
// Build the split-window command
|
||||
let splitCommand = "split-window";
|
||||
|
||||
// Add direction flag (-h for horizontal, -v for vertical)
|
||||
if (direction === "horizontal") {
|
||||
splitCommand += " -h";
|
||||
} else {
|
||||
splitCommand += " -v";
|
||||
}
|
||||
|
||||
// Add target pane
|
||||
splitCommand += ` -t '${targetPaneId}'`;
|
||||
|
||||
// Add size if specified (as percentage)
|
||||
if (size !== undefined && size > 0 && size < 100) {
|
||||
splitCommand += ` -p ${size}`;
|
||||
}
|
||||
|
||||
// Execute the split command
|
||||
await executeTmux(splitCommand);
|
||||
|
||||
// Get the window ID from the target pane to list all panes
|
||||
const windowInfo = await executeTmux(
|
||||
`display-message -p -t '${targetPaneId}' '#{window_id}'`
|
||||
);
|
||||
|
||||
// List all panes in the window to find the newly created one
|
||||
const panes = await listPanes(windowInfo);
|
||||
|
||||
// The newest pane is typically the last one in the list
|
||||
return panes.length > 0 ? panes[panes.length - 1] : null;
|
||||
}
|
||||
|
||||
// Map to track ongoing command executions
|
||||
const activeCommands = new Map<string, CommandExecution>();
|
||||
|
||||
const startMarkerText = "TMUX_MCP_START";
|
||||
const endMarkerPrefix = "TMUX_MCP_DONE_";
|
||||
|
||||
// Execute a command in a tmux pane and track its execution
|
||||
export async function executeCommand(
|
||||
paneId: string,
|
||||
command: string,
|
||||
rawMode?: boolean,
|
||||
noEnter?: boolean
|
||||
): Promise<string> {
|
||||
// Generate unique ID for this command execution
|
||||
const commandId = uuidv4();
|
||||
|
||||
let fullCommand: string;
|
||||
if (rawMode || noEnter) {
|
||||
fullCommand = command;
|
||||
} else {
|
||||
const endMarkerText = getEndMarkerText();
|
||||
fullCommand = `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`;
|
||||
}
|
||||
|
||||
// Store command in tracking map
|
||||
activeCommands.set(commandId, {
|
||||
id: commandId,
|
||||
paneId,
|
||||
command,
|
||||
status: "pending",
|
||||
startTime: new Date(),
|
||||
rawMode: rawMode || noEnter,
|
||||
});
|
||||
|
||||
// Send the command to the tmux pane
|
||||
if (noEnter) {
|
||||
// Check if this is a special key or key combination
|
||||
// Special keys in tmux are typically capitalized or have special names
|
||||
const specialKeys = [
|
||||
"Up",
|
||||
"Down",
|
||||
"Left",
|
||||
"Right",
|
||||
"Escape",
|
||||
"Tab",
|
||||
"Enter",
|
||||
"Space",
|
||||
"BSpace",
|
||||
"Delete",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"F1",
|
||||
"F2",
|
||||
"F3",
|
||||
"F4",
|
||||
"F5",
|
||||
"F6",
|
||||
"F7",
|
||||
"F8",
|
||||
"F9",
|
||||
"F10",
|
||||
"F11",
|
||||
"F12",
|
||||
"BTab",
|
||||
];
|
||||
|
||||
// Split the command into parts to handle combinations like "C-b" or "M-x"
|
||||
const parts = fullCommand.split("-");
|
||||
const isSpecialKey = parts.length === 1 && specialKeys.includes(parts[0]);
|
||||
const isKeyCombo =
|
||||
parts.length > 1 &&
|
||||
(parts[0] === "C" || // Control
|
||||
parts[0] === "M" || // Meta/Alt
|
||||
parts[0] === "S"); // Shift
|
||||
|
||||
if (isSpecialKey || isKeyCombo) {
|
||||
// Send special key or key combination as-is
|
||||
await executeTmux(`send-keys -t '${paneId}' ${fullCommand}`);
|
||||
} else {
|
||||
// For regular text, send each character individually to ensure proper processing
|
||||
// This handles both single characters (like 'q', 'f') and strings (like 'beam')
|
||||
for (const char of fullCommand) {
|
||||
await executeTmux(
|
||||
`send-keys -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await executeTmux(
|
||||
`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`
|
||||
);
|
||||
}
|
||||
|
||||
return commandId;
|
||||
}
|
||||
|
||||
export async function checkCommandStatus(
|
||||
commandId: string
|
||||
): Promise<CommandExecution | null> {
|
||||
const command = activeCommands.get(commandId);
|
||||
if (!command) return null;
|
||||
|
||||
if (command.status !== "pending") return command;
|
||||
|
||||
const content = await capturePaneContent(command.paneId, 1000);
|
||||
|
||||
if (command.rawMode) {
|
||||
command.result =
|
||||
"Status tracking unavailable for rawMode commands. Use capture-pane to monitor interactive apps instead.";
|
||||
return command;
|
||||
}
|
||||
|
||||
// Find the last occurrence of the markers
|
||||
const startIndex = content.lastIndexOf(startMarkerText);
|
||||
const endIndex = content.lastIndexOf(endMarkerPrefix);
|
||||
|
||||
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
|
||||
command.result = "Command output could not be captured properly";
|
||||
return command;
|
||||
}
|
||||
|
||||
// Extract exit code from the end marker line
|
||||
const endLine = content.substring(endIndex).split("\n")[0];
|
||||
const endMarkerRegex = new RegExp(`${endMarkerPrefix}(\\d+)`);
|
||||
const exitCodeMatch = endLine.match(endMarkerRegex);
|
||||
|
||||
if (exitCodeMatch) {
|
||||
const exitCode = parseInt(exitCodeMatch[1], 10);
|
||||
|
||||
command.status = exitCode === 0 ? "completed" : "error";
|
||||
command.exitCode = exitCode;
|
||||
|
||||
// Extract output between the start and end markers
|
||||
const outputStart = startIndex + startMarkerText.length;
|
||||
const outputContent = content.substring(outputStart, endIndex).trim();
|
||||
|
||||
command.result = outputContent
|
||||
.substring(outputContent.indexOf("\n") + 1)
|
||||
.trim();
|
||||
|
||||
// Update in map
|
||||
activeCommands.set(commandId, command);
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
// Get command by ID
|
||||
export function getCommand(commandId: string): CommandExecution | null {
|
||||
return activeCommands.get(commandId) || null;
|
||||
}
|
||||
|
||||
// Get all active command IDs
|
||||
export function getActiveCommandIds(): string[] {
|
||||
return Array.from(activeCommands.keys());
|
||||
}
|
||||
|
||||
// Clean up completed commands older than a certain time
|
||||
export function cleanupOldCommands(maxAgeMinutes: number = 60): void {
|
||||
const now = new Date();
|
||||
|
||||
for (const [id, command] of activeCommands.entries()) {
|
||||
const ageMinutes =
|
||||
(now.getTime() - command.startTime.getTime()) / (1000 * 60);
|
||||
|
||||
if (command.status !== "pending" && ageMinutes > maxAgeMinutes) {
|
||||
activeCommands.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getEndMarkerText(): string {
|
||||
return shellConfig.type === "fish"
|
||||
? `${endMarkerPrefix}$status`
|
||||
: `${endMarkerPrefix}$?`;
|
||||
}
|
||||
|
||||
// New consolidated API functions
|
||||
|
||||
export type ListScope = "all" | "sessions" | "session" | "window" | "pane";
|
||||
|
||||
interface SessionWithWindows extends TmuxSession {
|
||||
windowDetails?: WindowWithPanes[];
|
||||
}
|
||||
|
||||
interface WindowWithPanes extends TmuxWindow {
|
||||
paneDetails?: TmuxPane[];
|
||||
}
|
||||
|
||||
export async function list({
|
||||
scope,
|
||||
target,
|
||||
}: {
|
||||
scope: ListScope;
|
||||
target?: string;
|
||||
}): Promise<
|
||||
| SessionWithWindows[]
|
||||
| TmuxSession[]
|
||||
| TmuxWindow[]
|
||||
| TmuxPane[]
|
||||
| TmuxSession
|
||||
| TmuxWindow
|
||||
| TmuxPane
|
||||
> {
|
||||
if (scope === "all") {
|
||||
const sessions = await listSessions();
|
||||
const sessionsWithDetails: SessionWithWindows[] = [];
|
||||
|
||||
for (const session of sessions) {
|
||||
const windows = await listWindows(session.id);
|
||||
const windowsWithPanes: WindowWithPanes[] = [];
|
||||
|
||||
for (const window of windows) {
|
||||
const panes = await listPanes(window.id);
|
||||
windowsWithPanes.push({
|
||||
...window,
|
||||
paneDetails: panes,
|
||||
});
|
||||
}
|
||||
|
||||
sessionsWithDetails.push({
|
||||
...session,
|
||||
windowDetails: windowsWithPanes,
|
||||
});
|
||||
}
|
||||
|
||||
return sessionsWithDetails;
|
||||
}
|
||||
|
||||
if (scope === "sessions") {
|
||||
return listSessions();
|
||||
}
|
||||
|
||||
if (scope === "session") {
|
||||
if (!target) {
|
||||
throw new Error("target is required for scope 'session'");
|
||||
}
|
||||
return listWindows(target);
|
||||
}
|
||||
|
||||
if (scope === "window") {
|
||||
if (!target) {
|
||||
throw new Error("target is required for scope 'window'");
|
||||
}
|
||||
return listPanes(target);
|
||||
}
|
||||
|
||||
if (scope === "pane") {
|
||||
if (!target) {
|
||||
throw new Error("target is required for scope 'pane'");
|
||||
}
|
||||
const windowId = await executeTmux(
|
||||
`display-message -p -t '${target}' '#{window_id}'`
|
||||
);
|
||||
const panes = await listPanes(windowId);
|
||||
const pane = panes.find((p) => p.id === target);
|
||||
if (!pane) {
|
||||
throw new Error(`Pane not found: ${target}`);
|
||||
}
|
||||
return pane;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid scope: ${scope}`);
|
||||
}
|
||||
|
||||
export type KillScope = "session" | "window" | "pane";
|
||||
|
||||
export async function kill({
|
||||
scope,
|
||||
target,
|
||||
}: {
|
||||
scope: KillScope;
|
||||
target: string;
|
||||
}): Promise<void> {
|
||||
if (scope === "session") {
|
||||
return killSession(target);
|
||||
}
|
||||
|
||||
if (scope === "window") {
|
||||
return killWindow(target);
|
||||
}
|
||||
|
||||
if (scope === "pane") {
|
||||
return killPane(target);
|
||||
}
|
||||
|
||||
throw new Error(`Invalid scope: ${scope}`);
|
||||
}
|
||||
|
||||
export interface ShellCommandResult {
|
||||
command: string;
|
||||
status: "completed" | "error";
|
||||
exitCode: number;
|
||||
output: string;
|
||||
}
|
||||
|
||||
export async function executeShellCommand({
|
||||
paneId,
|
||||
command,
|
||||
timeout = 30000,
|
||||
}: {
|
||||
paneId: string;
|
||||
command: string;
|
||||
timeout?: number;
|
||||
}): Promise<ShellCommandResult> {
|
||||
const commandId = uuidv4();
|
||||
const endMarkerText = getEndMarkerText();
|
||||
const fullCommand = `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`;
|
||||
|
||||
activeCommands.set(commandId, {
|
||||
id: commandId,
|
||||
paneId,
|
||||
command,
|
||||
status: "pending",
|
||||
startTime: new Date(),
|
||||
rawMode: false,
|
||||
});
|
||||
|
||||
await executeTmux(
|
||||
`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`
|
||||
);
|
||||
|
||||
// Poll for completion
|
||||
const startTime = Date.now();
|
||||
const pollInterval = 100;
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
|
||||
const result = await checkCommandStatus(commandId);
|
||||
|
||||
if (result && result.status !== "pending") {
|
||||
// Cleanup
|
||||
activeCommands.delete(commandId);
|
||||
|
||||
return {
|
||||
command: result.command,
|
||||
status: result.status,
|
||||
exitCode: result.exitCode!,
|
||||
output: result.result || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout
|
||||
activeCommands.delete(commandId);
|
||||
throw new Error(
|
||||
`Command timed out after ${timeout}ms. Use capture-pane to check pane state.`
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendKeys({
|
||||
paneId,
|
||||
keys,
|
||||
repeat = 1,
|
||||
return_output,
|
||||
}: {
|
||||
paneId: string;
|
||||
keys: string;
|
||||
repeat?: number;
|
||||
return_output?: { lines?: number; wait?: number };
|
||||
}): Promise<string | void> {
|
||||
// Repeat the key press the specified number of times
|
||||
for (let i = 0; i < repeat; i++) {
|
||||
// Raw pass-through, no validation or processing
|
||||
await executeTmux(`send-keys -t '${paneId}' ${keys}`);
|
||||
}
|
||||
|
||||
// If return_output is requested, wait and capture pane content
|
||||
if (return_output) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, return_output.wait ?? 500)
|
||||
);
|
||||
const lines = return_output.lines || 200;
|
||||
return capturePaneContent(paneId, lines, false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendText({
|
||||
paneId,
|
||||
text,
|
||||
pressEnter = false,
|
||||
return_output,
|
||||
}: {
|
||||
paneId: string;
|
||||
text: string;
|
||||
pressEnter?: boolean;
|
||||
return_output?: { lines?: number; wait?: number };
|
||||
}): Promise<string | void> {
|
||||
// Send each character with -l flag for literal interpretation
|
||||
for (const char of text) {
|
||||
await executeTmux(
|
||||
`send-keys -l -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`
|
||||
);
|
||||
}
|
||||
|
||||
if (pressEnter) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
await executeTmux(`send-keys -t '${paneId}' Enter`);
|
||||
}
|
||||
|
||||
// If return_output is requested, wait and capture pane content
|
||||
if (return_output) {
|
||||
if (return_output.wait) {
|
||||
await new Promise((resolve) => setTimeout(resolve, return_output.wait));
|
||||
}
|
||||
const lines = return_output.lines || 200;
|
||||
return capturePaneContent(paneId, lines, false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user