smart-terminal-mcp 1.2.10 → 1.2.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-terminal-mcp",
3
- "version": "1.2.10",
3
+ "version": "1.2.12",
4
4
  "description": "MCP PTY server providing AI agents with real interactive terminal access",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -10,7 +10,7 @@
10
10
  "scripts": {
11
11
  "start": "node src/index.js",
12
12
  "test": "node --test",
13
- "stable": "npm dist-tag add smart-terminal-mcp@1.2.0 stable"
13
+ "stable": "npm dist-tag add smart-terminal-mcp@1.2.9 stable"
14
14
  },
15
15
  "keywords": [
16
16
  "mcp",
@@ -116,6 +116,9 @@ export async function runCommand({
116
116
 
117
117
  if (signal) result.signal = signal;
118
118
  if (maxOutputExceeded) result.maxOutputExceeded = true;
119
+ if (successExitCode === null && exitCode !== 0 && exitCode !== null) {
120
+ result.exitCodeIgnored = true;
121
+ }
119
122
  if (shouldIncludeSuccessChecks({ successExitCode, successFile })) {
120
123
  result.checks = checks.details;
121
124
  }
@@ -229,6 +232,14 @@ function buildSpawnPlan({ cmd, args, cwd }) {
229
232
  }
230
233
 
231
234
  const resolvedCommand = resolveWindowsCommand(cmd, cwd);
235
+
236
+ // When the caller explicitly invokes cmd.exe with /c (e.g. for shell
237
+ // built-ins like `for /f`), join the trailing args into a single verbatim
238
+ // command string so cmd.exe interprets them correctly.
239
+ if (isExplicitCmdExeCall(resolvedCommand ?? cmd, args)) {
240
+ return buildCmdExePlan(args);
241
+ }
242
+
232
243
  if (!resolvedCommand || !isWindowsBatchCommand(resolvedCommand)) {
233
244
  return {
234
245
  command: resolvedCommand ?? cmd,
@@ -244,6 +255,37 @@ function buildSpawnPlan({ cmd, args, cwd }) {
244
255
  };
245
256
  }
246
257
 
258
+ function isExplicitCmdExeCall(resolved, args) {
259
+ const comSpec = (process.env.ComSpec || 'cmd.exe').toLowerCase();
260
+ const name = resolved.toLowerCase();
261
+ if (name !== comSpec && name !== 'cmd' && name !== 'cmd.exe') return false;
262
+ return args.some((a) => a.toLowerCase() === '/c');
263
+ }
264
+
265
+ function buildCmdExePlan(args) {
266
+ const comSpec = process.env.ComSpec || 'cmd.exe';
267
+
268
+ // Collect any flags before /c (e.g. /d, /s) and the command body after /c.
269
+ const prefixFlags = [];
270
+ let commandBody = '';
271
+ let foundSlashC = false;
272
+ for (let i = 0; i < args.length; i++) {
273
+ if (!foundSlashC && args[i].toLowerCase() === '/c') {
274
+ foundSlashC = true;
275
+ // Everything after /c is the shell command – join into one string.
276
+ commandBody = args.slice(i + 1).join(' ');
277
+ break;
278
+ }
279
+ prefixFlags.push(args[i]);
280
+ }
281
+
282
+ return {
283
+ command: comSpec,
284
+ args: [...prefixFlags, '/s', '/c', `"${commandBody}"`],
285
+ windowsVerbatimArguments: true,
286
+ };
287
+ }
288
+
247
289
  function resolveWindowsCommand(cmd, cwd) {
248
290
  const pathExts = getWindowsPathExtensions();
249
291
  if (looksLikePath(cmd)) {
@@ -1,15 +1,24 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { stat } from 'node:fs/promises';
3
+ import { resolve as resolvePath } from 'node:path';
4
+ import { platform } from 'node:os';
2
5
  import { PtySession } from './pty-session.js';
3
- import { detectShell } from './shell-detector.js';
6
+ import { detectShell, isAvailable } from './shell-detector.js';
4
7
 
5
8
  const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 minutes
6
9
  const MAX_SESSIONS = 10;
7
10
  const CLEANUP_INTERVAL_MS = 60 * 1000; // Check every minute
11
+ const CWD_ERROR_MESSAGES = {
12
+ EACCES: 'Permission denied',
13
+ ENOENT: 'Path does not exist',
14
+ ENOTDIR: 'A component of the path is not a directory',
15
+ };
8
16
 
9
17
  export class SessionManager {
10
- constructor() {
18
+ constructor({ SessionClass = PtySession } = {}) {
11
19
  /** @type {Map<string, PtySession>} */
12
20
  this._sessions = new Map();
21
+ this._SessionClass = SessionClass;
13
22
  this._cleanupTimer = setInterval(() => this._cleanupExpired(), CLEANUP_INTERVAL_MS);
14
23
  // Don't keep process alive just for cleanup
15
24
  this._cleanupTimer.unref();
@@ -31,18 +40,19 @@ export class SessionManager {
31
40
  throw new Error(`Maximum ${MAX_SESSIONS} concurrent sessions reached. Stop an existing session first.`);
32
41
  }
33
42
 
43
+ const resolvedCwd = await resolveSessionCwd(cwd);
34
44
  const detected = detectShell();
35
- const resolvedShell = shell || detected.shell;
45
+ const resolvedShell = shell ? resolveUserShell(shell) : detected.shell;
36
46
  const shellArgs = shell ? [] : detected.args;
37
47
 
38
48
  const id = randomUUID().slice(0, 8);
39
- const session = new PtySession({
49
+ const session = new this._SessionClass({
40
50
  id,
41
51
  shell: resolvedShell,
42
52
  shellArgs,
43
53
  cols,
44
54
  rows,
45
- cwd: cwd || process.cwd(),
55
+ cwd: resolvedCwd,
46
56
  name,
47
57
  env,
48
58
  });
@@ -112,6 +122,57 @@ export class SessionManager {
112
122
  }
113
123
  }
114
124
 
125
+ const WINDOWS_SHELL_EXTENSIONS = ['.exe', '.cmd', '.bat'];
126
+
127
+ /**
128
+ * Validate and resolve a user-provided shell name.
129
+ * On Windows, tries appending common extensions if the bare name isn't found.
130
+ * @param {string} shell
131
+ * @returns {string} The resolved shell name
132
+ */
133
+ function resolveUserShell(shell) {
134
+ if (isAvailable(shell)) return shell;
135
+
136
+ // On Windows, try appending .exe etc. so "pwsh" resolves to "pwsh.exe"
137
+ if (platform() === 'win32' && !shell.includes('.')) {
138
+ for (const ext of WINDOWS_SHELL_EXTENSIONS) {
139
+ const candidate = `${shell}${ext}`;
140
+ if (isAvailable(candidate)) return candidate;
141
+ }
142
+ }
143
+
144
+ const detected = detectShell();
145
+ throw new Error(
146
+ `Shell "${shell}" not found. The auto-detected shell is "${detected.shell}". ` +
147
+ 'Omit the shell parameter to use auto-detection, or provide the full path to the shell executable.',
148
+ );
149
+ }
150
+
151
+ export async function resolveSessionCwd(cwd) {
152
+ const resolvedCwd = resolvePath(cwd ?? process.cwd());
153
+
154
+ let stats;
155
+ try {
156
+ stats = await stat(resolvedCwd);
157
+ } catch (err) {
158
+ throw new Error(`Invalid cwd "${resolvedCwd}": ${formatCwdError(err)}`);
159
+ }
160
+
161
+ if (!stats.isDirectory()) {
162
+ throw new Error(`Invalid cwd "${resolvedCwd}": Path is not a directory.`);
163
+ }
164
+
165
+ return resolvedCwd;
166
+ }
167
+
168
+ function formatCwdError(err) {
169
+ const hint = CWD_ERROR_MESSAGES[err?.code];
170
+ if (hint) {
171
+ return `${hint} (${err.code})`;
172
+ }
173
+ return err?.message ?? String(err);
174
+ }
175
+
115
176
  function log(msg) {
116
177
  process.stderr.write(`[smart-terminal-mcp] ${msg}\n`);
117
178
  }
@@ -6,7 +6,7 @@ import { platform } from 'node:os';
6
6
  * @param {string} exe
7
7
  * @returns {boolean}
8
8
  */
9
- function isAvailable(exe) {
9
+ export function isAvailable(exe) {
10
10
  try {
11
11
  const cmd = platform() === 'win32' ? 'where' : 'which';
12
12
  execFileSync(cmd, [exe], { stdio: 'ignore', timeout: 5000 });
package/src/tools.js CHANGED
@@ -69,15 +69,27 @@ export function registerTools(server, manager) {
69
69
  env: z.record(z.string()).optional().describe('Environment variables'),
70
70
  },
71
71
  async ({ shell, cols, rows, cwd, name, env }) => {
72
- const session = await manager.create({ shell, cols, rows, cwd, name, env });
73
- const banner = await session.waitForBanner();
74
- return jsonContent({
75
- sessionId: session.id,
76
- shell: session.shell,
77
- shellType: session.shellType,
78
- cwd: session.cwd,
79
- banner: banner || '(no banner)',
80
- });
72
+ let session;
73
+ try {
74
+ session = await manager.create({ shell, cols, rows, cwd, name, env });
75
+ const banner = await session.waitForBanner();
76
+ return jsonContent({
77
+ sessionId: session.id,
78
+ shell: session.shell,
79
+ shellType: session.shellType,
80
+ cwd: session.cwd,
81
+ banner: banner || '(no banner)',
82
+ });
83
+ } catch (err) {
84
+ if (session?.id && typeof manager.stop === 'function') {
85
+ try {
86
+ manager.stop(session.id);
87
+ } catch {
88
+ // Preserve the original startup failure.
89
+ }
90
+ }
91
+ throw err;
92
+ }
81
93
  }
82
94
  );
83
95
 
@@ -0,0 +1,89 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve as resolvePath } from 'node:path';
6
+ import { SessionManager, resolveSessionCwd } from '../src/session-manager.js';
7
+
8
+ test('resolveSessionCwd returns an absolute directory path', async () => {
9
+ const tempDir = await mkdtemp(join(tmpdir(), 'smart-terminal-mcp-'));
10
+
11
+ try {
12
+ const resolved = await resolveSessionCwd(tempDir);
13
+ assert.equal(resolved, resolvePath(tempDir));
14
+ } finally {
15
+ await rm(tempDir, { recursive: true, force: true });
16
+ }
17
+ });
18
+
19
+ test('SessionManager.create rejects invalid cwd before creating a session', async () => {
20
+ let constructed = 0;
21
+ class FakeSession {
22
+ constructor() {
23
+ constructed++;
24
+ }
25
+ }
26
+
27
+ const manager = new SessionManager({ SessionClass: FakeSession });
28
+ const missingDir = join(tmpdir(), `smart-terminal-mcp-missing-${Date.now()}`);
29
+
30
+ try {
31
+ await assert.rejects(
32
+ () => manager.create({ cwd: missingDir }),
33
+ (error) => {
34
+ assert.match(error.message, /^Invalid cwd ".+": Path does not exist \(ENOENT\)$/);
35
+ return true;
36
+ }
37
+ );
38
+ assert.equal(constructed, 0);
39
+ assert.equal(manager.list().length, 0);
40
+ } finally {
41
+ manager.destroyAll();
42
+ }
43
+ });
44
+
45
+ test('SessionManager.create rejects unknown shell before creating a session', async () => {
46
+ let constructed = 0;
47
+ class FakeSession {
48
+ constructor() {
49
+ constructed++;
50
+ }
51
+ }
52
+
53
+ const manager = new SessionManager({ SessionClass: FakeSession });
54
+
55
+ try {
56
+ await assert.rejects(
57
+ () => manager.create({ shell: 'nonexistent-shell-abc123' }),
58
+ (error) => {
59
+ assert.match(error.message, /Shell "nonexistent-shell-abc123" not found/);
60
+ return true;
61
+ }
62
+ );
63
+ assert.equal(constructed, 0);
64
+ assert.equal(manager.list().length, 0);
65
+ } finally {
66
+ manager.destroyAll();
67
+ }
68
+ });
69
+
70
+ test('SessionManager.create rejects file cwd values', async () => {
71
+ const tempDir = await mkdtemp(join(tmpdir(), 'smart-terminal-mcp-'));
72
+ const filePath = join(tempDir, 'not-a-directory.txt');
73
+ await writeFile(filePath, 'hello');
74
+
75
+ const manager = new SessionManager({
76
+ SessionClass: class FakeSession { },
77
+ });
78
+
79
+ try {
80
+ await assert.rejects(
81
+ () => manager.create({ cwd: filePath }),
82
+ /Path is not a directory/
83
+ );
84
+ assert.equal(manager.list().length, 0);
85
+ } finally {
86
+ manager.destroyAll();
87
+ await rm(tempDir, { recursive: true, force: true });
88
+ }
89
+ });
@@ -111,6 +111,66 @@ test('tool schemas keep agent-friendly default output sizes', () => {
111
111
  });
112
112
  });
113
113
 
114
+ test('terminal_start returns compact session metadata', async () => {
115
+ const server = createFakeServer();
116
+ const createCalls = [];
117
+ const manager = {
118
+ create: async (opts) => {
119
+ createCalls.push(opts);
120
+ return {
121
+ id: 's1',
122
+ shell: 'pwsh.exe',
123
+ shellType: 'powershell',
124
+ cwd: 'C:/repo',
125
+ waitForBanner: async () => 'PowerShell 7',
126
+ };
127
+ },
128
+ };
129
+
130
+ registerTools(server, manager);
131
+
132
+ const result = await server.tools.get('terminal_start').handler({
133
+ cols: 140,
134
+ rows: 40,
135
+ cwd: 'C:/repo',
136
+ name: 'smc-verify',
137
+ });
138
+
139
+ assert.deepEqual(createCalls, [{ cols: 140, rows: 40, cwd: 'C:/repo', name: 'smc-verify', shell: undefined, env: undefined }]);
140
+ assert.deepEqual(JSON.parse(result.content[0].text), {
141
+ sessionId: 's1',
142
+ shell: 'pwsh.exe',
143
+ shellType: 'powershell',
144
+ cwd: 'C:/repo',
145
+ banner: 'PowerShell 7',
146
+ });
147
+ });
148
+
149
+ test('terminal_start stops a created session when banner startup fails', async () => {
150
+ const server = createFakeServer();
151
+ const stopCalls = [];
152
+ const manager = {
153
+ create: async () => ({
154
+ id: 's1',
155
+ cwd: 'C:/repo',
156
+ waitForBanner: async () => {
157
+ throw new Error('banner failed');
158
+ },
159
+ }),
160
+ stop: (sessionId) => {
161
+ stopCalls.push(sessionId);
162
+ },
163
+ };
164
+
165
+ registerTools(server, manager);
166
+
167
+ await assert.rejects(
168
+ () => server.tools.get('terminal_start').handler({}),
169
+ /banner failed/
170
+ );
171
+ assert.deepEqual(stopCalls, ['s1']);
172
+ });
173
+
114
174
  test('terminal_run forwards summary mode for concise output', async () => {
115
175
  const server = createFakeServer();
116
176
  const lookupCommand = process.platform === 'win32' ? 'where' : 'which';