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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shmakk",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "AI-supervised terminal wrapper — command correction, tool-driven tasks, safety controls",
5
5
  "license": "MIT",
6
6
  "keywords": [
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