@poping/yome 0.0.8 → 0.0.10
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/dist/agent.js +13 -7
- package/dist/agent.js.map +1 -1
- package/dist/llm.d.ts +1 -0
- package/dist/llm.js +35 -3
- package/dist/llm.js.map +1 -1
- package/dist/mesh/rpc-handler-path.test.d.ts +1 -0
- package/dist/mesh/rpc-handler-path.test.js +248 -0
- package/dist/mesh/rpc-handler-path.test.js.map +1 -0
- package/dist/mesh/rpc-handler.d.ts +13 -14
- package/dist/mesh/rpc-handler.js +409 -153
- package/dist/mesh/rpc-handler.js.map +1 -1
- package/dist/mesh/types.d.ts +1 -1
- package/dist/tools/glob.js +16 -6
- package/dist/tools/glob.js.map +1 -1
- package/dist/tools/grep.js +24 -3
- package/dist/tools/grep.js.map +1 -1
- package/dist/tools/ls.js +33 -15
- package/dist/tools/ls.js.map +1 -1
- package/dist/tools/read.js +98 -14
- package/dist/tools/read.js.map +1 -1
- package/dist/ui/App.js +36 -1
- package/dist/ui/App.js.map +1 -1
- package/dist/ui/ToolResult.js +4 -0
- package/dist/ui/ToolResult.js.map +1 -1
- package/package.json +1 -1
package/dist/mesh/rpc-handler.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// which routes by `parsed.domain` to a per-domain bridge. Here on Linux:
|
|
6
6
|
//
|
|
7
7
|
// domain=bash → spawn /bin/sh and stream output
|
|
8
|
-
// domain=fs → file system operations (
|
|
8
|
+
// domain=fs → file system operations (full POSIX paths, no sandbox)
|
|
9
9
|
//
|
|
10
10
|
// Stage A intentionally ships only 'bash' + 'fs' to prove the pipe.
|
|
11
11
|
// Other domains (git / docker / k8s / systemd / pkg / log / svc / net)
|
|
@@ -13,16 +13,95 @@
|
|
|
13
13
|
// "not implemented yet" so the Cloud agent can fall back gracefully.
|
|
14
14
|
//
|
|
15
15
|
// fs actions implemented here (mirrors yome-skill-fs/signature/fs.signature.json):
|
|
16
|
-
// ls /
|
|
17
|
-
// edit
|
|
18
|
-
// grep
|
|
19
|
-
// glob
|
|
16
|
+
// ls / tree / stat / find / read (alias cat) / head / tail
|
|
17
|
+
// write / append / mkdir / edit / cp / mv / rm
|
|
18
|
+
// grep — system rg (or fallback grep), line-numbered matches
|
|
19
|
+
// glob — fast-glob multi-pattern + exclude
|
|
20
|
+
//
|
|
21
|
+
// IMPORTANT path conventions on Linux mesh (different from macOS sandbox!):
|
|
22
|
+
// - There is NO Desktop/Downloads/Documents sandbox; full POSIX paths allowed.
|
|
23
|
+
// - `~` and `~/foo` are expanded to the daemon user's $HOME.
|
|
24
|
+
// - A missing/empty path is treated as $HOME (NOT process.cwd(), because the
|
|
25
|
+
// daemon's cwd is wherever systemd/the background launcher started it,
|
|
26
|
+
// which is meaningless to the LLM).
|
|
27
|
+
// - A relative path is resolved against $HOME (matches `cd ~` then `ls foo`).
|
|
20
28
|
import { spawn } from 'child_process';
|
|
21
29
|
import { promises as fsp } from 'fs';
|
|
22
|
-
import {
|
|
30
|
+
import { homedir } from 'os';
|
|
31
|
+
import { basename, join, resolve as resolvePath, isAbsolute } from 'path';
|
|
23
32
|
import fg from 'fast-glob';
|
|
24
33
|
const BASH_TIMEOUT_MS = 60_000;
|
|
25
34
|
const MAX_STDOUT_CHARS = 64_000;
|
|
35
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
36
|
+
// Path helpers — shared by every fs action so behaviour is identical
|
|
37
|
+
// across read/write/grep/glob.
|
|
38
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
39
|
+
/** Expand a leading `~` to $HOME. `~user` is intentionally NOT supported. */
|
|
40
|
+
function expandTilde(p) {
|
|
41
|
+
if (!p)
|
|
42
|
+
return p;
|
|
43
|
+
if (p === '~')
|
|
44
|
+
return homedir();
|
|
45
|
+
if (p.startsWith('~/'))
|
|
46
|
+
return join(homedir(), p.slice(2));
|
|
47
|
+
return p;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolve a user-supplied path with consistent semantics on Linux mesh:
|
|
51
|
+
* - empty / undefined → $HOME (NOT cwd — the daemon's cwd is opaque)
|
|
52
|
+
* - leading `~` → $HOME-relative
|
|
53
|
+
* - absolute → kept as-is
|
|
54
|
+
* - relative → resolved against $HOME
|
|
55
|
+
*/
|
|
56
|
+
function resolveUserPath(raw, fallbackToHome = true) {
|
|
57
|
+
const s = (raw ?? '').trim();
|
|
58
|
+
if (!s) {
|
|
59
|
+
if (fallbackToHome)
|
|
60
|
+
return homedir();
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
const expanded = expandTilde(s);
|
|
64
|
+
if (isAbsolute(expanded))
|
|
65
|
+
return expanded;
|
|
66
|
+
return resolvePath(homedir(), expanded);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Extract the path argument from a parsed fs command.
|
|
70
|
+
* Server-side commandParser stores the bare positional under `args.positional`
|
|
71
|
+
* (see Server/agent/commandParser.ts:558). Older code paths and the
|
|
72
|
+
* yome-skill-fs signature use `--path=`, so we accept both.
|
|
73
|
+
*/
|
|
74
|
+
function pathArg(args, fallbackToHome = true) {
|
|
75
|
+
const fromPath = typeof args.path === 'string' ? args.path : '';
|
|
76
|
+
const fromPos = typeof args.positional === 'string' ? args.positional : '';
|
|
77
|
+
return resolveUserPath(fromPath || fromPos, fallbackToHome);
|
|
78
|
+
}
|
|
79
|
+
function intArg(args, key, dflt) {
|
|
80
|
+
const v = args[key];
|
|
81
|
+
if (typeof v !== 'string')
|
|
82
|
+
return dflt;
|
|
83
|
+
const n = Number.parseInt(v, 10);
|
|
84
|
+
return Number.isFinite(n) ? n : dflt;
|
|
85
|
+
}
|
|
86
|
+
function strArg(args, key, dflt = '') {
|
|
87
|
+
const v = args[key];
|
|
88
|
+
return typeof v === 'string' ? v : dflt;
|
|
89
|
+
}
|
|
90
|
+
function boolArg(args, key) {
|
|
91
|
+
return args[key] === 'true' || args[key] === true;
|
|
92
|
+
}
|
|
93
|
+
function humanSize(n) {
|
|
94
|
+
if (n < 1024)
|
|
95
|
+
return `${n}B`;
|
|
96
|
+
if (n < 1024 ** 2)
|
|
97
|
+
return `${(n / 1024).toFixed(1)}KB`;
|
|
98
|
+
if (n < 1024 ** 3)
|
|
99
|
+
return `${(n / 1024 ** 2).toFixed(1)}MB`;
|
|
100
|
+
return `${(n / 1024 ** 3).toFixed(2)}GB`;
|
|
101
|
+
}
|
|
102
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
103
|
+
// Handler
|
|
104
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
26
105
|
export class RpcHandler {
|
|
27
106
|
client;
|
|
28
107
|
opts;
|
|
@@ -86,6 +165,7 @@ export class RpcHandler {
|
|
|
86
165
|
const domain = req.parsed?.domain;
|
|
87
166
|
switch (domain) {
|
|
88
167
|
case 'bash':
|
|
168
|
+
case 'sh':
|
|
89
169
|
return this.handleBash(req);
|
|
90
170
|
case 'fs':
|
|
91
171
|
return this.handleFs(req);
|
|
@@ -100,30 +180,40 @@ export class RpcHandler {
|
|
|
100
180
|
case 'svc':
|
|
101
181
|
return {
|
|
102
182
|
stdout: '',
|
|
103
|
-
stderr: `[mesh] domain '${domain}' not implemented on linux cli yet —
|
|
183
|
+
stderr: `[mesh] domain '${domain}' not implemented on linux cli yet — use \`bash exec --cmd="..."\` instead`,
|
|
104
184
|
exitCode: 127,
|
|
105
185
|
};
|
|
106
186
|
default:
|
|
107
187
|
return {
|
|
108
188
|
stdout: '',
|
|
109
|
-
stderr: `[mesh] unknown domain: ${domain}
|
|
189
|
+
stderr: `[mesh] unknown domain: ${domain} (linux cli implements: sh, fs). For shell access use \`sh <command>\`.`,
|
|
110
190
|
exitCode: 127,
|
|
111
191
|
};
|
|
112
192
|
}
|
|
113
193
|
}
|
|
114
194
|
/**
|
|
115
|
-
* `bash
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
195
|
+
* Handles both `bash` (legacy: --cmd=…) and `sh` (new top-level shell
|
|
196
|
+
* domain: everything after the leading `sh ` token is the shell
|
|
197
|
+
* line). Resolution order:
|
|
198
|
+
* 1. parsed.args.cmd — legacy `bash exec --cmd="..."`
|
|
199
|
+
* 2. req.command with leading `sh ` / `bash ` stripped — `sh find / | xargs grep ...`
|
|
200
|
+
* 3. raw req.command as last resort.
|
|
119
201
|
*/
|
|
120
202
|
handleBash(req) {
|
|
121
|
-
|
|
203
|
+
let shellLine = req.parsed?.args?.cmd ?? '';
|
|
204
|
+
if (!shellLine) {
|
|
205
|
+
const raw = (req.command ?? '').trim();
|
|
206
|
+
const stripped = raw.replace(/^(?:sh|bash)\s+/i, '');
|
|
207
|
+
shellLine = stripped || raw;
|
|
208
|
+
}
|
|
122
209
|
if (!shellLine.trim()) {
|
|
123
210
|
return Promise.resolve({ stdout: '', stderr: '[bash] empty command', exitCode: 2 });
|
|
124
211
|
}
|
|
125
212
|
return new Promise((resolveP) => {
|
|
126
|
-
const proc = spawn('sh', ['-c', shellLine], {
|
|
213
|
+
const proc = spawn('sh', ['-c', shellLine], {
|
|
214
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
215
|
+
cwd: homedir(),
|
|
216
|
+
});
|
|
127
217
|
let stdout = '';
|
|
128
218
|
let stderr = '';
|
|
129
219
|
let killed = false;
|
|
@@ -152,70 +242,186 @@ export class RpcHandler {
|
|
|
152
242
|
});
|
|
153
243
|
}
|
|
154
244
|
/**
|
|
155
|
-
* `fs <action>
|
|
156
|
-
*
|
|
157
|
-
*
|
|
245
|
+
* `fs <action> [<path>] [--key=value ...]`
|
|
246
|
+
* Path resolution: see `resolveUserPath` — empty → $HOME, ~ expanded,
|
|
247
|
+
* relative → resolved against $HOME, full POSIX paths allowed.
|
|
158
248
|
*/
|
|
159
249
|
async handleFs(req) {
|
|
160
250
|
const action = req.parsed?.action ?? '';
|
|
161
|
-
const args = req.parsed?.args ?? {};
|
|
162
|
-
const path = typeof args.path === 'string' ? args.path : '';
|
|
163
|
-
const safeJoinedPath = path && isAbsolute(path) ? path : resolvePath(process.cwd(), path);
|
|
251
|
+
const args = (req.parsed?.args ?? {});
|
|
164
252
|
try {
|
|
165
253
|
switch (action) {
|
|
254
|
+
case 'ls': {
|
|
255
|
+
const target = pathArg(args);
|
|
256
|
+
const entries = await fsp.readdir(target, { withFileTypes: true });
|
|
257
|
+
// Output TSV: `name\ttype\tsize` so the Server-side fsDomain.ls
|
|
258
|
+
// compress() (which filters on `\t`) actually sees rows. Header
|
|
259
|
+
// line lets compress() detect it. Sort: dirs first, then by name.
|
|
260
|
+
entries.sort((a, b) => {
|
|
261
|
+
if (a.isDirectory() !== b.isDirectory())
|
|
262
|
+
return a.isDirectory() ? -1 : 1;
|
|
263
|
+
return a.name.localeCompare(b.name);
|
|
264
|
+
});
|
|
265
|
+
const rows = ['name\ttype\tsize'];
|
|
266
|
+
for (const e of entries) {
|
|
267
|
+
const full = join(target, e.name);
|
|
268
|
+
let sizeStr = '-';
|
|
269
|
+
if (e.isFile()) {
|
|
270
|
+
try {
|
|
271
|
+
const st = await fsp.stat(full);
|
|
272
|
+
sizeStr = humanSize(st.size);
|
|
273
|
+
}
|
|
274
|
+
catch { /* keep '-' */ }
|
|
275
|
+
}
|
|
276
|
+
const kind = e.isDirectory() ? 'dir' : e.isSymbolicLink() ? 'link' : 'file';
|
|
277
|
+
rows.push(`${e.name}\t${kind}\t${sizeStr}`);
|
|
278
|
+
}
|
|
279
|
+
return { stdout: `${target}:\n${rows.join('\n')}`, stderr: '', exitCode: 0 };
|
|
280
|
+
}
|
|
281
|
+
case 'tree': {
|
|
282
|
+
const target = pathArg(args);
|
|
283
|
+
const depth = Math.max(1, intArg(args, 'depth', 2));
|
|
284
|
+
const out = [target];
|
|
285
|
+
await walkTree(target, depth, '', out);
|
|
286
|
+
return { stdout: out.join('\n'), stderr: '', exitCode: 0 };
|
|
287
|
+
}
|
|
288
|
+
case 'stat': {
|
|
289
|
+
const target = pathArg(args, false);
|
|
290
|
+
if (!target)
|
|
291
|
+
return { stdout: '', stderr: '[fs stat] path is required', exitCode: 2 };
|
|
292
|
+
const st = await fsp.stat(target);
|
|
293
|
+
const info = {
|
|
294
|
+
name: basename(target) || target,
|
|
295
|
+
path: target,
|
|
296
|
+
type: st.isDirectory() ? 'dir' : st.isSymbolicLink() ? 'link' : 'file',
|
|
297
|
+
size: st.size,
|
|
298
|
+
sizeFormatted: humanSize(st.size),
|
|
299
|
+
modifiedAt: st.mtime.toISOString(),
|
|
300
|
+
createdAt: st.birthtime.toISOString(),
|
|
301
|
+
mode: '0' + (st.mode & 0o777).toString(8),
|
|
302
|
+
};
|
|
303
|
+
return { stdout: JSON.stringify(info), stderr: '', exitCode: 0 };
|
|
304
|
+
}
|
|
305
|
+
case 'find': {
|
|
306
|
+
// fs find <path> --name=GLOB [--type=file|dir] [--limit=20]
|
|
307
|
+
const target = pathArg(args);
|
|
308
|
+
const namePat = strArg(args, 'name');
|
|
309
|
+
if (!namePat)
|
|
310
|
+
return { stdout: '', stderr: '[fs find] --name is required', exitCode: 2 };
|
|
311
|
+
const typeFilter = strArg(args, 'type');
|
|
312
|
+
const limit = Math.max(1, intArg(args, 'limit', 20));
|
|
313
|
+
const matches = await fg([`**/${namePat}`], {
|
|
314
|
+
cwd: target,
|
|
315
|
+
dot: true,
|
|
316
|
+
onlyFiles: typeFilter === 'file' ? true : typeFilter === 'dir' ? false : false,
|
|
317
|
+
onlyDirectories: typeFilter === 'dir',
|
|
318
|
+
absolute: true,
|
|
319
|
+
suppressErrors: true,
|
|
320
|
+
ignore: ['**/node_modules/**', '**/.git/**'],
|
|
321
|
+
});
|
|
322
|
+
if (matches.length === 0) {
|
|
323
|
+
return { stdout: 'path\tname\nNo matches found\t-', stderr: '', exitCode: 0 };
|
|
324
|
+
}
|
|
325
|
+
const shown = matches.slice(0, limit);
|
|
326
|
+
// TSV so Server-side compress() picks rows correctly.
|
|
327
|
+
const rows = ['path\tname', ...shown.map((p) => `${p}\t${basename(p)}`)];
|
|
328
|
+
const tail = matches.length > limit
|
|
329
|
+
? `\n[showing ${limit} of ${matches.length}; raise --limit or narrow --name]`
|
|
330
|
+
: '';
|
|
331
|
+
return { stdout: rows.join('\n') + tail, stderr: '', exitCode: 0 };
|
|
332
|
+
}
|
|
166
333
|
case 'cat':
|
|
167
334
|
case 'read': {
|
|
168
|
-
const
|
|
169
|
-
|
|
335
|
+
const target = pathArg(args, false);
|
|
336
|
+
if (!target)
|
|
337
|
+
return { stdout: '', stderr: '[fs read] path is required', exitCode: 2 };
|
|
338
|
+
const st = await fsp.stat(target);
|
|
339
|
+
if (st.isDirectory()) {
|
|
340
|
+
return {
|
|
341
|
+
stdout: '',
|
|
342
|
+
stderr: `[fs read] ${target} is a directory — use \`fs ls ${shellQuote(target)}\` instead`,
|
|
343
|
+
exitCode: 1,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
const content = await fsp.readFile(target, 'utf-8');
|
|
347
|
+
const lines = intArg(args, 'lines', 50);
|
|
348
|
+
const offset = Math.max(0, intArg(args, 'offset', 0));
|
|
349
|
+
if (offset === 0 && lines >= content.split('\n').length) {
|
|
350
|
+
return { stdout: content, stderr: '', exitCode: 0 };
|
|
351
|
+
}
|
|
352
|
+
const all = content.split('\n');
|
|
353
|
+
const slice = all.slice(offset, offset + lines).join('\n');
|
|
354
|
+
return { stdout: slice, stderr: '', exitCode: 0 };
|
|
170
355
|
}
|
|
171
|
-
case '
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
356
|
+
case 'head': {
|
|
357
|
+
const target = pathArg(args, false);
|
|
358
|
+
if (!target)
|
|
359
|
+
return { stdout: '', stderr: '[fs head] path is required', exitCode: 2 };
|
|
360
|
+
const lines = Math.max(1, intArg(args, 'lines', 10));
|
|
361
|
+
const content = await fsp.readFile(target, 'utf-8');
|
|
362
|
+
return { stdout: content.split('\n').slice(0, lines).join('\n'), stderr: '', exitCode: 0 };
|
|
363
|
+
}
|
|
364
|
+
case 'tail': {
|
|
365
|
+
const target = pathArg(args, false);
|
|
366
|
+
if (!target)
|
|
367
|
+
return { stdout: '', stderr: '[fs tail] path is required', exitCode: 2 };
|
|
368
|
+
const lines = Math.max(1, intArg(args, 'lines', 10));
|
|
369
|
+
const content = await fsp.readFile(target, 'utf-8');
|
|
370
|
+
return { stdout: content.split('\n').slice(-lines).join('\n'), stderr: '', exitCode: 0 };
|
|
175
371
|
}
|
|
176
372
|
case 'mkdir': {
|
|
177
|
-
|
|
178
|
-
|
|
373
|
+
const target = pathArg(args, false);
|
|
374
|
+
if (!target)
|
|
375
|
+
return { stdout: '', stderr: '[fs mkdir] path is required', exitCode: 2 };
|
|
376
|
+
await fsp.mkdir(target, { recursive: true });
|
|
377
|
+
return { stdout: `created ${target}`, stderr: '', exitCode: 0 };
|
|
179
378
|
}
|
|
180
379
|
case 'write': {
|
|
181
|
-
const
|
|
182
|
-
|
|
380
|
+
const target = pathArg(args, false);
|
|
381
|
+
if (!target)
|
|
382
|
+
return { stdout: '', stderr: '[fs write] path is required', exitCode: 2 };
|
|
383
|
+
const content = strArg(args, 'content');
|
|
384
|
+
const force = boolArg(args, 'force');
|
|
183
385
|
if (!force) {
|
|
184
386
|
try {
|
|
185
|
-
await fsp.access(
|
|
186
|
-
return { stdout: '', stderr: `[fs] ${
|
|
387
|
+
await fsp.access(target);
|
|
388
|
+
return { stdout: '', stderr: `[fs write] ${target} already exists; pass --force=true to overwrite`, exitCode: 1 };
|
|
187
389
|
}
|
|
188
390
|
catch { /* file doesn't exist — fine */ }
|
|
189
391
|
}
|
|
190
|
-
await fsp.mkdir(join(
|
|
191
|
-
await fsp.writeFile(
|
|
192
|
-
return { stdout: `wrote ${content.length} bytes to ${
|
|
392
|
+
await fsp.mkdir(join(target, '..'), { recursive: true });
|
|
393
|
+
await fsp.writeFile(target, content, 'utf-8');
|
|
394
|
+
return { stdout: `wrote ${content.length} bytes to ${target}`, stderr: '', exitCode: 0 };
|
|
193
395
|
}
|
|
194
396
|
case 'append': {
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
397
|
+
const target = pathArg(args, false);
|
|
398
|
+
if (!target)
|
|
399
|
+
return { stdout: '', stderr: '[fs append] path is required', exitCode: 2 };
|
|
400
|
+
const content = strArg(args, 'content');
|
|
401
|
+
await fsp.mkdir(join(target, '..'), { recursive: true });
|
|
402
|
+
await fsp.appendFile(target, content, 'utf-8');
|
|
403
|
+
return { stdout: `appended ${content.length} bytes to ${target}`, stderr: '', exitCode: 0 };
|
|
199
404
|
}
|
|
200
405
|
case 'edit': {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
406
|
+
const target = pathArg(args, false);
|
|
407
|
+
if (!target)
|
|
408
|
+
return { stdout: '', stderr: '[fs edit] path is required', exitCode: 2 };
|
|
409
|
+
const oldStr = strArg(args, 'old');
|
|
410
|
+
const newStr = strArg(args, 'new');
|
|
411
|
+
const all = boolArg(args, 'all');
|
|
412
|
+
if (!oldStr)
|
|
205
413
|
return { stdout: '', stderr: '[fs edit] --old is required', exitCode: 2 };
|
|
206
|
-
}
|
|
207
414
|
let original;
|
|
208
415
|
try {
|
|
209
|
-
original = await fsp.readFile(
|
|
416
|
+
original = await fsp.readFile(target, 'utf-8');
|
|
210
417
|
}
|
|
211
418
|
catch (err) {
|
|
212
|
-
return { stdout: '', stderr: `[fs edit] cannot read ${
|
|
419
|
+
return { stdout: '', stderr: `[fs edit] cannot read ${target}: ${err.message}`, exitCode: 1 };
|
|
213
420
|
}
|
|
214
421
|
if (!all) {
|
|
215
422
|
const first = original.indexOf(oldStr);
|
|
216
|
-
if (first === -1)
|
|
423
|
+
if (first === -1)
|
|
217
424
|
return { stdout: '', stderr: '[fs edit] --old not found in file', exitCode: 1 };
|
|
218
|
-
}
|
|
219
425
|
const second = original.indexOf(oldStr, first + oldStr.length);
|
|
220
426
|
if (second !== -1) {
|
|
221
427
|
return {
|
|
@@ -225,62 +431,91 @@ export class RpcHandler {
|
|
|
225
431
|
};
|
|
226
432
|
}
|
|
227
433
|
const next = original.slice(0, first) + newStr + original.slice(first + oldStr.length);
|
|
228
|
-
await fsp.writeFile(
|
|
229
|
-
return { stdout: `replaced 1 occurrence in ${
|
|
434
|
+
await fsp.writeFile(target, next, 'utf-8');
|
|
435
|
+
return { stdout: `replaced 1 occurrence in ${target}`, stderr: '', exitCode: 0 };
|
|
230
436
|
}
|
|
231
|
-
// --all=true → split/join replaces every literal occurrence (no regex).
|
|
232
437
|
const parts = original.split(oldStr);
|
|
233
438
|
const count = parts.length - 1;
|
|
234
|
-
if (count === 0)
|
|
439
|
+
if (count === 0)
|
|
235
440
|
return { stdout: '', stderr: '[fs edit] --old not found in file', exitCode: 1 };
|
|
441
|
+
await fsp.writeFile(target, parts.join(newStr), 'utf-8');
|
|
442
|
+
return { stdout: `replaced ${count} occurrence(s) in ${target}`, stderr: '', exitCode: 0 };
|
|
443
|
+
}
|
|
444
|
+
case 'cp':
|
|
445
|
+
case 'mv': {
|
|
446
|
+
const src = pathArg(args, false);
|
|
447
|
+
const dst = resolveUserPath(strArg(args, 'to'), false);
|
|
448
|
+
if (!src || !dst) {
|
|
449
|
+
return { stdout: '', stderr: `[fs ${action}] both <path> and --to=<dest> are required`, exitCode: 2 };
|
|
450
|
+
}
|
|
451
|
+
const force = boolArg(args, 'force');
|
|
452
|
+
try {
|
|
453
|
+
await fsp.access(dst);
|
|
454
|
+
if (!force) {
|
|
455
|
+
return { stdout: '', stderr: `[fs ${action}] ${dst} already exists; pass --force=true to overwrite`, exitCode: 1 };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
catch { /* target doesn't exist — fine */ }
|
|
459
|
+
await fsp.mkdir(join(dst, '..'), { recursive: true });
|
|
460
|
+
if (action === 'mv') {
|
|
461
|
+
await fsp.rename(src, dst);
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
// Node 16.7+ has fsp.cp; use recursive for dirs.
|
|
465
|
+
await fsp.cp(src, dst, { recursive: true, force: true });
|
|
236
466
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
467
|
+
return { stdout: `${action} ${src} → ${dst}`, stderr: '', exitCode: 0 };
|
|
468
|
+
}
|
|
469
|
+
case 'rm': {
|
|
470
|
+
const target = pathArg(args, false);
|
|
471
|
+
if (!target)
|
|
472
|
+
return { stdout: '', stderr: '[fs rm] path is required', exitCode: 2 };
|
|
473
|
+
const recursive = boolArg(args, 'recursive');
|
|
474
|
+
await fsp.rm(target, { recursive, force: false });
|
|
475
|
+
return { stdout: `removed ${target}`, stderr: '', exitCode: 0 };
|
|
240
476
|
}
|
|
241
477
|
case 'grep':
|
|
242
478
|
return this.handleFsGrep(req);
|
|
243
479
|
case 'glob':
|
|
244
480
|
return this.handleFsGlob(req);
|
|
245
481
|
default:
|
|
246
|
-
return {
|
|
482
|
+
return {
|
|
483
|
+
stdout: '',
|
|
484
|
+
stderr: `[fs] unknown action: ${action} (linux supports: ls, tree, stat, find, read/cat, head, tail, write, append, mkdir, edit, cp, mv, rm, grep, glob)`,
|
|
485
|
+
exitCode: 127,
|
|
486
|
+
};
|
|
247
487
|
}
|
|
248
488
|
}
|
|
249
489
|
catch (err) {
|
|
250
|
-
|
|
490
|
+
const msg = err.code === 'EISDIR'
|
|
491
|
+
? `${err.message} — use \`fs ls\` for directories`
|
|
492
|
+
: err.message;
|
|
493
|
+
return { stdout: '', stderr: `[fs] ${msg}`, exitCode: 1 };
|
|
251
494
|
}
|
|
252
495
|
}
|
|
253
496
|
/**
|
|
254
497
|
* `fs grep <pattern> [path] [--type=ts] [--glob=*.md] [--context=2] [-i]
|
|
255
|
-
* [--fixed=true] [--limit=200]` —
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
* the local Grep tool's output shape so the Cloud LLM can reason
|
|
259
|
-
* about the result without mode-switching.
|
|
498
|
+
* [--fixed=true] [--limit=200]` — ripgrep when present, fallback to grep.
|
|
499
|
+
* Both pattern and path support positional/--flag entry, with ~ expansion
|
|
500
|
+
* via resolveUserPath.
|
|
260
501
|
*/
|
|
261
502
|
handleFsGrep(req) {
|
|
262
|
-
const args = req.parsed?.args ?? {};
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
const pattern =
|
|
266
|
-
? args.pattern
|
|
267
|
-
: (typeof args.positional === 'string' ? args.positional : '');
|
|
503
|
+
const args = (req.parsed?.args ?? {});
|
|
504
|
+
// pattern: bare positional, OR --pattern=. The path (if any) is parsed
|
|
505
|
+
// server-side as positional2.
|
|
506
|
+
const pattern = strArg(args, 'pattern') || strArg(args, 'positional');
|
|
268
507
|
if (!pattern) {
|
|
269
508
|
return Promise.resolve({ stdout: '', stderr: '[fs grep] pattern is required', exitCode: 2 });
|
|
270
509
|
}
|
|
271
|
-
const
|
|
272
|
-
const searchPath =
|
|
273
|
-
const caseInsensitive = args
|
|
274
|
-
const fixed = args
|
|
275
|
-
const globFilter =
|
|
276
|
-
const typeFilter =
|
|
277
|
-
const
|
|
278
|
-
const
|
|
279
|
-
const limitRaw = typeof args.limit === 'string' ? Number.parseInt(args.limit, 10) : NaN;
|
|
280
|
-
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 200;
|
|
510
|
+
const pathRaw = strArg(args, 'path') || strArg(args, 'positional2');
|
|
511
|
+
const searchPath = resolveUserPath(pathRaw, true);
|
|
512
|
+
const caseInsensitive = boolArg(args, 'i');
|
|
513
|
+
const fixed = boolArg(args, 'fixed');
|
|
514
|
+
const globFilter = strArg(args, 'glob');
|
|
515
|
+
const typeFilter = strArg(args, 'type');
|
|
516
|
+
const ctx = Math.max(0, intArg(args, 'context', 0));
|
|
517
|
+
const limit = Math.max(1, intArg(args, 'limit', 200));
|
|
281
518
|
return new Promise((resolveP) => {
|
|
282
|
-
// Probe rg synchronously via spawn callback chain so we don't fork
|
|
283
|
-
// twice when rg isn't present.
|
|
284
519
|
const probe = spawn('which', ['rg'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
285
520
|
let probeOut = '';
|
|
286
521
|
probe.stdout.on('data', (b) => { probeOut += b.toString('utf-8'); });
|
|
@@ -310,105 +545,60 @@ export class RpcHandler {
|
|
|
310
545
|
argv.push('-C', String(ctx));
|
|
311
546
|
if (globFilter)
|
|
312
547
|
argv.push(`--include=${globFilter}`);
|
|
548
|
+
argv.push('--exclude-dir=.git', '--exclude-dir=node_modules');
|
|
313
549
|
}
|
|
314
550
|
argv.push(pattern, searchPath);
|
|
315
|
-
|
|
316
|
-
let stdout = '';
|
|
317
|
-
let stderr = '';
|
|
318
|
-
proc.stdout.on('data', (b) => { stdout += b.toString('utf-8'); });
|
|
319
|
-
proc.stderr.on('data', (b) => { stderr += b.toString('utf-8'); });
|
|
320
|
-
proc.on('close', (exitCode) => {
|
|
321
|
-
// grep / rg both exit 1 when there are no matches — treat as success.
|
|
322
|
-
const noMatches = (exitCode === 1) && !stdout.trim();
|
|
323
|
-
if (noMatches) {
|
|
324
|
-
resolveP({ stdout: 'No matches found', stderr: '', exitCode: 0 });
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
327
|
-
if (exitCode !== 0 && exitCode !== 1) {
|
|
328
|
-
resolveP({ stdout: '', stderr: stderr || `[fs grep] exit ${exitCode}`, exitCode: exitCode ?? 1 });
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
const lines = stdout.split('\n');
|
|
332
|
-
if (lines.length > limit) {
|
|
333
|
-
const head = lines.slice(0, limit).join('\n');
|
|
334
|
-
resolveP({
|
|
335
|
-
stdout: `${head}\n\n[showing ${limit} of ${lines.length} lines; raise --limit or narrow the search]`,
|
|
336
|
-
stderr: '',
|
|
337
|
-
exitCode: 0,
|
|
338
|
-
});
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
resolveP({ stdout: stdout.trim() || 'No matches found', stderr: '', exitCode: 0 });
|
|
342
|
-
});
|
|
343
|
-
proc.on('error', (err) => {
|
|
344
|
-
resolveP({ stdout: '', stderr: `[fs grep] spawn ${argv[0]}: ${err.message}`, exitCode: 1 });
|
|
345
|
-
});
|
|
551
|
+
runGrepLike(argv, limit, resolveP);
|
|
346
552
|
});
|
|
347
553
|
probe.on('error', () => {
|
|
348
|
-
// `which` not available; just try grep.
|
|
349
554
|
const argv = ['grep', '-rn'];
|
|
350
555
|
if (caseInsensitive)
|
|
351
556
|
argv.push('-i');
|
|
352
557
|
if (fixed)
|
|
353
558
|
argv.push('-F');
|
|
559
|
+
if (ctx > 0)
|
|
560
|
+
argv.push('-C', String(ctx));
|
|
561
|
+
if (globFilter)
|
|
562
|
+
argv.push(`--include=${globFilter}`);
|
|
563
|
+
argv.push('--exclude-dir=.git', '--exclude-dir=node_modules');
|
|
354
564
|
argv.push(pattern, searchPath);
|
|
355
|
-
|
|
356
|
-
let stdout = '';
|
|
357
|
-
let stderr = '';
|
|
358
|
-
proc.stdout.on('data', (b) => { stdout += b.toString('utf-8'); });
|
|
359
|
-
proc.stderr.on('data', (b) => { stderr += b.toString('utf-8'); });
|
|
360
|
-
proc.on('close', (exitCode) => {
|
|
361
|
-
const noMatches = (exitCode === 1) && !stdout.trim();
|
|
362
|
-
if (noMatches) {
|
|
363
|
-
resolveP({ stdout: 'No matches found', stderr: '', exitCode: 0 });
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
resolveP({ stdout: stdout.trim() || 'No matches found', stderr, exitCode: exitCode ?? 0 });
|
|
367
|
-
});
|
|
368
|
-
proc.on('error', (err) => {
|
|
369
|
-
resolveP({ stdout: '', stderr: `[fs grep] grep unavailable: ${err.message}`, exitCode: 1 });
|
|
370
|
-
});
|
|
565
|
+
runGrepLike(argv, limit, resolveP);
|
|
371
566
|
});
|
|
372
567
|
});
|
|
373
568
|
}
|
|
374
569
|
/**
|
|
375
570
|
* `fs glob <pattern...> [--folder=DIR] [--exclude=PAT...]`
|
|
376
|
-
* Multi-pattern OR via fast-glob.
|
|
377
|
-
* under --folder (default cwd), capped at 100 entries.
|
|
571
|
+
* Multi-pattern OR via fast-glob.
|
|
378
572
|
*/
|
|
379
573
|
async handleFsGlob(req) {
|
|
380
|
-
const args = req.parsed?.args ?? {};
|
|
381
|
-
// Wire-level args are Record<string,string>. Accept patterns as either
|
|
382
|
-
// a comma-separated string under --patterns, a JSON array string under
|
|
383
|
-
// --patterns, or the bare positional (`fs glob "**/*.ts"`).
|
|
574
|
+
const args = (req.parsed?.args ?? {});
|
|
384
575
|
const rawPatterns = (() => {
|
|
385
576
|
const p = args.patterns;
|
|
386
577
|
if (typeof p === 'string' && p.trim()) {
|
|
387
578
|
const s = p.trim();
|
|
388
579
|
if (s.startsWith('[')) {
|
|
389
580
|
try {
|
|
390
|
-
const
|
|
391
|
-
if (Array.isArray(
|
|
392
|
-
return
|
|
581
|
+
const arr = JSON.parse(s);
|
|
582
|
+
if (Array.isArray(arr))
|
|
583
|
+
return arr.map(String);
|
|
393
584
|
}
|
|
394
|
-
catch { /* fall through
|
|
585
|
+
catch { /* fall through */ }
|
|
395
586
|
}
|
|
396
587
|
return s.split(',').map((q) => q.trim()).filter(Boolean);
|
|
397
588
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
return
|
|
589
|
+
const pos1 = strArg(args, 'positional');
|
|
590
|
+
const pos2 = strArg(args, 'positional2');
|
|
591
|
+
const list = [];
|
|
592
|
+
if (pos1)
|
|
593
|
+
list.push(pos1);
|
|
594
|
+
if (pos2)
|
|
595
|
+
list.push(pos2);
|
|
596
|
+
return list;
|
|
406
597
|
})();
|
|
407
598
|
if (rawPatterns.length === 0) {
|
|
408
599
|
return { stdout: '', stderr: '[fs glob] at least one pattern is required', exitCode: 2 };
|
|
409
600
|
}
|
|
410
|
-
const
|
|
411
|
-
const folder = isAbsolute(folderArg) ? folderArg : resolvePath(process.cwd(), folderArg);
|
|
601
|
+
const folder = resolveUserPath(strArg(args, 'folder'), true);
|
|
412
602
|
const ignore = (() => {
|
|
413
603
|
const base = ['**/node_modules/**', '**/.git/**'];
|
|
414
604
|
const ex = args.exclude;
|
|
@@ -416,9 +606,9 @@ export class RpcHandler {
|
|
|
416
606
|
const s = ex.trim();
|
|
417
607
|
if (s.startsWith('[')) {
|
|
418
608
|
try {
|
|
419
|
-
const
|
|
420
|
-
if (Array.isArray(
|
|
421
|
-
return [...base, ...
|
|
609
|
+
const arr = JSON.parse(s);
|
|
610
|
+
if (Array.isArray(arr))
|
|
611
|
+
return [...base, ...arr.map(String)];
|
|
422
612
|
}
|
|
423
613
|
catch { /* fall through */ }
|
|
424
614
|
}
|
|
@@ -433,12 +623,12 @@ export class RpcHandler {
|
|
|
433
623
|
dot: true,
|
|
434
624
|
onlyFiles: true,
|
|
435
625
|
absolute: false,
|
|
626
|
+
suppressErrors: true,
|
|
436
627
|
});
|
|
437
628
|
files.sort();
|
|
438
629
|
const limit = 100;
|
|
439
|
-
if (files.length === 0)
|
|
630
|
+
if (files.length === 0)
|
|
440
631
|
return { stdout: 'No files found', stderr: '', exitCode: 0 };
|
|
441
|
-
}
|
|
442
632
|
const truncated = files.length > limit;
|
|
443
633
|
const shown = truncated ? files.slice(0, limit) : files;
|
|
444
634
|
const header = `Found ${files.length} file(s) under ${folder}`;
|
|
@@ -463,4 +653,70 @@ export class RpcHandler {
|
|
|
463
653
|
console.log(`[rpc] ${line}`);
|
|
464
654
|
}
|
|
465
655
|
}
|
|
656
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
657
|
+
// Local helpers (module-private)
|
|
658
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
659
|
+
function shellQuote(s) {
|
|
660
|
+
return /[\s'"`$\\]/.test(s) ? `'${s.replace(/'/g, "'\\''")}'` : s;
|
|
661
|
+
}
|
|
662
|
+
async function walkTree(dir, depth, prefix, out) {
|
|
663
|
+
if (depth <= 0)
|
|
664
|
+
return;
|
|
665
|
+
let entries;
|
|
666
|
+
try {
|
|
667
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
668
|
+
}
|
|
669
|
+
catch (err) {
|
|
670
|
+
out.push(`${prefix}[error: ${err.message}]`);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
entries.sort((a, b) => {
|
|
674
|
+
if (a.isDirectory() !== b.isDirectory())
|
|
675
|
+
return a.isDirectory() ? -1 : 1;
|
|
676
|
+
return a.name.localeCompare(b.name);
|
|
677
|
+
});
|
|
678
|
+
for (let i = 0; i < entries.length; i++) {
|
|
679
|
+
const e = entries[i];
|
|
680
|
+
const last = i === entries.length - 1;
|
|
681
|
+
const branch = last ? '└── ' : '├── ';
|
|
682
|
+
const next = last ? ' ' : '│ ';
|
|
683
|
+
out.push(`${prefix}${branch}${e.name}${e.isDirectory() ? '/' : ''}`);
|
|
684
|
+
if (e.isDirectory()) {
|
|
685
|
+
await walkTree(join(dir, e.name), depth - 1, prefix + next, out);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
function runGrepLike(argv, limit, resolveP) {
|
|
690
|
+
const proc = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
691
|
+
let stdout = '';
|
|
692
|
+
let stderr = '';
|
|
693
|
+
proc.stdout.on('data', (b) => { stdout += b.toString('utf-8'); });
|
|
694
|
+
proc.stderr.on('data', (b) => { stderr += b.toString('utf-8'); });
|
|
695
|
+
proc.on('close', (exitCode) => {
|
|
696
|
+
// grep / rg exit 1 when there are no matches — that's success for us.
|
|
697
|
+
const noMatches = exitCode === 1 && !stdout.trim();
|
|
698
|
+
if (noMatches) {
|
|
699
|
+
resolveP({ stdout: 'No matches found', stderr: '', exitCode: 0 });
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (exitCode !== 0 && exitCode !== 1) {
|
|
703
|
+
resolveP({ stdout: '', stderr: stderr || `[fs grep] exit ${exitCode}`, exitCode: exitCode ?? 1 });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const lines = stdout.split('\n');
|
|
707
|
+
if (lines.length > limit) {
|
|
708
|
+
const head = lines.slice(0, limit).join('\n');
|
|
709
|
+
resolveP({
|
|
710
|
+
stdout: `${head}\n\n[showing ${limit} of ${lines.length} lines; raise --limit or narrow the search]`,
|
|
711
|
+
stderr: '',
|
|
712
|
+
exitCode: 0,
|
|
713
|
+
});
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
resolveP({ stdout: stdout.trim() || 'No matches found', stderr: '', exitCode: 0 });
|
|
717
|
+
});
|
|
718
|
+
proc.on('error', (err) => {
|
|
719
|
+
resolveP({ stdout: '', stderr: `[fs grep] spawn ${argv[0]}: ${err.message}`, exitCode: 1 });
|
|
720
|
+
});
|
|
721
|
+
}
|
|
466
722
|
//# sourceMappingURL=rpc-handler.js.map
|