mcpretentious
Version:
MCPretentious - Universal Terminal MCP. High-performance terminal automation for iTerm2 (WebSocket) and tmux (control mode). Cross-platform support with cursor position, colors, and layered screenshots.
308 lines (278 loc) • 8.82 kB
JavaScript
/**
* Simplified TMux Client using direct commands instead of control mode
* This implementation uses execSync for simpler, more reliable operations
*/
import { execSync } from 'child_process';
import { randomBytes } from 'crypto';
// Mouse protocol support is handled directly by backends
export class TmuxClientSimple {
constructor() {
this.sessionPrefix = 'mcp';
}
/**
* Execute a tmux command
* @param {Array<string>} args - Command arguments
* @returns {string} Command output
*/
executeCommand(args) {
try {
// Build command string, properly quoting arguments that contain special characters
const quotedArgs = args.map(arg => {
// Convert to string if not already
const strArg = String(arg);
// Always quote arguments that contain tmux format strings or special chars
// Include brackets and angle brackets in the pattern for escape sequences
if (strArg.includes('#') || /[\s"'$`\\{}[\]<>]/.test(strArg)) {
return `'${strArg.replace(/'/g, "'\\''")}'`;
}
return strArg;
});
return execSync(`tmux ${quotedArgs.join(' ')}`, { encoding: 'utf8' });
} catch (error) {
// If tmux returns non-zero with no sessions, return empty
if (error.status === 1 && error.message.includes('no server running')) {
return '';
}
throw new Error(`tmux command failed: ${error.message}`);
}
}
/**
* Create a new tmux session
* @param {number} columns - Width in columns
* @param {number} rows - Height in rows
* @returns {Promise<string>} Session ID
*/
async createSession(columns = 80, rows = 24) {
const sessionId = `${this.sessionPrefix}-${randomBytes(4).toString('hex')}`;
try {
// Create detached session with specified size
this.executeCommand([
'new-session',
'-d',
'-s', sessionId,
'-x', columns,
'-y', rows
]);
return sessionId;
} catch (error) {
throw new Error(`Failed to create tmux session: ${error.message}`);
}
}
/**
* Close a tmux session
* @param {string} sessionId - Session to close
* @returns {Promise<boolean>} Success status
*/
async closeSession(sessionId) {
try {
this.executeCommand(['kill-session', '-t', sessionId]);
return true;
} catch (error) {
// Session might already be gone
return false;
}
}
/**
* List tmux sessions
* @returns {Promise<Array>} List of sessions
*/
async listSessions() {
try {
const output = this.executeCommand([
'list-sessions',
'-F',
'#{session_name}:#{session_attached}:#{session_windows}'
]);
return output.trim().split('\n').filter(Boolean).map(line => {
const [name, attached, windows] = line.split(':');
return {
uniqueIdentifier: name,
attached: attached === '1',
windowCount: parseInt(windows) || 1
};
});
} catch (error) {
// No sessions
return [];
}
}
/**
* Send text/keys to a tmux session
* @param {string} sessionId - Target session
* @param {string} text - Text to send
* @returns {Promise<boolean>} Success status
*/
async sendText(sessionId, text) {
try {
// Use -l flag to send literal text without shell interpretation
this.executeCommand([
'send-keys',
'-t', sessionId,
'-l',
text
]);
return true;
} catch (error) {
throw new Error(`Failed to send text: ${error.message}`);
}
}
/**
* Send raw keys to a tmux session
* @param {string} sessionId - Target session
* @param {string} key - Key name (e.g., 'Enter', 'C-c')
* @returns {Promise<boolean>} Success status
*/
async sendKeys(sessionId, key) {
try {
this.executeCommand([
'send-keys',
'-t', sessionId,
key
]);
return true;
} catch (error) {
throw new Error(`Failed to send key: ${error.message}`);
}
}
/**
* Get screen contents from a tmux session
* @param {string} sessionId - Target session
* @param {boolean} includeStyles - Include ANSI escape sequences
* @returns {Promise<Object>} Screen data
*/
async getScreenContents(sessionId, includeStyles = false) {
try {
// Get cursor position
const cursorOutput = this.executeCommand([
'display-message',
'-t', sessionId,
'-p', '"#{cursor_x},#{cursor_y}"'
]);
const [cursorX, cursorY] = cursorOutput.trim().split(',').map(Number);
// Capture pane content
const captureArgs = ['capture-pane', '-t', sessionId, '-p'];
if (includeStyles) {
captureArgs.push('-e'); // Preserve escape sequences
}
const content = this.executeCommand(captureArgs);
const lines = content.split('\n');
if (includeStyles) {
// Parse ANSI codes to extract styles
const { parseAnsiScreen } = await import('./ansi-parser.js');
return parseAnsiScreen(lines, { x: cursorX, y: cursorY });
} else {
return {
text: content,
cursor: { x: cursorX, y: cursorY },
lines: lines.map(text => ({ text }))
};
}
} catch (error) {
throw new Error(`Failed to get screen contents: ${error.message}`);
}
}
/**
* Get session information
* @param {string} sessionId - Target session
* @returns {Promise<Object>} Session info
*/
async getSessionInfo(sessionId) {
try {
const output = this.executeCommand([
'display-message',
'-t', sessionId,
'-p', '"#{session_name}:#{pane_width}:#{pane_height}:#{window_id}:#{pane_id}"'
]);
const [name, width, height, windowId, paneId] = output.trim().split(':');
return {
sessionId: name || sessionId,
windowId: windowId || '0',
paneId: paneId || '0',
dimensions: {
columns: parseInt(width) || 80,
rows: parseInt(height) || 24
}
};
} catch (error) {
throw new Error(`Failed to get session info: ${error.message}`);
}
}
/**
* Resize a tmux pane
* @param {string} sessionId - Target session
* @param {number} columns - New width
* @param {number} rows - New height
* @returns {Promise<boolean>} Success status
*/
async resizePane(sessionId, columns, rows) {
try {
// Note: tmux doesn't directly support resizing to exact dimensions
// We need to use refresh-client with -C flag
this.executeCommand([
'refresh-client',
'-t', sessionId,
'-C', `${columns},${rows}`
]);
return true;
} catch (error) {
throw new Error(`Failed to resize pane: ${error.message}`);
}
}
/**
* Alias for resizePane to match the interface expected by tmux-backend.js
* @param {string} sessionId - Target session
* @param {number} columns - New width
* @param {number} rows - New height
* @returns {Promise<boolean>} Success status
*/
async setSessionSize(sessionId, columns, rows) {
return await this.resizePane(sessionId, columns, rows);
}
/**
* Get a specific tmux property
* @param {string} sessionId - Target session
* @param {string} property - Property name
* @returns {Promise<any>} Property value
*/
async getTmuxProperty(sessionId, property) {
try {
const formatMap = {
'grid_size': '#{pane_width}:#{pane_height}',
'cursor_position': '#{cursor_x}:#{cursor_y}',
'title': '#{pane_title}'
};
const format = formatMap[property];
if (!format) {
throw new Error(`Unknown property: ${property}`);
}
const output = this.executeCommand([
'display-message',
'-t', sessionId,
'-p', `"${format}"`
]);
const result = output.trim();
// Parse based on property type
if (property === 'grid_size') {
const [width, height] = result.split(':').map(Number);
// Return actual dimensions (no adjustment needed)
return { width, height };
} else if (property === 'cursor_position') {
const [x, y] = result.split(':').map(Number);
return { x, y };
}
return result;
} catch (error) {
throw new Error(`Failed to get property ${property}: ${error.message}`);
}
}
/**
* Close/cleanup method for compatibility
* Since this is a simple client using execSync, there's nothing to clean up
*/
close() {
// No persistent connections to close
// This method exists for interface compatibility
}
}
// Export singleton instance
export const tmuxClient = new TmuxClientSimple();