@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.
@@ -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 (mkdir / ls / cat / write)
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 / cat (alias read) / mkdir / write / append
17
- // edit exact string replace, unique-match by default
18
- // grep — system rg (or fallback grep), line-numbered matches
19
- // glob — fast-glob multi-pattern + exclude
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 { join, resolve as resolvePath, isAbsolute } from 'path';
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 — falling back to bash via Cloud agent`,
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 exec --cmd="..."` or any other action where args.cmd is the
116
- * shell line. We deliberately only honour --cmd (not raw `command`)
117
- * to match the existing domain-RPC parser shape that other domains
118
- * use; if cmd is absent we fall back to req.command.
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
- const shellLine = req.parsed?.args?.cmd ?? req.command ?? '';
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], { stdio: ['ignore', 'pipe', 'pipe'] });
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> --path=... --content=...` minimal port of the same
156
- * actions the macOS FileBridge exposes (Server/agent/commands/fsCommands.ts
157
- * lists the canonical action set). Stage A: cat / ls / mkdir / write.
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 content = await fsp.readFile(safeJoinedPath, 'utf-8');
169
- return { stdout: content, stderr: '', exitCode: 0 };
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 'ls': {
172
- const entries = await fsp.readdir(safeJoinedPath, { withFileTypes: true });
173
- const lines = entries.map((e) => `${e.isDirectory() ? 'd' : '-'} ${e.name}`);
174
- return { stdout: lines.join('\n'), stderr: '', exitCode: 0 };
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
- await fsp.mkdir(safeJoinedPath, { recursive: true });
178
- return { stdout: `created ${safeJoinedPath}`, stderr: '', exitCode: 0 };
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 content = typeof args.content === 'string' ? args.content : '';
182
- const force = args.force === 'true';
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(safeJoinedPath);
186
- return { stdout: '', stderr: `[fs] ${safeJoinedPath} already exists; pass --force=true to overwrite`, exitCode: 1 };
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(safeJoinedPath, '..'), { recursive: true });
191
- await fsp.writeFile(safeJoinedPath, content, 'utf-8');
192
- return { stdout: `wrote ${content.length} bytes to ${safeJoinedPath}`, stderr: '', exitCode: 0 };
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 content = typeof args.content === 'string' ? args.content : '';
196
- await fsp.mkdir(join(safeJoinedPath, '..'), { recursive: true });
197
- await fsp.appendFile(safeJoinedPath, content, 'utf-8');
198
- return { stdout: `appended ${content.length} bytes to ${safeJoinedPath}`, stderr: '', exitCode: 0 };
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 oldStr = typeof args.old === 'string' ? args.old : '';
202
- const newStr = typeof args.new === 'string' ? args.new : '';
203
- const all = args.all === 'true';
204
- if (!oldStr) {
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(safeJoinedPath, 'utf-8');
416
+ original = await fsp.readFile(target, 'utf-8');
210
417
  }
211
418
  catch (err) {
212
- return { stdout: '', stderr: `[fs edit] cannot read ${safeJoinedPath}: ${err.message}`, exitCode: 1 };
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(safeJoinedPath, next, 'utf-8');
229
- return { stdout: `replaced 1 occurrence in ${safeJoinedPath}`, stderr: '', exitCode: 0 };
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
- const next = parts.join(newStr);
238
- await fsp.writeFile(safeJoinedPath, next, 'utf-8');
239
- return { stdout: `replaced ${count} occurrence(s) in ${safeJoinedPath}`, stderr: '', exitCode: 0 };
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 { stdout: '', stderr: `[fs] unknown action: ${action}`, exitCode: 127 };
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
- return { stdout: '', stderr: `[fs] ${err.message}`, exitCode: 1 };
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]` — shells out to ripgrep when present,
256
- * otherwise falls back to /usr/bin/grep so the action stays useful on
257
- * minimal Linux images. Returns line-anchored matches identical to
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
- // commandParser stores the bare positional under args.positional;
264
- // accept --pattern=... too for symmetry.
265
- const pattern = typeof args.pattern === 'string' && args.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 pathArg = typeof args.path === 'string' && args.path ? args.path : process.cwd();
272
- const searchPath = isAbsolute(pathArg) ? pathArg : resolvePath(process.cwd(), pathArg);
273
- const caseInsensitive = args.i === 'true';
274
- const fixed = args.fixed === 'true';
275
- const globFilter = typeof args.glob === 'string' ? args.glob : '';
276
- const typeFilter = typeof args.type === 'string' ? args.type : '';
277
- const contextRaw = typeof args.context === 'string' ? Number.parseInt(args.context, 10) : NaN;
278
- const ctx = Number.isFinite(contextRaw) && contextRaw >= 0 ? contextRaw : 0;
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
- const proc = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'pipe'] });
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
- const proc = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'pipe'] });
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. Returns sorted relative file paths
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 parsedArr = JSON.parse(s);
391
- if (Array.isArray(parsedArr))
392
- return parsedArr.map(String);
581
+ const arr = JSON.parse(s);
582
+ if (Array.isArray(arr))
583
+ return arr.map(String);
393
584
  }
394
- catch { /* fall through to comma split */ }
585
+ catch { /* fall through */ }
395
586
  }
396
587
  return s.split(',').map((q) => q.trim()).filter(Boolean);
397
588
  }
398
- if (typeof args.positional === 'string' && args.positional.trim()) {
399
- const list = [args.positional.trim()];
400
- if (typeof args.positional2 === 'string' && args.positional2.trim()) {
401
- list.push(args.positional2.trim());
402
- }
403
- return list;
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 folderArg = typeof args.folder === 'string' && args.folder ? args.folder : process.cwd();
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 parsedArr = JSON.parse(s);
420
- if (Array.isArray(parsedArr))
421
- return [...base, ...parsedArr.map(String)];
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