shmakk 1.2.1 → 1.2.3
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/README.md +40 -0
- package/package.json +1 -1
- package/src/agent.js +54 -9
- package/src/cli.js +193 -78
- package/src/correction.js +6 -0
- package/src/endpoints.js +6 -0
- package/src/hooks/bash.js +17 -2
- package/src/hooks/fish.js +21 -2
- package/src/hooks/zsh.js +31 -2
- package/src/index.js +11 -2
- package/src/llm.js +2 -2
- package/src/mcp-client.js +7 -1
- package/src/notify.js +6 -3
- package/src/pty.js +2 -2
- package/src/review.js +3 -3
- package/src/self-commands.js +96 -16
- package/src/session.js +14 -5
- package/src/shell.js +39 -19
- package/src/ssh.js +255 -0
- package/src/system-prompt.js +3 -1
- package/src/tools.js +105 -1
package/src/ssh.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// SSH remote execution and file transfer for shmakk.
|
|
2
|
+
//
|
|
3
|
+
// Hosts are defined in .shmakk/hosts.json (project-local) or
|
|
4
|
+
// ~/.config/shmakk/hosts.json (global). If a host config has
|
|
5
|
+
// allow_ssh_config: true, ~/.ssh/config Host entries are also
|
|
6
|
+
// available as targets.
|
|
7
|
+
//
|
|
8
|
+
// Schema (hosts.json):
|
|
9
|
+
// {
|
|
10
|
+
// "hosts": {
|
|
11
|
+
// "devbox": {
|
|
12
|
+
// "host": "user@192.168.1.100",
|
|
13
|
+
// "port": 22,
|
|
14
|
+
// "auto_approve": false,
|
|
15
|
+
// "timeout_sec": 30
|
|
16
|
+
// }
|
|
17
|
+
// },
|
|
18
|
+
// "allow_ssh_config": false,
|
|
19
|
+
// "default_timeout_sec": 30
|
|
20
|
+
// }
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const os = require('os');
|
|
25
|
+
const { execFile } = require('child_process');
|
|
26
|
+
|
|
27
|
+
// ── Config loading ─────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function loadHostConfig(workspaceRoot) {
|
|
30
|
+
const candidates = [];
|
|
31
|
+
// Project-local config
|
|
32
|
+
if (workspaceRoot) {
|
|
33
|
+
candidates.push(path.join(workspaceRoot, '.shmakk', 'hosts.json'));
|
|
34
|
+
}
|
|
35
|
+
// Global config
|
|
36
|
+
candidates.push(path.join(os.homedir(), '.config', 'shmakk', 'hosts.json'));
|
|
37
|
+
|
|
38
|
+
let merged = { hosts: {}, allow_ssh_config: false, default_timeout_sec: 30 };
|
|
39
|
+
|
|
40
|
+
for (const p of candidates) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
43
|
+
const cfg = JSON.parse(raw);
|
|
44
|
+
if (cfg.hosts && typeof cfg.hosts === 'object') {
|
|
45
|
+
Object.assign(merged.hosts, cfg.hosts);
|
|
46
|
+
}
|
|
47
|
+
if (typeof cfg.allow_ssh_config === 'boolean') {
|
|
48
|
+
merged.allow_ssh_config = merged.allow_ssh_config || cfg.allow_ssh_config;
|
|
49
|
+
}
|
|
50
|
+
if (typeof cfg.default_timeout_sec === 'number') {
|
|
51
|
+
merged.default_timeout_sec = cfg.default_timeout_sec;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// Missing or malformed — skip
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Optionally import ~/.ssh/config hosts
|
|
59
|
+
if (merged.allow_ssh_config) {
|
|
60
|
+
const sshConfigHosts = parseSSHConfig();
|
|
61
|
+
for (const [name, entry] of Object.entries(sshConfigHosts)) {
|
|
62
|
+
if (!merged.hosts[name]) {
|
|
63
|
+
merged.hosts[name] = entry;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return merged;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseSSHConfig() {
|
|
72
|
+
const configPath = path.join(os.homedir(), '.ssh', 'config');
|
|
73
|
+
const hosts = {};
|
|
74
|
+
try {
|
|
75
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
76
|
+
const lines = content.split(/\r?\n/);
|
|
77
|
+
let currentHost = null;
|
|
78
|
+
let currentEntry = null;
|
|
79
|
+
|
|
80
|
+
for (const raw of lines) {
|
|
81
|
+
const line = raw.trim();
|
|
82
|
+
if (!line || line.startsWith('#')) continue;
|
|
83
|
+
|
|
84
|
+
const m = line.match(/^(\S+)\s+(.+)$/);
|
|
85
|
+
if (!m) continue;
|
|
86
|
+
|
|
87
|
+
const key = m[1].toLowerCase();
|
|
88
|
+
const value = m[2].trim();
|
|
89
|
+
|
|
90
|
+
if (key === 'host') {
|
|
91
|
+
// Save previous entry
|
|
92
|
+
if (currentHost && currentEntry) {
|
|
93
|
+
currentEntry._aliases = currentHost.split(/\s+/);
|
|
94
|
+
for (const alias of currentEntry._aliases) {
|
|
95
|
+
if (alias !== '*') hosts[alias] = currentEntry;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
currentHost = value;
|
|
99
|
+
currentEntry = { host: null, port: 22, auto_approve: false, _from_ssh_config: true };
|
|
100
|
+
} else if (currentEntry) {
|
|
101
|
+
if (key === 'hostname') currentEntry.host = value;
|
|
102
|
+
else if (key === 'port') currentEntry.port = parseInt(value, 10) || 22;
|
|
103
|
+
else if (key === 'user') {
|
|
104
|
+
// Merge user into the host string
|
|
105
|
+
const h = currentEntry.host || value;
|
|
106
|
+
currentEntry.host = `${value}@${h.replace(/^[^@]+@/, '')}`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Save last entry
|
|
111
|
+
if (currentHost && currentEntry) {
|
|
112
|
+
currentEntry._aliases = currentHost.split(/\s+/);
|
|
113
|
+
for (const alias of currentEntry._aliases) {
|
|
114
|
+
if (alias !== '*') hosts[alias] = currentEntry;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// No config or unreadable
|
|
119
|
+
}
|
|
120
|
+
return hosts;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function resolveHost(cfg, name) {
|
|
124
|
+
const entry = cfg.hosts[name];
|
|
125
|
+
if (!entry) return null;
|
|
126
|
+
if (!entry.host) return null;
|
|
127
|
+
return entry;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── SSH command builder ─────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
function buildSSHArgs(entry, cmd) {
|
|
133
|
+
const args = ['ssh'];
|
|
134
|
+
if (entry.port && entry.port !== 22) {
|
|
135
|
+
args.push('-p', String(entry.port));
|
|
136
|
+
}
|
|
137
|
+
// Common options for non-interactive remote execution
|
|
138
|
+
args.push('-o', 'BatchMode=yes');
|
|
139
|
+
args.push('-o', 'StrictHostKeyChecking=accept-new');
|
|
140
|
+
args.push('-o', 'ConnectTimeout=10');
|
|
141
|
+
args.push(entry.host);
|
|
142
|
+
args.push(cmd);
|
|
143
|
+
return args;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildSCPArgs(entry, src, dest, direction) {
|
|
147
|
+
// direction: 'push' → local src → remote dest
|
|
148
|
+
// 'pull' → remote src → local dest
|
|
149
|
+
const args = ['scp'];
|
|
150
|
+
if (entry.port && entry.port !== 22) {
|
|
151
|
+
args.push('-P', String(entry.port));
|
|
152
|
+
}
|
|
153
|
+
args.push('-o', 'BatchMode=yes');
|
|
154
|
+
args.push('-o', 'StrictHostKeyChecking=accept-new');
|
|
155
|
+
args.push('-o', 'ConnectTimeout=10');
|
|
156
|
+
|
|
157
|
+
if (direction === 'push') {
|
|
158
|
+
args.push(src, `${entry.host}:${dest}`);
|
|
159
|
+
} else {
|
|
160
|
+
args.push(`${entry.host}:${src}`, dest);
|
|
161
|
+
}
|
|
162
|
+
return args;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Execution ───────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
function sshRun(entry, cmd, signal) {
|
|
168
|
+
const timeout = (entry.timeout_sec || 30) * 1000;
|
|
169
|
+
const args = buildSSHArgs(entry, cmd);
|
|
170
|
+
|
|
171
|
+
return new Promise((resolve) => {
|
|
172
|
+
const child = execFile('ssh', args, {
|
|
173
|
+
timeout,
|
|
174
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
175
|
+
}, (err, stdout, stderr) => {
|
|
176
|
+
if (err) {
|
|
177
|
+
const msg = (stderr || '').toString().trim() || err.message;
|
|
178
|
+
// Distinguish known SSH errors
|
|
179
|
+
if (err.killed) {
|
|
180
|
+
resolve({ error: `SSH timed out after ${timeout / 1000}s`, exitCode: null, stderr: msg });
|
|
181
|
+
} else {
|
|
182
|
+
resolve({
|
|
183
|
+
error: `SSH failed (exit ${err.code}): ${msg}`,
|
|
184
|
+
exitCode: err.code,
|
|
185
|
+
stderr: msg,
|
|
186
|
+
stdout: (stdout || '').toString().trim(),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
resolve({
|
|
192
|
+
ok: true,
|
|
193
|
+
stdout: (stdout || '').toString().trim(),
|
|
194
|
+
stderr: (stderr || '').toString().trim(),
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
if (signal) {
|
|
199
|
+
const onAbort = () => { try { child.kill('SIGINT'); } catch {} };
|
|
200
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function sshTransfer(entry, src, dest, direction, signal) {
|
|
206
|
+
const timeout = (entry.timeout_sec || 60) * 1000;
|
|
207
|
+
const args = buildSCPArgs(entry, src, dest, direction);
|
|
208
|
+
|
|
209
|
+
return new Promise((resolve) => {
|
|
210
|
+
const child = execFile('scp', args, {
|
|
211
|
+
timeout,
|
|
212
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
213
|
+
}, (err, stdout, stderr) => {
|
|
214
|
+
if (err) {
|
|
215
|
+
const msg = (stderr || '').toString().trim() || err.message;
|
|
216
|
+
if (err.killed) {
|
|
217
|
+
resolve({ error: `SCP timed out after ${timeout / 1000}s`, stderr: msg });
|
|
218
|
+
} else {
|
|
219
|
+
resolve({
|
|
220
|
+
error: `SCP failed (exit ${err.code}): ${msg}`,
|
|
221
|
+
exitCode: err.code,
|
|
222
|
+
stderr: msg,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
resolve({ ok: true, stdout: (stdout || '').toString().trim() });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (signal) {
|
|
231
|
+
const onAbort = () => { try { child.kill('SIGINT'); } catch {} };
|
|
232
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── Host listing ────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
function listHosts(cfg) {
|
|
240
|
+
return Object.entries(cfg.hosts).map(([name, entry]) => ({
|
|
241
|
+
name,
|
|
242
|
+
host: entry.host,
|
|
243
|
+
port: entry.port || 22,
|
|
244
|
+
auto_approve: !!entry.auto_approve,
|
|
245
|
+
from_ssh_config: !!entry._from_ssh_config,
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
module.exports = {
|
|
250
|
+
loadHostConfig,
|
|
251
|
+
resolveHost,
|
|
252
|
+
sshRun,
|
|
253
|
+
sshTransfer,
|
|
254
|
+
listHosts,
|
|
255
|
+
};
|
package/src/system-prompt.js
CHANGED
|
@@ -15,6 +15,7 @@ function buildSystemPrompt({
|
|
|
15
15
|
mcpToolHint = null,
|
|
16
16
|
userRulesText = null,
|
|
17
17
|
userMemoryText = null,
|
|
18
|
+
supportsVision = false,
|
|
18
19
|
}) {
|
|
19
20
|
return `You are an expert AI coding assistant running inside shmakk.
|
|
20
21
|
|
|
@@ -65,12 +66,13 @@ Tool Call Format:
|
|
|
65
66
|
|
|
66
67
|
Available Tools:
|
|
67
68
|
- list_dir: list files/directories
|
|
68
|
-
- read_file: read file contents
|
|
69
|
+
- read_file: read file contents (text or images)
|
|
69
70
|
- write_file: create or overwrite a file
|
|
70
71
|
- make_dir: create a directory
|
|
71
72
|
- run: execute shell commands
|
|
72
73
|
- web_search: search the web
|
|
73
74
|
- fetch_url: fetch a URL
|
|
75
|
+
${supportsVision ? '\nIf images are included in tool results (e.g. read_file on a PNG, or MCP tools that return visuals), you can describe and analyze what you see in them.\n' : ''}
|
|
74
76
|
|
|
75
77
|
Path Rules:
|
|
76
78
|
- Always use relative paths resolved against \`${roots[0]}\`.
|
package/src/tools.js
CHANGED
|
@@ -14,6 +14,14 @@ const https = require('https');
|
|
|
14
14
|
const http = require('http');
|
|
15
15
|
const os = require('os');
|
|
16
16
|
|
|
17
|
+
// Lazy-load SSH (optional — only required when ssh_* tools are called).
|
|
18
|
+
let _ssh = null;
|
|
19
|
+
function _getSSH(roots) {
|
|
20
|
+
if (_ssh) return _ssh;
|
|
21
|
+
try { _ssh = require('./ssh'); } catch (e) { return null; }
|
|
22
|
+
return _ssh;
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
// Lazy-load TTS (kokoro-js is an optional dep; only required when
|
|
18
26
|
// tts_generate is actually called).
|
|
19
27
|
let _ttsGenerate = null;
|
|
@@ -73,7 +81,7 @@ function within(roots, p) {
|
|
|
73
81
|
const TOOLS = [
|
|
74
82
|
{ type: 'function', function: {
|
|
75
83
|
name: 'read_file',
|
|
76
|
-
description: 'Read a
|
|
84
|
+
description: 'Read a file inside the workspace. Text files support partial reads (head, tail, grep, imports, exports, symbol). Image files (.png/.jpg/.gif/.webp/.bmp/.svg) are returned as base64 for vision analysis.',
|
|
77
85
|
parameters: {
|
|
78
86
|
type: 'object',
|
|
79
87
|
required: ['path'],
|
|
@@ -229,6 +237,44 @@ const TOOLS = [
|
|
|
229
237
|
},
|
|
230
238
|
},
|
|
231
239
|
}},
|
|
240
|
+
{ type: 'function', function: {
|
|
241
|
+
name: 'ssh_run',
|
|
242
|
+
description: 'Run a shell command on a pre-configured remote host via SSH. Hosts are defined in .shmakk/hosts.json or ~/.config/shmakk/hosts.json. Output is captured.',
|
|
243
|
+
parameters: {
|
|
244
|
+
type: 'object',
|
|
245
|
+
required: ['host', 'cmd'],
|
|
246
|
+
properties: {
|
|
247
|
+
host: { type: 'string', description: 'Host alias as defined in hosts.json (e.g. "devbox")' },
|
|
248
|
+
cmd: { type: 'string', description: 'Shell command to run on the remote host' },
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
}},
|
|
252
|
+
{ type: 'function', function: {
|
|
253
|
+
name: 'ssh_push',
|
|
254
|
+
description: 'Copy a file from the local workspace to a remote host via SCP. Hosts are defined in .shmakk/hosts.json.',
|
|
255
|
+
parameters: {
|
|
256
|
+
type: 'object',
|
|
257
|
+
required: ['host', 'src', 'dest'],
|
|
258
|
+
properties: {
|
|
259
|
+
host: { type: 'string', description: 'Host alias as defined in hosts.json' },
|
|
260
|
+
src: { type: 'string', description: 'Local file path (relative to workspace or absolute)' },
|
|
261
|
+
dest: { type: 'string', description: 'Remote destination path (absolute on remote host)' },
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
}},
|
|
265
|
+
{ type: 'function', function: {
|
|
266
|
+
name: 'ssh_pull',
|
|
267
|
+
description: 'Copy a file from a remote host to the local workspace via SCP. Hosts are defined in .shmakk/hosts.json.',
|
|
268
|
+
parameters: {
|
|
269
|
+
type: 'object',
|
|
270
|
+
required: ['host', 'src', 'dest'],
|
|
271
|
+
properties: {
|
|
272
|
+
host: { type: 'string', description: 'Host alias as defined in hosts.json' },
|
|
273
|
+
src: { type: 'string', description: 'Remote source path (absolute on remote host)' },
|
|
274
|
+
dest: { type: 'string', description: 'Local destination path (relative to workspace or absolute)' },
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
}},
|
|
232
278
|
];
|
|
233
279
|
|
|
234
280
|
// Tool safety classification.
|
|
@@ -258,6 +304,7 @@ function classifyTool(name, args, mcpManager) {
|
|
|
258
304
|
if (name === 'tts_generate') return 'safe'; // local-only, no network
|
|
259
305
|
if (name === 'video_probe') return 'safe'; // read-only local metadata
|
|
260
306
|
if (name === 'video_compose') return 'safe'; // local ffmpeg, reads only workspace files
|
|
307
|
+
if (name === 'ssh_run' || name === 'ssh_push' || name === 'ssh_pull') return 'unsafe';
|
|
261
308
|
return 'uncertain';
|
|
262
309
|
}
|
|
263
310
|
|
|
@@ -287,6 +334,9 @@ function describeTool(name, args, mcpManager) {
|
|
|
287
334
|
if (name === 'tts_generate') return `tts_generate: "${(args.text || '').slice(0, 80)}" (voice: ${args.voice || 'af_heart'})`;
|
|
288
335
|
if (name === 'video_probe') return `video_probe ${args.path || ''}`;
|
|
289
336
|
if (name === 'video_compose') return `video_compose ${(args.segments || []).length} segments → ${args.outputPath || ''}`;
|
|
337
|
+
if (name === 'ssh_run') return `ssh_run ${args.host || ''}: ${(args.cmd || '').slice(0, 100)}`;
|
|
338
|
+
if (name === 'ssh_push') return `ssh_push ${args.src || ''} → ${args.host || ''}:${args.dest || ''}`;
|
|
339
|
+
if (name === 'ssh_pull') return `ssh_pull ${args.host || ''}:${args.src || ''} → ${args.dest || ''}`;
|
|
290
340
|
return `${name} ${JSON.stringify(args).slice(0, 80)}`;
|
|
291
341
|
}
|
|
292
342
|
|
|
@@ -347,6 +397,31 @@ async function dispatchTool(name, args, roots, confirmTool, signal, mcpManager)
|
|
|
347
397
|
if (!p) return { error: 'path outside workspace' };
|
|
348
398
|
try {
|
|
349
399
|
const buf = fs.readFileSync(p);
|
|
400
|
+
const ext = path.extname(p).toLowerCase();
|
|
401
|
+
|
|
402
|
+
// Image files: return as base64 for vision-capable providers.
|
|
403
|
+
// Mode-specific sub-reads don't apply to images — always return full.
|
|
404
|
+
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
|
405
|
+
const MIME_MAP = {
|
|
406
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
407
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp',
|
|
408
|
+
'.svg': 'image/svg+xml',
|
|
409
|
+
};
|
|
410
|
+
if (IMAGE_EXTS.has(ext)) {
|
|
411
|
+
const maxImageBytes = 2 * 1024 * 1024; // 2 MB binary (~2.7 MB base64)
|
|
412
|
+
const slice = buf.length > maxImageBytes ? buf.subarray(0, maxImageBytes) : buf;
|
|
413
|
+
const b64 = slice.toString('base64');
|
|
414
|
+
return {
|
|
415
|
+
content: `[Image: ${path.basename(p)} — ${buf.length} bytes${buf.length > maxImageBytes ? ' (truncated to 2MB for display)' : ''}]`,
|
|
416
|
+
images: [{
|
|
417
|
+
mimeType: MIME_MAP[ext],
|
|
418
|
+
data: b64,
|
|
419
|
+
dataLength: b64.length,
|
|
420
|
+
truncated: buf.length > maxImageBytes,
|
|
421
|
+
}],
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
350
425
|
const text = buf.slice(0, MAX_FILE_BYTES).toString('utf8');
|
|
351
426
|
const lines = text.split(/\r?\n/);
|
|
352
427
|
const mode = args.mode || 'full';
|
|
@@ -767,6 +842,35 @@ async function dispatchTool(name, args, roots, confirmTool, signal, mcpManager)
|
|
|
767
842
|
});
|
|
768
843
|
return composeResult;
|
|
769
844
|
}
|
|
845
|
+
if (name === 'ssh_run') {
|
|
846
|
+
const ssh = _getSSH(roots);
|
|
847
|
+
if (!ssh) return { error: 'SSH module not available' };
|
|
848
|
+
const cfg = ssh.loadHostConfig(roots[0]);
|
|
849
|
+
const entry = ssh.resolveHost(cfg, args.host);
|
|
850
|
+
if (!entry) return { error: 'host not configured: ' + args.host + '. Define it in .shmakk/hosts.json or ~/.config/shmakk/hosts.json' };
|
|
851
|
+
return await ssh.sshRun(entry, args.cmd, signal);
|
|
852
|
+
}
|
|
853
|
+
if (name === 'ssh_push') {
|
|
854
|
+
const ssh = _getSSH(roots);
|
|
855
|
+
if (!ssh) return { error: 'SSH module not available' };
|
|
856
|
+
const cfg = ssh.loadHostConfig(roots[0]);
|
|
857
|
+
const entry = ssh.resolveHost(cfg, args.host);
|
|
858
|
+
if (!entry) return { error: 'host not configured: ' + args.host };
|
|
859
|
+
const p = within(roots, args.src);
|
|
860
|
+
if (!p) return { error: 'src path outside workspace' };
|
|
861
|
+
if (!fs.existsSync(p)) return { error: 'src not found: ' + args.src };
|
|
862
|
+
return await ssh.sshTransfer(entry, p, args.dest, 'push', signal);
|
|
863
|
+
}
|
|
864
|
+
if (name === 'ssh_pull') {
|
|
865
|
+
const ssh = _getSSH(roots);
|
|
866
|
+
if (!ssh) return { error: 'SSH module not available' };
|
|
867
|
+
const cfg = ssh.loadHostConfig(roots[0]);
|
|
868
|
+
const entry = ssh.resolveHost(cfg, args.host);
|
|
869
|
+
if (!entry) return { error: 'host not configured: ' + args.host };
|
|
870
|
+
const p = within(roots, args.dest);
|
|
871
|
+
if (!p) return { error: 'dest path outside workspace' };
|
|
872
|
+
return await ssh.sshTransfer(entry, args.src, p, 'pull', signal);
|
|
873
|
+
}
|
|
770
874
|
return { error: `unknown tool: ${name}` };
|
|
771
875
|
}
|
|
772
876
|
|