@arearseth/tmux-mcp 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +8 -0
- package/README.md +77 -0
- package/build/index.js +567 -0
- package/build/tmux.js +352 -0
- package/package.json +44 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Copyright 2025 Nicolò Gnudi
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
8
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Tmux MCP Server
|
|
2
|
+
|
|
3
|
+
Model Context Protocol server that enables Claude Desktop to interact with and view tmux session content. This integration allows AI assistants to read from, control, and observe your terminal sessions.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- List and search tmux sessions
|
|
8
|
+
- View and navigate tmux windows and panes
|
|
9
|
+
- Capture and expose terminal content from any pane
|
|
10
|
+
- Execute commands in tmux panes and retrieve results (use it at your own risk ⚠️)
|
|
11
|
+
- Create new tmux sessions and windows
|
|
12
|
+
- Split panes horizontally or vertically with customizable sizes
|
|
13
|
+
- Kill tmux sessions, windows, and panes
|
|
14
|
+
|
|
15
|
+
Check out this short video to get excited!
|
|
16
|
+
|
|
17
|
+
</br>
|
|
18
|
+
|
|
19
|
+
[](https://www.youtube.com/watch?v=3W0pqRF1RS0)
|
|
20
|
+
|
|
21
|
+
## Prerequisites
|
|
22
|
+
|
|
23
|
+
- Node.js
|
|
24
|
+
- tmux installed and running
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
### Configure Claude Desktop
|
|
29
|
+
|
|
30
|
+
Add this MCP server to your Claude Desktop configuration:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
"mcpServers": {
|
|
34
|
+
"tmux": {
|
|
35
|
+
"command": "npx",
|
|
36
|
+
"args": ["-y", "tmux-mcp"]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### MCP server options
|
|
42
|
+
|
|
43
|
+
You can optionally specify the command line shell you are using, if unspecified it defaults to `bash`
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
"mcpServers": {
|
|
47
|
+
"tmux": {
|
|
48
|
+
"command": "npx",
|
|
49
|
+
"args": ["-y", "tmux-mcp", "--shell-type=fish"]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The MCP server needs to know the shell only when executing commands, to properly read its exit status.
|
|
55
|
+
|
|
56
|
+
## Available Resources
|
|
57
|
+
|
|
58
|
+
- `tmux://sessions` - List all tmux sessions
|
|
59
|
+
- `tmux://pane/{paneId}` - View content of a specific tmux pane
|
|
60
|
+
- `tmux://command/{commandId}/result` - Results from executed commands
|
|
61
|
+
|
|
62
|
+
## Available Tools
|
|
63
|
+
|
|
64
|
+
- `list-sessions` - List all active tmux sessions
|
|
65
|
+
- `find-session` - Find a tmux session by name
|
|
66
|
+
- `list-windows` - List windows in a tmux session
|
|
67
|
+
- `list-panes` - List panes in a tmux window
|
|
68
|
+
- `capture-pane` - Capture content from a tmux pane
|
|
69
|
+
- `create-session` - Create a new tmux session
|
|
70
|
+
- `create-window` - Create a new window in a tmux session
|
|
71
|
+
- `split-pane` - Split a tmux pane horizontally or vertically with optional size
|
|
72
|
+
- `kill-session` - Kill a tmux session by ID
|
|
73
|
+
- `kill-window` - Kill a tmux window by ID
|
|
74
|
+
- `kill-pane` - Kill a tmux pane by ID
|
|
75
|
+
- `execute-command` - Execute a command in a tmux pane
|
|
76
|
+
- `get-command-result` - Get the result of an executed command
|
|
77
|
+
|
package/build/index.js
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import * as tmux from "./tmux.js";
|
|
7
|
+
// Create MCP server
|
|
8
|
+
const server = new McpServer({
|
|
9
|
+
name: "tmux-mcp",
|
|
10
|
+
version: "0.2.2"
|
|
11
|
+
}, {
|
|
12
|
+
capabilities: {
|
|
13
|
+
resources: {
|
|
14
|
+
subscribe: true,
|
|
15
|
+
listChanged: true
|
|
16
|
+
},
|
|
17
|
+
tools: {
|
|
18
|
+
listChanged: true
|
|
19
|
+
},
|
|
20
|
+
logging: {}
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
const shellTypeSchema = z.enum(tmux.supportedShellTypes);
|
|
24
|
+
// List all tmux sessions - Tool
|
|
25
|
+
server.tool("list-sessions", "List all active tmux sessions", {}, async () => {
|
|
26
|
+
try {
|
|
27
|
+
const sessions = await tmux.listSessions();
|
|
28
|
+
return {
|
|
29
|
+
content: [{
|
|
30
|
+
type: "text",
|
|
31
|
+
text: JSON.stringify(sessions, null, 2)
|
|
32
|
+
}]
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
return {
|
|
37
|
+
content: [{
|
|
38
|
+
type: "text",
|
|
39
|
+
text: `Error listing tmux sessions: ${error}`
|
|
40
|
+
}],
|
|
41
|
+
isError: true
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
// Find session by name - Tool
|
|
46
|
+
server.tool("find-session", "Find a tmux session by name", {
|
|
47
|
+
name: z.string().describe("Name of the tmux session to find")
|
|
48
|
+
}, async ({ name }) => {
|
|
49
|
+
try {
|
|
50
|
+
const session = await tmux.findSessionByName(name);
|
|
51
|
+
return {
|
|
52
|
+
content: [{
|
|
53
|
+
type: "text",
|
|
54
|
+
text: session ? JSON.stringify(session, null, 2) : `Session not found: ${name}`
|
|
55
|
+
}]
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
return {
|
|
60
|
+
content: [{
|
|
61
|
+
type: "text",
|
|
62
|
+
text: `Error finding tmux session: ${error}`
|
|
63
|
+
}],
|
|
64
|
+
isError: true
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
// List windows in a session - Tool
|
|
69
|
+
server.tool("list-windows", "List windows in a tmux session", {
|
|
70
|
+
sessionId: z.string().describe("ID of the tmux session")
|
|
71
|
+
}, async ({ sessionId }) => {
|
|
72
|
+
try {
|
|
73
|
+
const windows = await tmux.listWindows(sessionId);
|
|
74
|
+
return {
|
|
75
|
+
content: [{
|
|
76
|
+
type: "text",
|
|
77
|
+
text: JSON.stringify(windows, null, 2)
|
|
78
|
+
}]
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
return {
|
|
83
|
+
content: [{
|
|
84
|
+
type: "text",
|
|
85
|
+
text: `Error listing windows: ${error}`
|
|
86
|
+
}],
|
|
87
|
+
isError: true
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
// List panes in a window - Tool
|
|
92
|
+
server.tool("list-panes", "List panes in a tmux window", {
|
|
93
|
+
windowId: z.string().describe("ID of the tmux window")
|
|
94
|
+
}, async ({ windowId }) => {
|
|
95
|
+
try {
|
|
96
|
+
const panes = await tmux.listPanes(windowId);
|
|
97
|
+
return {
|
|
98
|
+
content: [{
|
|
99
|
+
type: "text",
|
|
100
|
+
text: JSON.stringify(panes, null, 2)
|
|
101
|
+
}]
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return {
|
|
106
|
+
content: [{
|
|
107
|
+
type: "text",
|
|
108
|
+
text: `Error listing panes: ${error}`
|
|
109
|
+
}],
|
|
110
|
+
isError: true
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
// Capture pane content - Tool
|
|
115
|
+
server.tool("capture-pane", "Capture content from a tmux pane with configurable lines count and optional color preservation", {
|
|
116
|
+
paneId: z.string().describe("ID of the tmux pane"),
|
|
117
|
+
lines: z.string().optional().describe("Number of lines to capture"),
|
|
118
|
+
colors: z.boolean().optional().describe("Include color/escape sequences for text and background attributes in output")
|
|
119
|
+
}, async ({ paneId, lines, colors }) => {
|
|
120
|
+
try {
|
|
121
|
+
// Parse lines parameter if provided
|
|
122
|
+
const linesCount = lines ? parseInt(lines, 10) : undefined;
|
|
123
|
+
const includeColors = colors || false;
|
|
124
|
+
const content = await tmux.capturePaneContent(paneId, linesCount, includeColors);
|
|
125
|
+
return {
|
|
126
|
+
content: [{
|
|
127
|
+
type: "text",
|
|
128
|
+
text: content || "No content captured"
|
|
129
|
+
}]
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
return {
|
|
134
|
+
content: [{
|
|
135
|
+
type: "text",
|
|
136
|
+
text: `Error capturing pane content: ${error}`
|
|
137
|
+
}],
|
|
138
|
+
isError: true
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
// Create new session - Tool
|
|
143
|
+
server.tool("create-session", "Create a new tmux session", {
|
|
144
|
+
name: z.string().describe("Name for the new tmux session")
|
|
145
|
+
}, async ({ name }) => {
|
|
146
|
+
try {
|
|
147
|
+
const session = await tmux.createSession(name);
|
|
148
|
+
return {
|
|
149
|
+
content: [{
|
|
150
|
+
type: "text",
|
|
151
|
+
text: session
|
|
152
|
+
? `Session created: ${JSON.stringify(session, null, 2)}`
|
|
153
|
+
: `Failed to create session: ${name}`
|
|
154
|
+
}]
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
return {
|
|
159
|
+
content: [{
|
|
160
|
+
type: "text",
|
|
161
|
+
text: `Error creating session: ${error}`
|
|
162
|
+
}],
|
|
163
|
+
isError: true
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
// Create new window - Tool
|
|
168
|
+
server.tool("create-window", "Create a new window in a tmux session", {
|
|
169
|
+
sessionId: z.string().describe("ID of the tmux session"),
|
|
170
|
+
name: z.string().describe("Name for the new window")
|
|
171
|
+
}, async ({ sessionId, name }) => {
|
|
172
|
+
try {
|
|
173
|
+
const window = await tmux.createWindow(sessionId, name);
|
|
174
|
+
return {
|
|
175
|
+
content: [{
|
|
176
|
+
type: "text",
|
|
177
|
+
text: window
|
|
178
|
+
? `Window created: ${JSON.stringify(window, null, 2)}`
|
|
179
|
+
: `Failed to create window: ${name}`
|
|
180
|
+
}]
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
return {
|
|
185
|
+
content: [{
|
|
186
|
+
type: "text",
|
|
187
|
+
text: `Error creating window: ${error}`
|
|
188
|
+
}],
|
|
189
|
+
isError: true
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
// Kill session - Tool
|
|
194
|
+
server.tool("kill-session", "Kill a tmux session by ID", {
|
|
195
|
+
sessionId: z.string().describe("ID of the tmux session to kill")
|
|
196
|
+
}, async ({ sessionId }) => {
|
|
197
|
+
try {
|
|
198
|
+
await tmux.killSession(sessionId);
|
|
199
|
+
return {
|
|
200
|
+
content: [{
|
|
201
|
+
type: "text",
|
|
202
|
+
text: `Session ${sessionId} has been killed`
|
|
203
|
+
}]
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
return {
|
|
208
|
+
content: [{
|
|
209
|
+
type: "text",
|
|
210
|
+
text: `Error killing session: ${error}`
|
|
211
|
+
}],
|
|
212
|
+
isError: true
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
// Kill window - Tool
|
|
217
|
+
server.tool("kill-window", "Kill a tmux window by ID", {
|
|
218
|
+
windowId: z.string().describe("ID of the tmux window to kill")
|
|
219
|
+
}, async ({ windowId }) => {
|
|
220
|
+
try {
|
|
221
|
+
await tmux.killWindow(windowId);
|
|
222
|
+
return {
|
|
223
|
+
content: [{
|
|
224
|
+
type: "text",
|
|
225
|
+
text: `Window ${windowId} has been killed`
|
|
226
|
+
}]
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
return {
|
|
231
|
+
content: [{
|
|
232
|
+
type: "text",
|
|
233
|
+
text: `Error killing window: ${error}`
|
|
234
|
+
}],
|
|
235
|
+
isError: true
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
// Kill pane - Tool
|
|
240
|
+
server.tool("kill-pane", "Kill a tmux pane by ID", {
|
|
241
|
+
paneId: z.string().describe("ID of the tmux pane to kill")
|
|
242
|
+
}, async ({ paneId }) => {
|
|
243
|
+
try {
|
|
244
|
+
await tmux.killPane(paneId);
|
|
245
|
+
return {
|
|
246
|
+
content: [{
|
|
247
|
+
type: "text",
|
|
248
|
+
text: `Pane ${paneId} has been killed`
|
|
249
|
+
}]
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
return {
|
|
254
|
+
content: [{
|
|
255
|
+
type: "text",
|
|
256
|
+
text: `Error killing pane: ${error}`
|
|
257
|
+
}],
|
|
258
|
+
isError: true
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
// Split pane - Tool
|
|
263
|
+
server.tool("split-pane", "Split a tmux pane horizontally or vertically", {
|
|
264
|
+
paneId: z.string().describe("ID of the tmux pane to split"),
|
|
265
|
+
direction: z.enum(["horizontal", "vertical"]).optional().describe("Split direction: 'horizontal' (side by side) or 'vertical' (top/bottom). Default is 'vertical'"),
|
|
266
|
+
size: z.number().min(1).max(99).optional().describe("Size of the new pane as percentage (1-99). Default is 50%")
|
|
267
|
+
}, async ({ paneId, direction, size }) => {
|
|
268
|
+
try {
|
|
269
|
+
const newPane = await tmux.splitPane(paneId, direction || 'vertical', size);
|
|
270
|
+
return {
|
|
271
|
+
content: [{
|
|
272
|
+
type: "text",
|
|
273
|
+
text: newPane
|
|
274
|
+
? `Pane split successfully. New pane: ${JSON.stringify(newPane, null, 2)}`
|
|
275
|
+
: `Failed to split pane ${paneId}`
|
|
276
|
+
}]
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
return {
|
|
281
|
+
content: [{
|
|
282
|
+
type: "text",
|
|
283
|
+
text: `Error splitting pane: ${error}`
|
|
284
|
+
}],
|
|
285
|
+
isError: true
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
// Configure shell type - Tool
|
|
290
|
+
server.tool("set-shell-type", "Configure the shell type used for command execution. Provide paneId to override a specific pane.", {
|
|
291
|
+
type: shellTypeSchema,
|
|
292
|
+
paneId: z.string().optional().describe("ID of the tmux pane to override. Omit to change the default shell type.")
|
|
293
|
+
}, async ({ type, paneId }) => {
|
|
294
|
+
try {
|
|
295
|
+
tmux.setShellConfig({ type, paneId });
|
|
296
|
+
const target = paneId ? `pane ${paneId}` : 'default';
|
|
297
|
+
return {
|
|
298
|
+
content: [{
|
|
299
|
+
type: "text",
|
|
300
|
+
text: `Shell type for ${target} set to ${type}`
|
|
301
|
+
}]
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
return {
|
|
306
|
+
content: [{
|
|
307
|
+
type: "text",
|
|
308
|
+
text: `Error setting shell type: ${error}`
|
|
309
|
+
}],
|
|
310
|
+
isError: true
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
// Execute command in pane - Tool
|
|
315
|
+
server.tool("execute-command", "Execute a command in a tmux pane and get results. For interactive applications (REPLs, editors), use `rawMode=true`. IMPORTANT: When `rawMode=false` (default), avoid heredoc syntax (cat << EOF) and other multi-line constructs as they conflict with command wrapping. For file writing, prefer: printf 'content\\n' > file, echo statements, or write to temp files instead", {
|
|
316
|
+
paneId: z.string().describe("ID of the tmux pane"),
|
|
317
|
+
command: z.string().describe("Command to execute"),
|
|
318
|
+
rawMode: z.boolean().optional().describe("Execute command without wrapper markers for REPL/interactive compatibility. Disables get-command-result status tracking. Use capture-pane after execution to verify command outcome."),
|
|
319
|
+
noEnter: z.boolean().optional().describe("Send keystrokes without pressing Enter. For TUI navigation in apps like btop, vim, less. Supports special keys (Up, Down, Escape, Tab, etc.) and strings (sent char-by-char for proper filtering). Automatically applies rawMode. Use capture-pane after to see results.")
|
|
320
|
+
}, async ({ paneId, command, rawMode, noEnter }) => {
|
|
321
|
+
try {
|
|
322
|
+
// If noEnter is true, automatically apply rawMode
|
|
323
|
+
const effectiveRawMode = noEnter || rawMode;
|
|
324
|
+
const commandId = await tmux.executeCommand(paneId, command, effectiveRawMode, noEnter);
|
|
325
|
+
if (effectiveRawMode) {
|
|
326
|
+
const modeText = noEnter ? "Keys sent without Enter" : "Interactive command started (rawMode)";
|
|
327
|
+
return {
|
|
328
|
+
content: [{
|
|
329
|
+
type: "text",
|
|
330
|
+
text: `${modeText}.\n\nStatus tracking is disabled.\nUse 'capture-pane' with paneId '${paneId}' to verify the command outcome.\n\nCommand ID: ${commandId}`
|
|
331
|
+
}]
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
// Create the resource URI for this command's results
|
|
335
|
+
const resourceUri = `tmux://command/${commandId}/result`;
|
|
336
|
+
return {
|
|
337
|
+
content: [{
|
|
338
|
+
type: "text",
|
|
339
|
+
text: `Command execution started.\n\nTo get results, subscribe to and read resource: ${resourceUri}\n\nStatus will change from 'pending' to 'completed' or 'error' when finished.`
|
|
340
|
+
}]
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
return {
|
|
345
|
+
content: [{
|
|
346
|
+
type: "text",
|
|
347
|
+
text: `Error executing command: ${error}`
|
|
348
|
+
}],
|
|
349
|
+
isError: true
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
// Get command result - Tool
|
|
354
|
+
server.tool("get-command-result", "Get the result of an executed command", {
|
|
355
|
+
commandId: z.string().describe("ID of the executed command")
|
|
356
|
+
}, async ({ commandId }) => {
|
|
357
|
+
try {
|
|
358
|
+
// Check and update command status
|
|
359
|
+
const command = await tmux.checkCommandStatus(commandId);
|
|
360
|
+
if (!command) {
|
|
361
|
+
return {
|
|
362
|
+
content: [{
|
|
363
|
+
type: "text",
|
|
364
|
+
text: `Command not found: ${commandId}`
|
|
365
|
+
}],
|
|
366
|
+
isError: true
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
// Format the response based on command status
|
|
370
|
+
let resultText;
|
|
371
|
+
if (command.status === 'pending') {
|
|
372
|
+
if (command.result) {
|
|
373
|
+
resultText = `Status: ${command.status}\nCommand: ${command.command}\n\n--- Message ---\n${command.result}`;
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
resultText = `Command still executing...\nStarted: ${command.startTime.toISOString()}\nCommand: ${command.command}`;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
resultText = `Status: ${command.status}\nExit code: ${command.exitCode}\nCommand: ${command.command}\n\n--- Output ---\n${command.result}`;
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
content: [{
|
|
384
|
+
type: "text",
|
|
385
|
+
text: resultText
|
|
386
|
+
}]
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
return {
|
|
391
|
+
content: [{
|
|
392
|
+
type: "text",
|
|
393
|
+
text: `Error retrieving command result: ${error}`
|
|
394
|
+
}],
|
|
395
|
+
isError: true
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
// Expose tmux session list as a resource
|
|
400
|
+
server.resource("Tmux Sessions", "tmux://sessions", async () => {
|
|
401
|
+
try {
|
|
402
|
+
const sessions = await tmux.listSessions();
|
|
403
|
+
return {
|
|
404
|
+
contents: [{
|
|
405
|
+
uri: "tmux://sessions",
|
|
406
|
+
text: JSON.stringify(sessions.map(session => ({
|
|
407
|
+
id: session.id,
|
|
408
|
+
name: session.name,
|
|
409
|
+
attached: session.attached,
|
|
410
|
+
windows: session.windows
|
|
411
|
+
})), null, 2)
|
|
412
|
+
}]
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
return {
|
|
417
|
+
contents: [{
|
|
418
|
+
uri: "tmux://sessions",
|
|
419
|
+
text: `Error listing tmux sessions: ${error}`
|
|
420
|
+
}]
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
// Expose pane content as a resource
|
|
425
|
+
server.resource("Tmux Pane Content", new ResourceTemplate("tmux://pane/{paneId}", {
|
|
426
|
+
list: async () => {
|
|
427
|
+
try {
|
|
428
|
+
// Get all sessions
|
|
429
|
+
const sessions = await tmux.listSessions();
|
|
430
|
+
const paneResources = [];
|
|
431
|
+
// For each session, get all windows
|
|
432
|
+
for (const session of sessions) {
|
|
433
|
+
const windows = await tmux.listWindows(session.id);
|
|
434
|
+
// For each window, get all panes
|
|
435
|
+
for (const window of windows) {
|
|
436
|
+
const panes = await tmux.listPanes(window.id);
|
|
437
|
+
// For each pane, create a resource with descriptive name
|
|
438
|
+
for (const pane of panes) {
|
|
439
|
+
paneResources.push({
|
|
440
|
+
name: `Pane: ${session.name} - ${pane.id} - ${pane.title} ${pane.active ? "(active)" : ""}`,
|
|
441
|
+
uri: `tmux://pane/${pane.id}`,
|
|
442
|
+
description: `Content from pane ${pane.id} - ${pane.title} in session ${session.name}`
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
resources: paneResources
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
server.server.sendLoggingMessage({
|
|
453
|
+
level: 'error',
|
|
454
|
+
data: `Error listing panes: ${error}`
|
|
455
|
+
});
|
|
456
|
+
return { resources: [] };
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}), async (uri, { paneId }) => {
|
|
460
|
+
try {
|
|
461
|
+
// Ensure paneId is a string
|
|
462
|
+
const paneIdStr = Array.isArray(paneId) ? paneId[0] : paneId;
|
|
463
|
+
// Default to no colors for resources to maintain clean programmatic access
|
|
464
|
+
const content = await tmux.capturePaneContent(paneIdStr, 200, false);
|
|
465
|
+
return {
|
|
466
|
+
contents: [{
|
|
467
|
+
uri: uri.href,
|
|
468
|
+
text: content || "No content captured"
|
|
469
|
+
}]
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
return {
|
|
474
|
+
contents: [{
|
|
475
|
+
uri: uri.href,
|
|
476
|
+
text: `Error capturing pane content: ${error}`
|
|
477
|
+
}]
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
// Create dynamic resource for command executions
|
|
482
|
+
server.resource("Command Execution Result", new ResourceTemplate("tmux://command/{commandId}/result", {
|
|
483
|
+
list: async () => {
|
|
484
|
+
// Only list active commands that aren't too old
|
|
485
|
+
tmux.cleanupOldCommands(10); // Clean commands older than 10 minutes
|
|
486
|
+
const resources = [];
|
|
487
|
+
for (const id of tmux.getActiveCommandIds()) {
|
|
488
|
+
const command = tmux.getCommand(id);
|
|
489
|
+
if (command) {
|
|
490
|
+
resources.push({
|
|
491
|
+
name: `Command: ${command.command.substring(0, 30)}${command.command.length > 30 ? '...' : ''}`,
|
|
492
|
+
uri: `tmux://command/${id}/result`,
|
|
493
|
+
description: `Execution status: ${command.status}`
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return { resources };
|
|
498
|
+
}
|
|
499
|
+
}), async (uri, { commandId }) => {
|
|
500
|
+
try {
|
|
501
|
+
// Ensure commandId is a string
|
|
502
|
+
const commandIdStr = Array.isArray(commandId) ? commandId[0] : commandId;
|
|
503
|
+
// Check command status
|
|
504
|
+
const command = await tmux.checkCommandStatus(commandIdStr);
|
|
505
|
+
if (!command) {
|
|
506
|
+
return {
|
|
507
|
+
contents: [{
|
|
508
|
+
uri: uri.href,
|
|
509
|
+
text: `Command not found: ${commandIdStr}`
|
|
510
|
+
}]
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
// Format the response based on command status
|
|
514
|
+
let resultText;
|
|
515
|
+
if (command.status === 'pending') {
|
|
516
|
+
// For rawMode commands, we set a result message while status remains 'pending'
|
|
517
|
+
// since we can't track their actual completion
|
|
518
|
+
if (command.result) {
|
|
519
|
+
resultText = `Status: ${command.status}\nCommand: ${command.command}\n\n--- Message ---\n${command.result}`;
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
resultText = `Command still executing...\nStarted: ${command.startTime.toISOString()}\nCommand: ${command.command}`;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
resultText = `Status: ${command.status}\nExit code: ${command.exitCode}\nCommand: ${command.command}\n\n--- Output ---\n${command.result}`;
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
contents: [{
|
|
530
|
+
uri: uri.href,
|
|
531
|
+
text: resultText
|
|
532
|
+
}]
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
return {
|
|
537
|
+
contents: [{
|
|
538
|
+
uri: uri.href,
|
|
539
|
+
text: `Error retrieving command result: ${error}`
|
|
540
|
+
}]
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
async function main() {
|
|
545
|
+
try {
|
|
546
|
+
const { values } = parseArgs({
|
|
547
|
+
options: {
|
|
548
|
+
'shell-type': { type: 'string', default: 'bash', short: 's' }
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
// Set shell configuration
|
|
552
|
+
tmux.setShellConfig({
|
|
553
|
+
type: values['shell-type']
|
|
554
|
+
});
|
|
555
|
+
// Start the MCP server
|
|
556
|
+
const transport = new StdioServerTransport();
|
|
557
|
+
await server.connect(transport);
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
console.error("Failed to start MCP server:", error);
|
|
561
|
+
process.exit(1);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
main().catch(error => {
|
|
565
|
+
console.error("Fatal error:", error);
|
|
566
|
+
process.exit(1);
|
|
567
|
+
});
|
package/build/tmux.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { exec as execCallback } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
4
|
+
const exec = promisify(execCallback);
|
|
5
|
+
export const supportedShellTypes = ['bash', 'zsh', 'fish', 'fc_shell'];
|
|
6
|
+
const shellConfig = {
|
|
7
|
+
defaultType: 'bash',
|
|
8
|
+
paneOverrides: new Map()
|
|
9
|
+
};
|
|
10
|
+
function normalizeShellType(type) {
|
|
11
|
+
return supportedShellTypes.includes(type)
|
|
12
|
+
? type
|
|
13
|
+
: 'bash';
|
|
14
|
+
}
|
|
15
|
+
export function setShellConfig(config) {
|
|
16
|
+
const normalized = normalizeShellType(config.type);
|
|
17
|
+
if (config.paneId) {
|
|
18
|
+
shellConfig.paneOverrides.set(config.paneId, normalized);
|
|
19
|
+
// Reset cached initialization so the helper can be installed on demand
|
|
20
|
+
fcShellInitializedPanes.delete(config.paneId);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
shellConfig.defaultType = normalized;
|
|
24
|
+
if (normalized !== 'fc_shell') {
|
|
25
|
+
fcShellInitializedPanes.clear();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function resolveShellType(paneId) {
|
|
29
|
+
return shellConfig.paneOverrides.get(paneId) ?? shellConfig.defaultType;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Execute a tmux command and return the result
|
|
33
|
+
*/
|
|
34
|
+
export async function executeTmux(tmuxCommand) {
|
|
35
|
+
try {
|
|
36
|
+
const { stdout } = await exec(`tmux ${tmuxCommand}`);
|
|
37
|
+
return stdout.trim();
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
throw new Error(`Failed to execute tmux command: ${error.message}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Check if tmux server is running
|
|
45
|
+
*/
|
|
46
|
+
export async function isTmuxRunning() {
|
|
47
|
+
try {
|
|
48
|
+
await executeTmux("list-sessions -F '#{session_name}'");
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* List all tmux sessions
|
|
57
|
+
*/
|
|
58
|
+
export async function listSessions() {
|
|
59
|
+
const format = "#{session_id}:#{session_name}:#{?session_attached,1,0}:#{session_windows}";
|
|
60
|
+
const output = await executeTmux(`list-sessions -F '${format}'`);
|
|
61
|
+
if (!output)
|
|
62
|
+
return [];
|
|
63
|
+
return output.split('\n').map(line => {
|
|
64
|
+
const [id, name, attached, windows] = line.split(':');
|
|
65
|
+
return {
|
|
66
|
+
id,
|
|
67
|
+
name,
|
|
68
|
+
attached: attached === '1',
|
|
69
|
+
windows: parseInt(windows, 10)
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Find a session by name
|
|
75
|
+
*/
|
|
76
|
+
export async function findSessionByName(name) {
|
|
77
|
+
try {
|
|
78
|
+
const sessions = await listSessions();
|
|
79
|
+
return sessions.find(session => session.name === name) || null;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* List windows in a session
|
|
87
|
+
*/
|
|
88
|
+
export async function listWindows(sessionId) {
|
|
89
|
+
const format = "#{window_id}:#{window_name}:#{?window_active,1,0}";
|
|
90
|
+
const output = await executeTmux(`list-windows -t '${sessionId}' -F '${format}'`);
|
|
91
|
+
if (!output)
|
|
92
|
+
return [];
|
|
93
|
+
return output.split('\n').map(line => {
|
|
94
|
+
const [id, name, active] = line.split(':');
|
|
95
|
+
return {
|
|
96
|
+
id,
|
|
97
|
+
name,
|
|
98
|
+
active: active === '1',
|
|
99
|
+
sessionId
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* List panes in a window
|
|
105
|
+
*/
|
|
106
|
+
export async function listPanes(windowId) {
|
|
107
|
+
const format = "#{pane_id}:#{pane_title}:#{?pane_active,1,0}";
|
|
108
|
+
const output = await executeTmux(`list-panes -t '${windowId}' -F '${format}'`);
|
|
109
|
+
if (!output)
|
|
110
|
+
return [];
|
|
111
|
+
return output.split('\n').map(line => {
|
|
112
|
+
const [id, title, active] = line.split(':');
|
|
113
|
+
return {
|
|
114
|
+
id,
|
|
115
|
+
windowId,
|
|
116
|
+
title: title,
|
|
117
|
+
active: active === '1'
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Capture content from a specific pane, by default the latest 200 lines.
|
|
123
|
+
*/
|
|
124
|
+
export async function capturePaneContent(paneId, lines = 200, includeColors = false) {
|
|
125
|
+
const colorFlag = includeColors ? '-e' : '';
|
|
126
|
+
return executeTmux(`capture-pane -p ${colorFlag} -t '${paneId}' -S -${lines} -E -`);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Create a new tmux session
|
|
130
|
+
*/
|
|
131
|
+
export async function createSession(name) {
|
|
132
|
+
await executeTmux(`new-session -d -s "${name}"`);
|
|
133
|
+
return findSessionByName(name);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Create a new window in a session
|
|
137
|
+
*/
|
|
138
|
+
export async function createWindow(sessionId, name) {
|
|
139
|
+
const output = await executeTmux(`new-window -t '${sessionId}' -n '${name}'`);
|
|
140
|
+
const windows = await listWindows(sessionId);
|
|
141
|
+
return windows.find(window => window.name === name) || null;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Kill a tmux session by ID
|
|
145
|
+
*/
|
|
146
|
+
export async function killSession(sessionId) {
|
|
147
|
+
await executeTmux(`kill-session -t '${sessionId}'`);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Kill a tmux window by ID
|
|
151
|
+
*/
|
|
152
|
+
export async function killWindow(windowId) {
|
|
153
|
+
await executeTmux(`kill-window -t '${windowId}'`);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Kill a tmux pane by ID
|
|
157
|
+
*/
|
|
158
|
+
export async function killPane(paneId) {
|
|
159
|
+
await executeTmux(`kill-pane -t '${paneId}'`);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Split a tmux pane horizontally or vertically
|
|
163
|
+
*/
|
|
164
|
+
export async function splitPane(targetPaneId, direction = 'vertical', size) {
|
|
165
|
+
// Build the split-window command
|
|
166
|
+
let splitCommand = 'split-window';
|
|
167
|
+
// Add direction flag (-h for horizontal, -v for vertical)
|
|
168
|
+
if (direction === 'horizontal') {
|
|
169
|
+
splitCommand += ' -h';
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
splitCommand += ' -v';
|
|
173
|
+
}
|
|
174
|
+
// Add target pane
|
|
175
|
+
splitCommand += ` -t '${targetPaneId}'`;
|
|
176
|
+
// Add size if specified (as percentage)
|
|
177
|
+
if (size !== undefined && size > 0 && size < 100) {
|
|
178
|
+
splitCommand += ` -p ${size}`;
|
|
179
|
+
}
|
|
180
|
+
// Execute the split command
|
|
181
|
+
await executeTmux(splitCommand);
|
|
182
|
+
// Get the window ID from the target pane to list all panes
|
|
183
|
+
const windowInfo = await executeTmux(`display-message -p -t '${targetPaneId}' '#{window_id}'`);
|
|
184
|
+
// List all panes in the window to find the newly created one
|
|
185
|
+
const panes = await listPanes(windowInfo);
|
|
186
|
+
// The newest pane is typically the last one in the list
|
|
187
|
+
return panes.length > 0 ? panes[panes.length - 1] : null;
|
|
188
|
+
}
|
|
189
|
+
// Map to track ongoing command executions
|
|
190
|
+
const activeCommands = new Map();
|
|
191
|
+
const startMarkerText = 'TMUX_MCP_START';
|
|
192
|
+
const endMarkerPrefix = "TMUX_MCP_DONE_";
|
|
193
|
+
// Track fc_shell initialization per pane to keep terminal output minimal
|
|
194
|
+
const fcShellInitializedPanes = new Set();
|
|
195
|
+
// Execute a command in a tmux pane and track its execution
|
|
196
|
+
export async function executeCommand(paneId, command, rawMode, noEnter) {
|
|
197
|
+
// Generate unique ID for this command execution
|
|
198
|
+
const commandId = uuidv4();
|
|
199
|
+
const shellType = resolveShellType(paneId);
|
|
200
|
+
let fullCommand;
|
|
201
|
+
if (rawMode || noEnter) {
|
|
202
|
+
fullCommand = command;
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
if (shellType === 'fc_shell') {
|
|
206
|
+
await ensureFcShellInitialized(paneId);
|
|
207
|
+
fullCommand = buildFcShellCommand(command);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
fullCommand = buildWrappedCommand(command, shellType);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Store command in tracking map
|
|
214
|
+
activeCommands.set(commandId, {
|
|
215
|
+
id: commandId,
|
|
216
|
+
paneId,
|
|
217
|
+
command,
|
|
218
|
+
status: 'pending',
|
|
219
|
+
startTime: new Date(),
|
|
220
|
+
rawMode: rawMode || noEnter
|
|
221
|
+
});
|
|
222
|
+
// Send the command to the tmux pane
|
|
223
|
+
if (noEnter) {
|
|
224
|
+
// Check if this is a special key (e.g., Up, Down, Left, Right, Escape, Tab, etc.)
|
|
225
|
+
// Special keys in tmux are typically capitalized or have special names
|
|
226
|
+
const specialKeys = ['Up', 'Down', 'Left', 'Right', 'Escape', 'Tab', 'Enter', 'Space',
|
|
227
|
+
'BSpace', 'Delete', 'Home', 'End', 'PageUp', 'PageDown',
|
|
228
|
+
'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12'];
|
|
229
|
+
if (specialKeys.includes(fullCommand)) {
|
|
230
|
+
// Send special key as-is
|
|
231
|
+
await executeTmux(`send-keys -t '${paneId}' ${fullCommand}`);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
// For regular text, send each character individually to ensure proper processing
|
|
235
|
+
// This handles both single characters (like 'q', 'f') and strings (like 'beam')
|
|
236
|
+
for (const char of fullCommand) {
|
|
237
|
+
await executeTmux(`send-keys -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
await executeTmux(`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`);
|
|
243
|
+
}
|
|
244
|
+
return commandId;
|
|
245
|
+
}
|
|
246
|
+
export async function checkCommandStatus(commandId) {
|
|
247
|
+
const command = activeCommands.get(commandId);
|
|
248
|
+
if (!command)
|
|
249
|
+
return null;
|
|
250
|
+
if (command.status !== 'pending')
|
|
251
|
+
return command;
|
|
252
|
+
const content = await capturePaneContent(command.paneId, 1000);
|
|
253
|
+
if (command.rawMode) {
|
|
254
|
+
command.result = 'Status tracking unavailable for rawMode commands. Use capture-pane to monitor interactive apps instead.';
|
|
255
|
+
return command;
|
|
256
|
+
}
|
|
257
|
+
// Find the last occurrence of the markers
|
|
258
|
+
const startIndex = content.lastIndexOf(startMarkerText);
|
|
259
|
+
const endIndex = content.lastIndexOf(endMarkerPrefix);
|
|
260
|
+
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
|
|
261
|
+
command.result = "Command output could not be captured properly";
|
|
262
|
+
return command;
|
|
263
|
+
}
|
|
264
|
+
// Extract exit code from the end marker line
|
|
265
|
+
const endLine = content.substring(endIndex).split('\n')[0];
|
|
266
|
+
const endMarkerRegex = new RegExp(`${endMarkerPrefix}(\\d+)`);
|
|
267
|
+
const exitCodeMatch = endLine.match(endMarkerRegex);
|
|
268
|
+
if (exitCodeMatch) {
|
|
269
|
+
const exitCode = parseInt(exitCodeMatch[1], 10);
|
|
270
|
+
command.status = exitCode === 0 ? 'completed' : 'error';
|
|
271
|
+
command.exitCode = exitCode;
|
|
272
|
+
// Extract output between the start and end markers
|
|
273
|
+
const outputStart = startIndex + startMarkerText.length;
|
|
274
|
+
const outputContent = content.substring(outputStart, endIndex).trim();
|
|
275
|
+
const outputLines = outputContent ? outputContent.split('\n') : [];
|
|
276
|
+
if (outputLines.length > 0) {
|
|
277
|
+
const firstLine = outputLines[0].trim();
|
|
278
|
+
if (firstLine === command.command.trim()) {
|
|
279
|
+
outputLines.shift();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
command.result = outputLines.join('\n').trim();
|
|
283
|
+
// Update in map
|
|
284
|
+
activeCommands.set(commandId, command);
|
|
285
|
+
}
|
|
286
|
+
return command;
|
|
287
|
+
}
|
|
288
|
+
// Get command by ID
|
|
289
|
+
export function getCommand(commandId) {
|
|
290
|
+
return activeCommands.get(commandId) || null;
|
|
291
|
+
}
|
|
292
|
+
// Get all active command IDs
|
|
293
|
+
export function getActiveCommandIds() {
|
|
294
|
+
return Array.from(activeCommands.keys());
|
|
295
|
+
}
|
|
296
|
+
// Clean up completed commands older than a certain time
|
|
297
|
+
export function cleanupOldCommands(maxAgeMinutes = 60) {
|
|
298
|
+
const now = new Date();
|
|
299
|
+
for (const [id, command] of activeCommands.entries()) {
|
|
300
|
+
const ageMinutes = (now.getTime() - command.startTime.getTime()) / (1000 * 60);
|
|
301
|
+
if (command.status !== 'pending' && ageMinutes > maxAgeMinutes) {
|
|
302
|
+
activeCommands.delete(id);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function getEndMarkerText(shellType) {
|
|
307
|
+
if (shellType === 'fish') {
|
|
308
|
+
return `${endMarkerPrefix}$status`;
|
|
309
|
+
}
|
|
310
|
+
if (shellType === 'fc_shell') {
|
|
311
|
+
return `${endMarkerPrefix}$::tmux_mcp_status`;
|
|
312
|
+
}
|
|
313
|
+
return `${endMarkerPrefix}$?`;
|
|
314
|
+
}
|
|
315
|
+
function buildWrappedCommand(command, shellType) {
|
|
316
|
+
const endMarkerText = getEndMarkerText(shellType);
|
|
317
|
+
return `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`;
|
|
318
|
+
}
|
|
319
|
+
function buildFcShellCommand(command) {
|
|
320
|
+
const escaped = escapeForTcl(command);
|
|
321
|
+
return `::tmux_mcp::run {${escaped}}`;
|
|
322
|
+
}
|
|
323
|
+
function escapeForTcl(command) {
|
|
324
|
+
return command
|
|
325
|
+
.replace(/\\/g, '\\\\')
|
|
326
|
+
.replace(/\r/g, '\\r')
|
|
327
|
+
.replace(/\n/g, '\\n')
|
|
328
|
+
.replace(/\{/g, '\\{')
|
|
329
|
+
.replace(/\}/g, '\\}');
|
|
330
|
+
}
|
|
331
|
+
async function ensureFcShellInitialized(paneId) {
|
|
332
|
+
if (fcShellInitializedPanes.has(paneId)) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const definitionCommand = [
|
|
336
|
+
'namespace eval ::tmux_mcp {',
|
|
337
|
+
'proc run {cmd} {',
|
|
338
|
+
`puts "${startMarkerText}";`,
|
|
339
|
+
'set status [catch {uplevel #0 $cmd} result opts];',
|
|
340
|
+
'if {$status == 0} {',
|
|
341
|
+
'if {[info exists result] && $result ne ""} { puts $result }',
|
|
342
|
+
'} else {',
|
|
343
|
+
'if {[info exists opts(-errorinfo)]} { puts $opts(-errorinfo) } else { puts $result }',
|
|
344
|
+
'};',
|
|
345
|
+
`puts "${endMarkerPrefix}$status"`,
|
|
346
|
+
'}',
|
|
347
|
+
'}'
|
|
348
|
+
].join(' ');
|
|
349
|
+
const escapedCommand = definitionCommand.replace(/'/g, "'\\''");
|
|
350
|
+
await executeTmux(`send-keys -t '${paneId}' '${escapedCommand}' Enter`);
|
|
351
|
+
fcShellInitializedPanes.add(paneId);
|
|
352
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@arearseth/tmux-mcp",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "MCP Server for interfacing with tmux sessions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "build/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"start": "node build/index.js",
|
|
10
|
+
"dev": "tsc -w",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"check-release": "npm run build && npm publish --dry-run",
|
|
13
|
+
"release": "npm run build && npm publish"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"tmux-mcp": "build/index.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"build"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"tmux",
|
|
24
|
+
"claude"
|
|
25
|
+
],
|
|
26
|
+
"author": "nickgnd",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@modelcontextprotocol/sdk": "^1.0.2",
|
|
30
|
+
"uuid": "^11.1.0",
|
|
31
|
+
"zod": "^3.22.4"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^20.10.5",
|
|
35
|
+
"@types/uuid": "^10.0.0",
|
|
36
|
+
"vitest": "^1.6.0",
|
|
37
|
+
"typescript": "^5.3.3"
|
|
38
|
+
},
|
|
39
|
+
"repository": "github:AreAArseth/tmux-mcp",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/AreAArseth/tmux-mcp/issues"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/AreAArseth/tmux-mcp#readme"
|
|
44
|
+
}
|