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/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
+ };
@@ -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 UTF-8 file inside the workspace. Supports compact partial reads.',
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