UNPKG

@steipete/terminator-mcp

Version:

MCP plugin to manage macOS terminal sessions.

151 lines 8.94 kB
import { CURRENT_TERMINAL_APP, DEFAULT_BACKGROUND_STARTUP_SECONDS, DEFAULT_FOREGROUND_COMPLETION_SECONDS, DEFAULT_LINES, DEFAULT_FOCUS_ON_ACTION, DEFAULT_BACKGROUND_EXECUTION, getCanonicalOptions, debugLog } from './config.js'; import { invokeSwiftCLI } from './swift-cli.js'; import { resolveEffectiveProjectPath, resolveDefaultTag, formatCliOutputForAI } from './utils.js'; export const terminatorTool /*: McpTool<TerminatorExecuteParams, TerminatorResult>*/ = { name: 'execute', description: `Manages macOS terminal sessions using the ${CURRENT_TERMINAL_APP} application. Ideal for running commands that might be long-running or could hang, as it isolates them to protect your workflow and allows for faster interaction. The session screen is automatically cleared before executing a new command or after a process is killed. Use this to execute shell commands, retrieve output, and manage terminal processes.`, inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['exec', 'read', 'list', 'info', 'focus', 'kill'], description: "Optional. The operation to perform: 'execute', 'read', 'list', 'info', 'focus', or 'kill'. Defaults to 'execute'." }, project_path: { type: 'string', description: "Absolute path to the project directory. This is a mandatory field." }, tag: { type: 'string', description: "Optional. A unique identifier for the session (e.g., \"ui-build\", \"api-server\"). If omitted, a tag will be derived from the project_path." }, command: { type: 'string', description: "Optional, primarily for action: 'execute'. The shell command to execute. If action is 'execute' and command is empty or omitted, the session will be prepared (cleared, focused if applicable), but no new command is run." }, background: { type: 'boolean', default: DEFAULT_BACKGROUND_EXECUTION, description: `If true, command is long-running (default: ${DEFAULT_BACKGROUND_EXECUTION}).` }, lines: { type: 'number', default: DEFAULT_LINES, description: `Max recent output lines (default: ${DEFAULT_LINES}).` }, timeout: { type: 'number', description: `Timeout in seconds. Defaults depend on background flag (FG: ${DEFAULT_FOREGROUND_COMPLETION_SECONDS}s, BG: ${DEFAULT_BACKGROUND_STARTUP_SECONDS}s).` }, focus: { type: 'boolean', default: DEFAULT_FOCUS_ON_ACTION, description: `Bring terminal to front (default: ${DEFAULT_FOCUS_ON_ACTION}).` }, }, required: ['action', 'project_path'], additionalProperties: true, }, outputSchema: { type: 'object', properties: { success: { type: 'boolean' }, message: { type: 'string' }, }, required: ['success', 'message'], }, async handler(params, context) { debugLog(`Received raw params:`, params); const action = params.action; if (!['exec', 'read', 'list', 'info', 'focus', 'kill'].includes(action)) { return { success: false, message: `Error: Invalid action '${action}'. Must be one of exec, read, list, info, focus, kill.` }; } const options = getCanonicalOptions(params); debugLog(`Canonical options after processing:`, options); const effectiveProjectPath = resolveEffectiveProjectPath(params.project_path, undefined /* TODO: pass requestContext if available */); if (!effectiveProjectPath) { return { success: false, message: `Error: project_path '${params.project_path}' could not be resolved or is invalid.` }; } let commandOpt = typeof options.command === 'string' ? options.command : undefined; if (action === 'exec' && options.command === undefined) commandOpt = ''; let lines = typeof options.lines === 'number' ? options.lines : DEFAULT_LINES; if (typeof options.lines === 'string') lines = parseInt(options.lines, 10) || DEFAULT_LINES; let backgroundVal = options.background; let background = DEFAULT_BACKGROUND_EXECUTION; // Default value if (typeof backgroundVal === 'boolean') { background = backgroundVal; } else if (typeof backgroundVal === 'string') { background = ['true', '1', 't', 'yes', 'on'].includes(backgroundVal.toLowerCase()); } let focusVal = options.focus; let focus = DEFAULT_FOCUS_ON_ACTION; // Default value if (typeof focusVal === 'boolean') { focus = focusVal; } else if (typeof focusVal === 'string') { focus = ['true', '1', 't', 'yes', 'on'].includes(focusVal.toLowerCase()); } let timeoutOverride = typeof options.timeout === 'number' ? options.timeout : undefined; if (typeof options.timeout === 'string') timeoutOverride = parseInt(options.timeout, 10) || undefined; let tag = resolveDefaultTag(options.tag, effectiveProjectPath); if (!tag && ['exec', 'read', 'kill', 'focus'].includes(action) && action !== 'list' && action !== 'info') { const errorMsg = 'Error: Could not determine a session tag even with a project_path. This indicates an internal issue.'; console.error(errorMsg, { tagVal: options.tag, projPath: effectiveProjectPath }); return { success: false, message: errorMsg }; } const cliArgs = [action]; if (tag) { if (action === 'list' && options.tag) { /* Will be added as --tag option later */ } else if (action !== 'list' && action !== 'info') { cliArgs.push(tag); } } if (effectiveProjectPath) { cliArgs.push('--project-path', effectiveProjectPath); } if (commandOpt !== undefined && action === 'exec') { cliArgs.push('--command', commandOpt); } if (action === 'exec' || action === 'read') { cliArgs.push('--lines', lines.toString()); } const focusModeCli = focus ? 'force-focus' : 'no-focus'; if (['exec', 'read', 'kill', 'focus'].includes(action)) { cliArgs.push('--focus-mode', focusModeCli); } if (action === 'exec') { if (background) { cliArgs.push('--background'); } if (timeoutOverride !== undefined) { cliArgs.push('--timeout', timeoutOverride.toString()); } } if (action === 'list' || action === 'info') { cliArgs.push('--json'); } if (action === 'list' && tag && options.tag) { cliArgs.push('--tag', tag); } const terminatorEnv = {}; for (const key in process.env) { if (key.startsWith('TERMINATOR_')) { terminatorEnv[key] = process.env[key]; } } const internalWrapperTimeout = Math.max(DEFAULT_FOREGROUND_COMPLETION_SECONDS * 1000, DEFAULT_BACKGROUND_STARTUP_SECONDS * 1000) + 60000; try { const result = await invokeSwiftCLI(cliArgs, terminatorEnv, context, internalWrapperTimeout); if (result.cancelled) { return { success: false, message: 'Terminator action cancelled by request.' }; } if (result.internalTimeoutHit) { return { success: false, message: 'Terminator Swift CLI unresponsive and was terminated by the wrapper.' }; } if (result.exitCode === 0) { const message = formatCliOutputForAI(action, result, commandOpt, tag, background, timeoutOverride); return { success: true, message }; } else { let errMsg = result.stderr.trim() || result.stdout.trim() || 'Unknown error from Swift CLI'; if (result.exitCode === 2) errMsg = `Configuration Error: ${errMsg}`; else if (result.exitCode === 3) errMsg = `AppleScript Communication Error: ${errMsg}`; else if (result.exitCode === 4) errMsg = `Process Control Error: ${errMsg}`; else if (result.exitCode === 5) errMsg = `Invalid CLI Arguments/Usage: ${errMsg}`; else if (result.exitCode === 6) errMsg = `Unsupported Operation for App: ${errMsg}`; else if (result.exitCode === 7) errMsg = `File/IO Error: ${errMsg}`; return { success: false, message: `Terminator Error (Swift CLI Code ${result.exitCode}): ${errMsg}` }; } } catch (error) { debugLog('Error invoking or processing Swift CLI result:', error); return { success: false, message: `Terminator plugin internal error: ${error.message}` }; } } }; //# sourceMappingURL=tool.js.map