@prmichaelsen/acp-mcp 0.7.0 → 1.0.0

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.
@@ -1,6 +1,5 @@
1
- import { Client, SFTPWrapper } from 'ssh2';
1
+ import { Client } from 'ssh2';
2
2
  import { SSHConfig } from '../types/ssh-config.js';
3
- import { FileEntry, parsePermissions, getFileType } from '../types/file-entry.js';
4
3
  import { logger } from './logger.js';
5
4
 
6
5
  /**
@@ -106,10 +105,12 @@ export class SSHConnectionManager {
106
105
  }
107
106
 
108
107
  const startTime = Date.now();
109
- logger.sshCommand(command, undefined, timeoutSeconds);
108
+ // Wrap command to source shell config for proper PATH and environment
109
+ const wrappedCommand = this.wrapCommandWithShellInit(command);
110
+ logger.sshCommand(wrappedCommand, undefined, timeoutSeconds);
110
111
 
111
112
  const execPromise = new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve, reject) => {
112
- this.client.exec(command, (err, stream) => {
113
+ this.client.exec(wrappedCommand, (err, stream) => {
113
114
  if (err) {
114
115
  reject(err);
115
116
  return;
@@ -181,11 +182,13 @@ export class SSHConnectionManager {
181
182
  }
182
183
 
183
184
  const fullCommand = cwd ? `cd "${cwd}" && ${command}` : command;
185
+ // Wrap command to source shell config for proper PATH and environment
186
+ const wrappedCommand = this.wrapCommandWithShellInit(fullCommand);
184
187
  const startTime = Date.now();
185
- logger.sshCommand(fullCommand, cwd);
188
+ logger.sshCommand(wrappedCommand, cwd);
186
189
 
187
190
  return new Promise((resolve, reject) => {
188
- this.client.exec(fullCommand, (err, stream) => {
191
+ this.client.exec(wrappedCommand, (err, stream) => {
189
192
  if (err) {
190
193
  logger.error('SSH exec failed', {
191
194
  command: fullCommand,
@@ -227,305 +230,17 @@ export class SSHConnectionManager {
227
230
  }
228
231
 
229
232
  /**
230
- * Get SFTP wrapper for file operations
231
- */
232
- async getSFTP(): Promise<SFTPWrapper> {
233
- if (!this.connected) {
234
- await this.connect();
235
- }
236
-
237
- return new Promise((resolve, reject) => {
238
- this.client.sftp((err, sftp) => {
239
- if (err) {
240
- reject(err);
241
- } else {
242
- resolve(sftp);
243
- }
244
- });
245
- });
246
- }
247
-
248
- /**
249
- * List files in a directory with comprehensive metadata
250
- * Uses hybrid approach: shell ls for filenames (includes hidden), SFTP stat for metadata
233
+ * Wrap command to source shell configuration files
234
+ * This ensures PATH and other environment variables are properly set
235
+ * SSH non-interactive shells don't source ~/.bashrc or ~/.zshrc by default
251
236
  *
252
- * @param path - Directory path to list
253
- * @param includeHidden - Whether to include hidden files (default: true)
254
- * @returns Array of FileEntry objects with complete metadata
237
+ * @param command - The command to wrap
238
+ * @returns Wrapped command that sources shell config first
255
239
  */
