shmakk 1.2.1 → 1.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/README.md +40 -0
- package/package.json +1 -1
- package/src/cli.js +27 -0
- package/src/ssh.js +255 -0
- package/src/tools.js +79 -0
package/README.md
CHANGED
|
@@ -170,6 +170,46 @@ The coordinator system enables complex, multi-step task execution with plan-firs
|
|
|
170
170
|
- Secrets (`.env`, keys, tokens) are never sent to the AI
|
|
171
171
|
- Workspace root is enforced — tools can't access files outside it
|
|
172
172
|
|
|
173
|
+
## Remote SSH
|
|
174
|
+
|
|
175
|
+
The agent can run commands and transfer files on remote hosts via SSH. Configure hosts in `.shmakk/hosts.json` (per-project) or `~/.config/shmakk/hosts.json` (global):
|
|
176
|
+
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"hosts": {
|
|
180
|
+
"devbox": {
|
|
181
|
+
"host": "marcus@192.168.1.100",
|
|
182
|
+
"port": 22,
|
|
183
|
+
"auto_approve": false,
|
|
184
|
+
"timeout_sec": 30
|
|
185
|
+
},
|
|
186
|
+
"staging": {
|
|
187
|
+
"host": "deploy@10.0.0.5",
|
|
188
|
+
"port": 2247
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
"allow_ssh_config": false,
|
|
192
|
+
"default_timeout_sec": 30
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
| Tool | Description |
|
|
197
|
+
|------|-------------|
|
|
198
|
+
| `ssh_run` | Run a shell command on a remote host |
|
|
199
|
+
| `ssh_push` | Copy a local workspace file to a remote host |
|
|
200
|
+
| `ssh_pull` | Copy a remote file into the local workspace |
|
|
201
|
+
|
|
202
|
+
SSH key auth via `~/.ssh` is assumed. For persistent connections (avoid re-auth on every call), add to `~/.ssh/config`:
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
Host *
|
|
206
|
+
ControlMaster auto
|
|
207
|
+
ControlPath ~/.ssh/controlmasters/%r@%h:%p
|
|
208
|
+
ControlPersist 600
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Then `mkdir -p ~/.ssh/controlmasters` once.
|
|
212
|
+
|
|
173
213
|
## How it works
|
|
174
214
|
|
|
175
215
|
shmakk wraps your shell in a PTY (pseudo-terminal). Every command that fails is checked against a deterministic correction engine (no LLM, no API call). If a correction matches and the fixed command succeeds, shmakk feeds the agent your **original input** (not the fixed command) so the agent can address your full intent — not just the typo. You can also give task instructions in natural language — shmakk uses tools to read files, write code, list directories, and run commands, all constrained to your workspace.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -300,6 +300,33 @@ const HELP = `shmakk - AI-supervised terminal wrapper
|
|
|
300
300
|
Tools: navigate, click, type, read_page, screenshot, evaluate, select,
|
|
301
301
|
wait, scroll, close.
|
|
302
302
|
|
|
303
|
+
═══════════════════════════════════════════════════════════════════════════
|
|
304
|
+
REMOTE HOSTS (SSH)
|
|
305
|
+
═══════════════════════════════════════════════════════════════════════════
|
|
306
|
+
|
|
307
|
+
The agent can run commands on remote hosts and transfer files via SSH.
|
|
308
|
+
Configure hosts in .shmakk/hosts.json or ~/.config/shmakk/hosts.json:
|
|
309
|
+
|
|
310
|
+
{
|
|
311
|
+
"hosts": {
|
|
312
|
+
"devbox": {
|
|
313
|
+
"host": "user@192.168.1.100",
|
|
314
|
+
"port": 22,
|
|
315
|
+
"auto_approve": false,
|
|
316
|
+
"timeout_sec": 30
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
"allow_ssh_config": false,
|
|
320
|
+
"default_timeout_sec": 30
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
Agent tools: ssh_run (run command), ssh_push (upload), ssh_pull (download).
|
|
324
|
+
For persistent connections, use ControlMaster in ~/.ssh/config:
|
|
325
|
+
Host *
|
|
326
|
+
ControlMaster auto
|
|
327
|
+
ControlPath ~/.ssh/controlmasters/%r@%h:%p
|
|
328
|
+
ControlPersist 600
|
|
329
|
+
|
|
303
330
|
`;
|
|
304
331
|
|
|
305
332
|
module.exports = { parseArgs, HELP };
|
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/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;
|
|
@@ -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
|
|
|
@@ -767,6 +817,35 @@ async function dispatchTool(name, args, roots, confirmTool, signal, mcpManager)
|
|
|
767
817
|
});
|
|
768
818
|
return composeResult;
|
|
769
819
|
}
|
|
820
|
+
if (name === 'ssh_run') {
|
|
821
|
+
const ssh = _getSSH(roots);
|
|
822
|
+
if (!ssh) return { error: 'SSH module not available' };
|
|
823
|
+
const cfg = ssh.loadHostConfig(roots[0]);
|
|
824
|
+
const entry = ssh.resolveHost(cfg, args.host);
|
|
825
|
+
if (!entry) return { error: 'host not configured: ' + args.host + '. Define it in .shmakk/hosts.json or ~/.config/shmakk/hosts.json' };
|
|
826
|
+
return await ssh.sshRun(entry, args.cmd, signal);
|
|
827
|
+
}
|
|
828
|
+
if (name === 'ssh_push') {
|
|
829
|
+
const ssh = _getSSH(roots);
|
|
830
|
+
if (!ssh) return { error: 'SSH module not available' };
|
|
831
|
+
const cfg = ssh.loadHostConfig(roots[0]);
|
|
832
|
+
const entry = ssh.resolveHost(cfg, args.host);
|
|
833
|
+
if (!entry) return { error: 'host not configured: ' + args.host };
|
|
834
|
+
const p = within(roots, args.src);
|
|
835
|
+
if (!p) return { error: 'src path outside workspace' };
|
|
836
|
+
if (!fs.existsSync(p)) return { error: 'src not found: ' + args.src };
|
|
837
|
+
return await ssh.sshTransfer(entry, p, args.dest, 'push', signal);
|
|
838
|
+
}
|
|
839
|
+
if (name === 'ssh_pull') {
|
|
840
|
+
const ssh = _getSSH(roots);
|
|
841
|
+
if (!ssh) return { error: 'SSH module not available' };
|
|
842
|
+
const cfg = ssh.loadHostConfig(roots[0]);
|
|
843
|
+
const entry = ssh.resolveHost(cfg, args.host);
|
|
844
|
+
if (!entry) return { error: 'host not configured: ' + args.host };
|
|
845
|
+
const p = within(roots, args.dest);
|
|
846
|
+
if (!p) return { error: 'dest path outside workspace' };
|
|
847
|
+
return await ssh.sshTransfer(entry, args.src, p, 'pull', signal);
|
|
848
|
+
}
|
|
770
849
|
return { error: `unknown tool: ${name}` };
|
|
771
850
|
}
|
|
772
851
|
|