256
- async listFiles(path: string, includeHidden: boolean = true): Promise<FileEntry[]> {
257
- const startTime = Date.now();
258
- logger.debug('Listing files', { path, includeHidden });
259
-
260
- try {
261
- // Step 1: Use shell command to get ALL filenames (including hidden)
262
- const lsFlag = includeHidden ? '-A' : '';
263
- const command = `ls ${lsFlag} -1 "${path}" 2>/dev/null`;
264
- const result = await this.execWithTimeout(command, 10);
265
-
266
- if (result.exitCode !== 0) {
267
- throw new Error(`ls command failed: ${result.stderr}`);
268
- }
269
-
270
- const filenames = result.stdout
271
- .split('\n')
272
- .map(f => f.trim())
273
- .filter(f => f !== '' && f !== '.' && f !== '..');
274
-
275
- logger.debug('Filenames retrieved via shell', {
276
- path,
277
- count: filenames.length,
278
- method: 'shell',
279
- });
280
-
281
- // Step 2: Get rich metadata for each file using SFTP stat()
282
- const sftp = await this.getSFTP();
283
- const entries: FileEntry[] = [];
284
-
285
- for (const filename of filenames) {
286
- const fullPath = `${path}/${filename}`.replace(/\/+/g, '/');
287
-
288
- try {
289
- const stats = await new Promise<any>((resolve, reject) => {
290
- sftp.stat(fullPath, (err, stats) => {
291
- if (err) reject(err);
292
- else resolve(stats);
293
- });
294
- });
295
-
296
- entries.push({
297
- name: filename,
298
- path: fullPath,
299
- type: getFileType(stats),
300
- size: stats.size,
301
- permissions: parsePermissions(stats.mode),
302
- owner: {
303
- uid: stats.uid,
304
- gid: stats.gid,
305
- },
306
- timestamps: {
307
- accessed: new Date(stats.atime * 1000).toISOString(),
308
- modified: new Date(stats.mtime * 1000).toISOString(),
309
- },
310
- });
311
- } catch (error) {
312
- // Skip files we can't stat (permissions, race conditions, etc.)
313
- logger.warn('Failed to stat file, skipping', {
314
- path: fullPath,
315
- error: error instanceof Error ? error.message : String(error),
316
- });
317
- }
318
- }
319
-
320
- const duration = Date.now() - startTime;
321
- logger.debug('Files listed successfully', {
322
- path,
323
- count: entries.length,
324
- duration: `${duration}ms`,
325
- method: 'hybrid',
326
- });
327
-
328
- return entries;
329
- } catch (error) {
330
- // Fallback to SFTP readdir if shell command fails
331
- logger.warn('Shell ls command failed, falling back to SFTP readdir', {
332
- path,
333
- error: error instanceof Error ? error.message : String(error),
334
- });
335
-
336
- return this.listFilesViaSFTP(path, includeHidden);
337
- }
338
- }
339
-
340
- /**
341
- * Fallback method: List files using SFTP readdir (may miss hidden files)
342
- * @private
343
- */
344
- private async listFilesViaSFTP(path: string, includeHidden: boolean): Promise<FileEntry[]> {
345
- const sftp = await this.getSFTP();
346
-
347
- return new Promise((resolve, reject) => {
348
- sftp.readdir(path, (err, list) => {
349
- if (err) {
350
- logger.error('SFTP readdir failed', { path, error: err.message });
351
- reject(err);
352
- return;
353
- }
354
-
355
- let entries = list.map((item) => ({
356
- name: item.filename,
357
- path: `${path}/${item.filename}`.replace(/\/+/g, '/'),
358
- type: getFileType(item.attrs),
359
- size: item.attrs.size,
360
- permissions: parsePermissions(item.attrs.mode),
361
- owner: {
362
- uid: item.attrs.uid,
363
- gid: item.attrs.gid,
364
- },
365
- timestamps: {
366
- accessed: new Date(item.attrs.atime * 1000).toISOString(),
367
- modified: new Date(item.attrs.mtime * 1000).toISOString(),
368
- },
369
- }));
370
-
371
- // SFTP readdir doesn't return hidden files, so filter if requested
372
- if (!includeHidden) {
373
- entries = entries.filter(e => !e.name.startsWith('.'));
374
- }
375
-
376
- logger.debug('Files listed via SFTP fallback', {
377
- path,
378
- count: entries.length,
379
- method: 'sftp',
380
- note: 'Hidden files may be missing (SFTP limitation)',
381
- });
382
-
383
- resolve(entries);
384
- });
385
- });
386
- }
387
-
388
- /**
389
- * Read file contents from remote machine
390
- */
391
- async readFile(
392
- path: string,
393
- encoding: string = 'utf-8',
394
- maxSize: number = 1048576
395
- ): Promise<{ content: string; size: number; encoding: string }> {
396
- const startTime = Date.now();
397
- logger.fileOperation('read', path, { encoding, maxSize });
398
-
399
- const sftp = await this.getSFTP();
400
-
401
- return new Promise((resolve, reject) => {
402
- // First, get file stats to check size
403
- sftp.stat(path, (err, stats) => {
404
- if (err) {
405
- logger.error('File stat failed', { path, error: err.message });
406
- reject(new Error(`File not found or inaccessible: ${path}`));
407
- return;
408
- }
409
-
410
- logger.debug('File stat retrieved', { path, size: stats.size });
411
-
412
- if (stats.size > maxSize) {
413
- logger.warn('File too large', { path, size: stats.size, maxSize });
414
- reject(new Error(`File too large: ${stats.size} bytes (max: ${maxSize} bytes)`));
415
- return;
416
- }
417
-
418
- // Read file contents
419
- sftp.readFile(path, { encoding: encoding as BufferEncoding }, (err, data) => {
420
- if (err) {
421
- logger.error('File read failed', { path, error: err.message });
422
- reject(new Error(`Failed to read file: ${err.message}`));
423
- return;
424
- }
425
-
426
- const duration = Date.now() - startTime;
427
- logger.debug('File read completed', { path, size: stats.size, duration: `${duration}ms` });
428
-
429
- resolve({
430
- content: data.toString(),
431
- size: stats.size,
432
- encoding,
433
- });
434
- });
435
- });
436
- });
437
- }
438
-
439
- /**
440
- * Write file contents to remote machine
441
- */
442
- async writeFile(
443
- path: string,
444
- content: string,
445
- options: {
446
- encoding?: string;
447
- createDirs?: boolean;
448
- backup?: boolean;
449
- } = {}
450
- ): Promise<{ success: boolean; bytesWritten: number; backupPath?: string }> {
451
- const { encoding = 'utf-8', createDirs = false, backup = false } = options;
452
- const startTime = Date.now();
453
-
454
- logger.fileOperation('write', path, {
455
- contentSize: content.length,
456
- encoding,
457
- createDirs,
458
- backup,
459
- });
460
-
461
- const sftp = await this.getSFTP();
462
-
463
- return new Promise((resolve, reject) => {
464
- const writeOperation = () => {
465
- // Create backup if requested
466
- if (backup) {
467
- const backupPath = `${path}.backup`;
468
- sftp.rename(path, backupPath, (err) => {
469
- if (err && err.message !== 'No such file') {
470
- // Ignore "no such file" error (file doesn't exist yet)
471
- reject(new Error(`Failed to create backup: ${err.message}`));
472
- return;
473
- }
474
-
475
- // Write file
476
- performWrite(backupPath);
477
- });
478
- } else {
479
- performWrite();
480
- }
481
- };
482
-
483
- const performWrite = (backupPath?: string) => {
484
- const buffer = Buffer.from(content, encoding as BufferEncoding);
485
- const tempPath = `${path}.tmp`;
486
-
487
- // Write to temp file first (atomic write)
488
- sftp.writeFile(tempPath, buffer, (err) => {
489
- if (err) {
490
- reject(new Error(`Failed to write file: ${err.message}`));
491
- return;
492
- }
493
-
494
- // Rename temp file to target (atomic operation)
495
- sftp.rename(tempPath, path, (err) => {
496
- if (err) {
497
- logger.error('File rename failed', { tempPath, path, error: err.message });
498
- reject(new Error(`Failed to rename temp file: ${err.message}`));
499
- return;
500
- }
501
-
502
- const duration = Date.now() - startTime;
503
- logger.debug('File write completed', {
504
- path,
505
- bytesWritten: buffer.length,
506
- duration: `${duration}ms`,
507
- backupPath,
508
- });
509
-
510
- resolve({
511
- success: true,
512
- bytesWritten: buffer.length,
513
- backupPath,
514
- });
515
- });
516
- });
517
- };
518
-
519
- // Create parent directories if requested
520
- if (createDirs) {
521
- const dirPath = path.substring(0, path.lastIndexOf('/'));
522
- this.exec(`mkdir -p ${dirPath}`).then(() => {
523
- writeOperation();
524
- }).catch(reject);
525
- } else {
526
- writeOperation();
527
- }
528
- });
240
+ private wrapCommandWithShellInit(command: string): string {
241
+ // Try to source common shell config files
242
+ // Use || true to ignore errors if files don't exist
243
+ return `(source ~/.zshrc 2>/dev/null || source ~/.bashrc 2>/dev/null || source ~/.profile 2>/dev/null || true) && ${command}`;
529
244
  }
530
245
 
531
246
  /**
@@ -1,16 +0,0 @@
1
- import { Tool } from '@modelcontextprotocol/sdk/types.js';
2
- import { SSHConnectionManager } from '../utils/ssh-connection.js';
3
- export declare const acpRemoteListFilesTool: Tool;
4
- /**
5
- * Handle the acp_remote_list_files tool invocation
6
- * Lists files and directories at the specified path on the remote machine via SSH
7
- *
8
- * @param args - Tool arguments containing path and recursive flag
9
- * @param sshConnection - SSH connection manager for remote operations
10
- */
11
- export declare function handleAcpRemoteListFiles(args: any, sshConnection: SSHConnectionManager): Promise<{
12
- content: Array<{
13
- type: string;
14
- text: string;
15
- }>;
16
- }>;
@@ -1,16 +0,0 @@
1
- import { Tool } from '@modelcontextprotocol/sdk/types.js';
2
- import { SSHConnectionManager } from '../utils/ssh-connection.js';
3
- export declare const acpRemoteReadFileTool: Tool;
4
- /**
5
- * Handle the acp_remote_read_file tool invocation
6
- * Reads file contents from the remote machine via SSH
7
- *
8
- * @param args - Tool arguments containing path, encoding, and maxSize
9
- * @param sshConnection - SSH connection manager for remote operations
10
- */
11
- export declare function handleAcpRemoteReadFile(args: any, sshConnection: SSHConnectionManager): Promise<{
12
- content: Array<{
13
- type: string;
14
- text: string;
15
- }>;
16
- }>;
@@ -1,16 +0,0 @@
1
- import { Tool } from '@modelcontextprotocol/sdk/types.js';
2
- import { SSHConnectionManager } from '../utils/ssh-connection.js';
3
- export declare const acpRemoteWriteFileTool: Tool;
4
- /**
5
- * Handle the acp_remote_write_file tool invocation
6
- * Writes file contents to the remote machine via SSH
7
- *
8
- * @param args - Tool arguments containing path, content, and options
9
- * @param sshConnection - SSH connection manager for remote operations
10
- */
11
- export declare function handleAcpRemoteWriteFile(args: any, sshConnection: SSHConnectionManager): Promise<{
12
- content: Array<{
13
- type: string;
14
- text: string;
15
- }>;
16
- }>;
@@ -1,88 +0,0 @@
1
- /**
2
- * Comprehensive file entry with metadata
3
- */
4
- export interface FileEntry {
5
- /** Filename (without path) */
6
- name: string;
7
- /** Absolute path to file */
8
- path: string;
9
- /** File type */
10
- type: 'file' | 'directory' | 'symlink' | 'other';
11
- /** File size in bytes */
12
- size: number;
13
- /** File permissions */
14
- permissions: {
15
- /** Octal mode (e.g., 0o644) */
16
- mode: number;
17
- /** Human-readable string (e.g., "rw-r--r--") */
18
- string: string;
19
- /** Owner permissions */
20
- owner: {
21
- read: boolean;
22
- write: boolean;
23
- execute: boolean;
24
- };
25
- /** Group permissions */
26
- group: {
27
- read: boolean;
28
- write: boolean;
29
- execute: boolean;
30
- };
31
- /** Others permissions */
32
- others: {
33
- read: boolean;
34
- write: boolean;
35
- execute: boolean;
36
- };
37
- };
38
- /** File ownership */
39
- owner: {
40
- /** User ID */
41
- uid: number;
42
- /** Group ID */
43
- gid: number;
44
- };
45
- /** File timestamps */
46
- timestamps: {
47
- /** Last access time (ISO 8601) */
48
- accessed: string;
49
- /** Last modification time (ISO 8601) */
50
- modified: string;
51
- };
52
- }
53
- /**
54
- * Convert Unix mode to human-readable permission string
55
- * @param mode - Unix file mode (e.g., 33188 for -rw-r--r--)
56
- * @returns Permission string (e.g., "rw-r--r--")
57
- */
58
- export declare function modeToPermissionString(mode: number): string;
59
- /**
60
- * Parse Unix mode into structured permissions object
61
- * @param mode - Unix file mode
62
- * @returns Structured permissions object
63
- */
64
- export declare function parsePermissions(mode: number): {
65
- mode: number;
66
- string: string;
67
- owner: {
68
- read: boolean;
69
- write: boolean;
70
- execute: boolean;
71
- };
72
- group: {
73
- read: boolean;
74
- write: boolean;
75
- execute: boolean;
76
- };
77
- others: {
78
- read: boolean;
79
- write: boolean;
80
- execute: boolean;
81
- };
82
- };
83
- /**
84
- * Determine file type from SFTP stats
85
- * @param stats - SFTP file stats
86
- * @returns File type string
87
- */
88
- export declare function getFileType(stats: any): 'file' | 'directory' | 'symlink' | 'other';
@@ -1,100 +0,0 @@
1
- import { Tool } from '@modelcontextprotocol/sdk/types.js';
2
- import { SSHConnectionManager } from '../utils/ssh-connection.js';
3
- import { FileEntry } from '../types/file-entry.js';
4
-
5
- export const acpRemoteListFilesTool: Tool = {
6
- name: 'acp_remote_list_files',
7
- description: 'List files and directories in a specified path on the remote machine via SSH. Returns comprehensive metadata including permissions, timestamps, size, and ownership. Includes hidden files by default.',
8
- inputSchema: {
9
- type: 'object',
10
- properties: {
11
- path: {
12
- type: 'string',
13
- description: 'The directory path to list files from',
14
- },
15
- recursive: {
16
- type: 'boolean',
17
- description: 'Whether to list files recursively',
18
- default: false,
19
- },
20
- includeHidden: {
21
- type: 'boolean',
22
- description: 'Whether to include hidden files (starting with .)',
23
- default: true,
24
- },
25
- },
26
- required: ['path'],
27
- },
28
- };
29
-
30
- interface ListFilesArgs {
31
- path: string;
32
- recursive?: boolean;
33
- includeHidden?: boolean;
34
- }
35
-
36
- /**
37
- * Handle the acp_remote_list_files tool invocation
38
- * Lists files and directories at the specified path on the remote machine via SSH
39
- *
40
- * @param args - Tool arguments containing path and recursive flag
41
- * @param sshConnection - SSH connection manager for remote operations
42
- */
43
- export async function handleAcpRemoteListFiles(
44
- args: any,
45
- sshConnection: SSHConnectionManager
46
- ): Promise<{ content: Array<{ type: string; text: string }> }> {
47
- const { path, recursive = false, includeHidden = true } = args as ListFilesArgs;
48
-
49
- try {
50
- const entries = await listRemoteFiles(sshConnection, path, recursive, includeHidden);
51
-
52
- // Format as JSON for structured output
53
- const output = JSON.stringify(entries, null, 2);
54
-
55
- return {
56
- content: [
57
- {
58
- type: 'text',
59
- text: output,
60
- },
61
- ],
62
- };
63
- } catch (error) {
64
- const errorMessage = error instanceof Error ? error.message : String(error);
65
- return {
66
- content: [
67
- {
68
- type: 'text',
69
- text: `Error listing remote files: ${errorMessage}`,
70
- },
71
- ],
72
- };
73
- }
74
- }
75
-
76
- /**
77
- * Recursively list files in a remote directory via SSH
78
- * Returns FileEntry objects with comprehensive metadata
79
- */
80
- async function listRemoteFiles(
81
- ssh: SSHConnectionManager,
82
- dirPath: string,
83
- recursive: boolean,
84
- includeHidden: boolean
85
- ): Promise<FileEntry[]> {
86
- const entries = await ssh.listFiles(dirPath, includeHidden);
87
- const allEntries: FileEntry[] = [...entries];
88
-
89
- // Recursively list subdirectories if requested
90
- if (recursive) {
91
- for (const entry of entries) {
92
- if (entry.type === 'directory') {
93
- const subEntries = await listRemoteFiles(ssh, entry.path, recursive, includeHidden);
94
- allEntries.push(...subEntries);
95
- }
96
- }
97
- }
98
-
99
- return allEntries;
100
- }
@@ -1,94 +0,0 @@
1
- import { Tool } from '@modelcontextprotocol/sdk/types.js';
2
- import { SSHConnectionManager } from '../utils/ssh-connection.js';
3
- import { logger } from '../utils/logger.js';
4
-
5
- export const acpRemoteReadFileTool: Tool = {
6
- name: 'acp_remote_read_file',
7
- description: 'Read file contents from the remote machine via SSH',
8
- inputSchema: {
9
- type: 'object',
10
- properties: {
11
- path: {
12
- type: 'string',
13
- description: 'Absolute path to file',
14
- },
15
- encoding: {
16
- type: 'string',
17
- description: 'File encoding (default: utf-8)',
18
- default: 'utf-8',
19
- enum: ['utf-8', 'ascii', 'base64'],
20
- },
21
- maxSize: {
22
- type: 'number',
23
- description: 'Max file size in bytes (default: 1MB)',
24
- default: 1048576,
25
- },
26
- },
27
- required: ['path'],
28
- },
29
- };
30
-
31
- interface ReadFileArgs {
32
- path: string;
33
- encoding?: string;
34
- maxSize?: number;
35
- }
36
-
37
- interface ReadFileResult {
38
- content: string;
39
- size: number;
40
- encoding: string;
41
- }
42
-
43
- /**
44
- * Handle the acp_remote_read_file tool invocation
45
- * Reads file contents from the remote machine via SSH
46
- *
47
- * @param args - Tool arguments containing path, encoding, and maxSize
48
- * @param sshConnection - SSH connection manager for remote operations
49
- */
50
- export async function handleAcpRemoteReadFile(
51
- args: any,
52
- sshConnection: SSHConnectionManager
53
- ): Promise<{ content: Array<{ type: string; text: string }> }> {
54
- const { path, encoding = 'utf-8', maxSize = 1048576 } = args as ReadFileArgs;
55
-
56
- logger.debug('Reading remote file', { path, encoding, maxSize });
57
-
58
- try {
59
- const result = await sshConnection.readFile(path, encoding, maxSize);
60
-
61
- logger.debug('File read successful', { path, size: result.size });
62
-
63
- const output: ReadFileResult = {
64
- content: result.content,
65
- size: result.size,
66
- encoding: result.encoding,
67
- };
68
-
69
- return {
70
- content: [
71
- {
72
- type: 'text',
73
- text: JSON.stringify(output, null, 2),
74
- },
75
- ],
76
- };
77
- } catch (error) {
78
- const errorMessage = error instanceof Error ? error.message : String(error);
79
- logger.error('File read error', { path, error: errorMessage });
80
- return {
81
- content: [
82
- {
83
- type: 'text',
84
- text: JSON.stringify({
85
- error: errorMessage,
86
- content: '',
87
- size: 0,
88
- encoding,
89
- }, null, 2),
90
- },
91
- ],
92
- };
93
- }
94
- }