@ryuu-reinzz/ryuu-cli 0.0.0-stage → 0.3.1

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +560 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1213 -2
  4. package/package.json +45 -4
  5. package/src/agent/agent.js +1194 -0
  6. package/src/agent/approvals.js +76 -0
  7. package/src/agent/context.js +106 -0
  8. package/src/agent/planner.js +237 -0
  9. package/src/agent/worklog.js +318 -0
  10. package/src/cli/app.js +463 -0
  11. package/src/cli/commands.js +1150 -0
  12. package/src/cli/input.js +287 -0
  13. package/src/cli/markdown.js +527 -0
  14. package/src/cli/prompts.js +255 -0
  15. package/src/cli/renderer.js +2155 -0
  16. package/src/cli/settings-editor.js +280 -0
  17. package/src/cli/settings-tui.js +669 -0
  18. package/src/cli/tui.js +1737 -0
  19. package/src/cli/width.js +48 -0
  20. package/src/config/load.js +132 -0
  21. package/src/config/scaffold.js +50 -0
  22. package/src/config/settings-registry.js +495 -0
  23. package/src/config/settings.js +304 -0
  24. package/src/config/validate.js +451 -0
  25. package/src/index.js +449 -0
  26. package/src/providers/catalog.js +282 -0
  27. package/src/providers/index.js +84 -0
  28. package/src/providers/nvidia.js +645 -0
  29. package/src/providers/registry.js +104 -0
  30. package/src/services/clipboard.js +38 -0
  31. package/src/services/diff.js +153 -0
  32. package/src/services/doctor.js +126 -0
  33. package/src/services/logger.js +190 -0
  34. package/src/services/preferences.js +249 -0
  35. package/src/services/processes.js +345 -0
  36. package/src/services/provider-settings.js +226 -0
  37. package/src/services/sessions.js +279 -0
  38. package/src/services/undo.js +133 -0
  39. package/src/services/ushell.js +146 -0
  40. package/src/services/websearch/manager.js +616 -0
  41. package/src/services/websearch/mcp.js +233 -0
  42. package/src/services/websearch/normalize.js +284 -0
  43. package/src/services/websearch/providers.js +178 -0
  44. package/src/services/websearch/serper.js +263 -0
  45. package/src/services/workspace.js +589 -0
  46. package/src/tools/filesystem.js +304 -0
  47. package/src/tools/git.js +114 -0
  48. package/src/tools/index.js +48 -0
  49. package/src/tools/project.js +160 -0
  50. package/src/tools/registry.js +118 -0
  51. package/src/tools/search.js +210 -0
  52. package/src/tools/shell.js +279 -0
  53. package/src/tools/url.js +542 -0
  54. package/src/tools/web.js +67 -0
  55. package/src/utils/errors.js +160 -0
  56. package/src/utils/network.js +129 -0
  57. package/src/utils/redaction.js +73 -0
  58. package/src/utils/validation.js +370 -0
  59. package/src/utils/version.js +24 -0
@@ -0,0 +1,589 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import {
6
+ FileChangedError,
7
+ NotFoundError,
8
+ PathEscapeError,
9
+ SecretFileError,
10
+ ToolError,
11
+ } from '../utils/errors.js';
12
+
13
+ export const DEFAULT_IGNORED_DIRS = new Set([
14
+ '.git',
15
+ 'node_modules',
16
+ '.next',
17
+ '.nuxt',
18
+ '.cache',
19
+ '.ryuu',
20
+ '.venv',
21
+ 'venv',
22
+ '__pycache__',
23
+ '.pytest_cache',
24
+ '.mypy_cache',
25
+ 'dist',
26
+ 'build',
27
+ 'out',
28
+ 'coverage',
29
+ 'target',
30
+ 'vendor',
31
+ 'logs',
32
+ '.idea',
33
+ '.vscode',
34
+ ]);
35
+
36
+ const SAFE_ENV_TEMPLATES = new Set(['.env.example', '.env.sample', '.env.template', '.env.dist']);
37
+ const SECRET_BASENAME_PATTERNS = [
38
+ /^id_(rsa|dsa|ecdsa|ed25519)$/i,
39
+ /^\.npmrc$/i,
40
+ /^\.netrc$/i,
41
+ /^\.pgpass$/i,
42
+ /^\.htpasswd$/i,
43
+ /^credentials(\.json|\.ya?ml)?$/i,
44
+ /^service-account.*\.json$/i,
45
+ /^(secrets?|tokens?)\.(json|ya?ml|txt|ini)$/i,
46
+ ];
47
+ const SECRET_EXTENSIONS = /\.(pem|key|p12|pfx|jks|keystore|asc|gpg)$/i;
48
+ const SECRET_DIR_SEGMENTS = new Set(['.ssh', '.aws', '.gnupg', '.kube', '.docker']);
49
+ const CRLF_BYTES = Buffer.from('\r\n');
50
+
51
+ export function isSecretPath(relPath) {
52
+ const normalized = relPath.split(path.sep).join('/');
53
+ const segments = normalized.split('/');
54
+ const base = segments.at(-1) || '';
55
+ if (SAFE_ENV_TEMPLATES.has(base)) {
56
+ // .env.example is documentation, but never allow it to shadow a real secret.
57
+ return segments.some((segment) => SECRET_DIR_SEGMENTS.has(segment));
58
+ }
59
+ if (base === '.env' || base.startsWith('.env.')) return true;
60
+ if (SECRET_EXTENSIONS.test(base)) return true;
61
+ if (SECRET_BASENAME_PATTERNS.some((pattern) => pattern.test(base))) return true;
62
+ if (segments.some((segment) => SECRET_DIR_SEGMENTS.has(segment))) return true;
63
+ return false;
64
+ }
65
+
66
+ export function isIgnoredName(name) {
67
+ return DEFAULT_IGNORED_DIRS.has(name);
68
+ }
69
+
70
+ /**
71
+ * A Workspace is the only directory the agent may read or write.
72
+ * All tool paths are resolved through it; escapes (including symlinks) throw.
73
+ */
74
+ export class Workspace {
75
+ constructor(root, { logger } = {}) {
76
+ if (!root) throw new ToolError('Workspace root is required.');
77
+ const absolute = path.resolve(root);
78
+ if (!fs.existsSync(absolute)) throw new ToolError(`Workspace does not exist: ${absolute}`);
79
+ const stat = fs.statSync(absolute);
80
+ if (!stat.isDirectory()) throw new ToolError(`Workspace is not a directory: ${absolute}`);
81
+ this.root = fs.realpathSync(absolute);
82
+ this.logger = logger;
83
+ }
84
+
85
+ get label() {
86
+ return this.root;
87
+ }
88
+
89
+ /** Resolve a user/model supplied path, rejecting traversal and symlink escapes. */
90
+ resolve(inputPath = '.', { mustExist = false } = {}) {
91
+ if (typeof inputPath !== 'string') throw new ToolError('Path must be a string.');
92
+ if (inputPath.includes('\0')) throw new ToolError('Path contains a NUL byte.');
93
+ const requested = inputPath.trim() === '' ? '.' : inputPath.trim();
94
+ const absolute = path.resolve(this.root, requested);
95
+ const rel = path.relative(this.root, absolute);
96
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
97
+ throw new PathEscapeError(`Path escapes the workspace: ${inputPath}`, { workspace: this.root, requested });
98
+ }
99
+
100
+ // Resolve symlinks on the deepest existing ancestor, then re-check containment.
101
+ let ancestor = absolute;
102
+ const remainder = [];
103
+ while (!fs.existsSync(ancestor)) {
104
+ const parent = path.dirname(ancestor);
105
+ if (parent === ancestor) break;
106
+ remainder.unshift(path.basename(ancestor));
107
+ ancestor = parent;
108
+ }
109
+ let realAncestor;
110
+ try {
111
+ realAncestor = fs.realpathSync(ancestor);
112
+ } catch (error) {
113
+ throw new ToolError(`Cannot resolve path ${inputPath}: ${error.message}`);
114
+ }
115
+ const realTarget = path.join(realAncestor, ...remainder);
116
+ const realRel = path.relative(this.root, realTarget);
117
+ if (realRel.startsWith('..') || path.isAbsolute(realRel)) {
118
+ throw new PathEscapeError(`Path resolves outside the workspace via a symlink: ${inputPath}`, {
119
+ workspace: this.root,
120
+ resolved: realTarget,
121
+ });
122
+ }
123
+
124
+ if (mustExist && remainder.length > 0) {
125
+ throw new NotFoundError(`Path does not exist: ${inputPath}`);
126
+ }
127
+
128
+ const relPath = path.relative(this.root, absolute) || '.';
129
+ return { absPath: absolute, realPath: realTarget, relPath: relPath.split(path.sep).join('/') };
130
+ }
131
+
132
+ assertNotSecret(relPath) {
133
+ if (isSecretPath(relPath)) {
134
+ throw new SecretFileError(
135
+ `Access to secret file is blocked by policy: ${relPath}. Ryuu never reads or modifies credential files.`,
136
+ { path: relPath },
137
+ );
138
+ }
139
+ }
140
+
141
+ /** Resolve a path for reading, enforcing existence, type and secret rules. */
142
+ async resolveFile(inputPath) {
143
+ const resolved = this.resolve(inputPath);
144
+ this.assertNotSecret(resolved.relPath);
145
+ let linkStat;
146
+ try {
147
+ linkStat = await fsp.lstat(resolved.absPath);
148
+ } catch (error) {
149
+ if (error.code === 'ENOENT') throw new NotFoundError(`File not found: ${inputPath}`);
150
+ throw new ToolError(`Cannot stat ${inputPath}: ${error.message}`);
151
+ }
152
+ if (linkStat.isSymbolicLink()) throw new ToolError(`Refusing to follow symlink file: ${inputPath}`);
153
+ if (!linkStat.isFile()) throw new ToolError(`Not a regular file: ${inputPath}`);
154
+ const stat = await fsp.stat(resolved.absPath);
155
+ return { ...resolved, stat };
156
+ }
157
+
158
+ /** Read a UTF-8 text file with size and encoding checks. */
159
+ async readText(inputPath, { maxBytes = 262144 } = {}) {
160
+ const { absPath, relPath, stat } = await this.resolveFile(inputPath);
161
+ if (stat.size > maxBytes) {
162
+ throw new ToolError(
163
+ `File is too large to read (${stat.size} bytes > limit ${maxBytes}). Use search_files, or read a specific line range.`,
164
+ { path: relPath, size: stat.size, maxBytes },
165
+ );
166
+ }
167
+ const buffer = await fsp.readFile(absPath);
168
+ if (buffer.includes(0)) throw new ToolError(`File appears to be binary, not text: ${relPath}`);
169
+ let text;
170
+ try {
171
+ text = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
172
+ } catch {
173
+ throw new ToolError(`File is not valid UTF-8 text: ${relPath}`);
174
+ }
175
+ if (text.includes('\uFFFD')) {
176
+ throw new ToolError(`File contains undecodable characters (encoding is not UTF-8): ${relPath}`);
177
+ }
178
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
179
+ return { text, relPath, absPath, size: stat.size, mtimeMs: stat.mtimeMs, eol, lines: text.split(/\r?\n/).length };
180
+ }
181
+
182
+ /**
183
+ * Read a line range without loading the whole file into memory.
184
+ *
185
+ * A file above `maxBytes` cannot be read whole, but a range can still be
186
+ * streamed: the file is decoded chunk by chunk, binary and encoding problems
187
+ * are detected exactly as in readText(), only the requested lines are kept,
188
+ * and the total line count is reported so callers can paginate.
189
+ *
190
+ * The returned text is capped at `maxBytes`; `truncated` means there are more
191
+ * lines after `endLine`, and `capped` means the byte cap stopped collection
192
+ * before the requested end.
193
+ */
194
+ async readTextRange(inputPath, { startLine = 1, endLine = null, maxBytes = 262144 } = {}) {
195
+ const { absPath, relPath, stat } = await this.resolveFile(inputPath);
196
+ const decoder = new TextDecoder('utf-8', { fatal: true });
197
+ const lastWanted = endLine ?? Infinity;
198
+ const collected = [];
199
+ let collectedBytes = 0;
200
+ let pending = '';
201
+ let pendingBytes = 0;
202
+ let lineNumber = 1;
203
+ let lastReturned = 0;
204
+ let capped = false;
205
+ let sawCRLF = false;
206
+ let carryCR = false;
207
+
208
+ const wantsCurrentLine = () => !capped && lineNumber >= startLine && lineNumber <= lastWanted;
209
+
210
+ const appendSegment = (segment) => {
211
+ if (!segment || !wantsCurrentLine()) return;
212
+ pending += segment;
213
+ pendingBytes += Buffer.byteLength(segment, 'utf8');
214
+ // One line that cannot fit stops collection instead of buffering an
215
+ // unbounded amount of text.
216
+ if (pendingBytes > maxBytes) {
217
+ pending = '';
218
+ pendingBytes = 0;
219
+ capped = true;
220
+ }
221
+ };
222
+
223
+ const completeLine = () => {
224
+ if (wantsCurrentLine()) {
225
+ const line = pending.endsWith('\r') ? pending.slice(0, -1) : pending;
226
+ const separator = collected.length > 0 ? 1 : 0;
227
+ const bytes = Buffer.byteLength(line, 'utf8') + separator;
228
+ if (collectedBytes + bytes > maxBytes) {
229
+ capped = true;
230
+ } else {
231
+ collected.push(line);
232
+ collectedBytes += bytes;
233
+ lastReturned = lineNumber;
234
+ }
235
+ }
236
+ pending = '';
237
+ pendingBytes = 0;
238
+ lineNumber += 1;
239
+ };
240
+
241
+ for await (const chunk of fs.createReadStream(absPath, { highWaterMark: 64 * 1024 })) {
242
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
243
+ if (buffer.includes(0)) throw new ToolError(`File appears to be binary, not text: ${relPath}`);
244
+ if (!sawCRLF) {
245
+ if (buffer.includes(CRLF_BYTES)) sawCRLF = true;
246
+ else if (carryCR && buffer[0] === 0x0a) sawCRLF = true;
247
+ }
248
+ carryCR = buffer[buffer.length - 1] === 0x0d;
249
+
250
+ let text;
251
+ try {
252
+ text = decoder.decode(buffer, { stream: true });
253
+ } catch {
254
+ throw new ToolError(`File is not valid UTF-8 text: ${relPath}`);
255
+ }
256
+ let rest = text;
257
+ for (;;) {
258
+ const newline = rest.indexOf('\n');
259
+ if (newline === -1) break;
260
+ appendSegment(rest.slice(0, newline));
261
+ completeLine();
262
+ rest = rest.slice(newline + 1);
263
+ }
264
+ appendSegment(rest);
265
+ }
266
+ try {
267
+ appendSegment(decoder.decode());
268
+ } catch {
269
+ throw new ToolError(`File is not valid UTF-8 text: ${relPath}`);
270
+ }
271
+ completeLine();
272
+ const totalLines = lineNumber - 1;
273
+
274
+ if (startLine > totalLines) {
275
+ throw new ToolError(`start_line ${startLine} is beyond the end of ${relPath} (${totalLines} lines).`);
276
+ }
277
+ if (lastReturned < startLine) {
278
+ throw new ToolError(
279
+ `The range starting at line ${startLine} does not fit in ${maxBytes} bytes; one line is larger than the read limit. Raise max_bytes, or use search_files to locate the content.`,
280
+ { path: relPath, maxBytes },
281
+ );
282
+ }
283
+
284
+ return {
285
+ text: collected.join('\n'),
286
+ relPath,
287
+ absPath,
288
+ startLine,
289
+ endLine: lastReturned,
290
+ totalLines,
291
+ size: stat.size,
292
+ mtimeMs: stat.mtimeMs,
293
+ eol: sawCRLF ? '\r\n' : '\n',
294
+ truncated: lastReturned < totalLines,
295
+ capped,
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Write a text file atomically, optionally requiring the file to be unchanged.
301
+ * Preserves CRLF line endings and existing permissions where practical.
302
+ */
303
+ async writeText(inputPath, content, { expectedMtimeMs, atomic = true, allowCreate = true } = {}) {
304
+ const resolved = this.resolve(inputPath);
305
+ this.assertNotSecret(resolved.relPath);
306
+ if (typeof content !== 'string') throw new ToolError('File content must be a string.');
307
+
308
+ let existing = null;
309
+ let mode = 0o644;
310
+ try {
311
+ const stat = await fsp.lstat(resolved.absPath);
312
+ if (stat.isDirectory()) throw new ToolError(`Cannot write to a directory: ${resolved.relPath}`);
313
+ if (stat.isSymbolicLink()) throw new ToolError(`Refusing to write through a symlink: ${resolved.relPath}`);
314
+ existing = stat;
315
+ mode = stat.mode & 0o777;
316
+ } catch (error) {
317
+ if (error instanceof ToolError) throw error;
318
+ if (error.code !== 'ENOENT') throw new ToolError(`Cannot stat ${resolved.relPath}: ${error.message}`);
319
+ if (!allowCreate) throw new NotFoundError(`File not found: ${resolved.relPath}`);
320
+ }
321
+
322
+ if (expectedMtimeMs !== undefined && expectedMtimeMs !== null) {
323
+ if (!existing) throw new FileChangedError(`File was deleted since it was inspected: ${resolved.relPath}`);
324
+ if (existing.mtimeMs !== expectedMtimeMs) {
325
+ throw new FileChangedError(
326
+ `File changed on disk since it was inspected (mtime ${existing.mtimeMs} != ${expectedMtimeMs}). Re-read it and retry.`,
327
+ { path: resolved.relPath, expected: expectedMtimeMs, actual: existing.mtimeMs },
328
+ );
329
+ }
330
+ }
331
+
332
+ let finalContent = content;
333
+ if (existing) {
334
+ const previous = await fsp.readFile(resolved.absPath, 'utf8');
335
+ if (previous.includes('\r\n')) finalContent = content.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n');
336
+ }
337
+
338
+ await fsp.mkdir(path.dirname(resolved.absPath), { recursive: true });
339
+ let writtenPath = resolved.absPath;
340
+ if (atomic) {
341
+ const tmp = path.join(path.dirname(resolved.absPath), `.ryuu-tmp-${crypto.randomBytes(6).toString('hex')}`);
342
+ const handle = await fsp.open(tmp, 'wx', mode);
343
+ try {
344
+ await handle.writeFile(finalContent, 'utf8');
345
+ await handle.sync();
346
+ } finally {
347
+ await handle.close();
348
+ }
349
+ try {
350
+ await fsp.rename(tmp, resolved.absPath);
351
+ } catch (error) {
352
+ await fsp.rm(tmp, { force: true });
353
+ throw new ToolError(`Atomic write failed for ${resolved.relPath}: ${error.message}`);
354
+ }
355
+ writtenPath = resolved.absPath;
356
+ } else {
357
+ await fsp.writeFile(writtenPath, finalContent, { encoding: 'utf8', mode });
358
+ }
359
+
360
+ const stat = await fsp.stat(writtenPath);
361
+ return {
362
+ relPath: resolved.relPath,
363
+ absPath: writtenPath,
364
+ size: stat.size,
365
+ mtimeMs: stat.mtimeMs,
366
+ created: !existing,
367
+ lines: finalContent.split(/\r?\n/).length,
368
+ };
369
+ }
370
+
371
+ async createText(inputPath, content, { overwrite = false } = {}) {
372
+ const resolved = this.resolve(inputPath);
373
+ this.assertNotSecret(resolved.relPath);
374
+ if (!overwrite && fs.existsSync(resolved.absPath)) {
375
+ throw new ToolError(`File already exists: ${resolved.relPath}. Use write_file to replace it or set overwrite=true.`);
376
+ }
377
+ return this.writeText(inputPath, content);
378
+ }
379
+
380
+ async exists(inputPath) {
381
+ try {
382
+ const resolved = this.resolve(inputPath);
383
+ return fs.existsSync(resolved.absPath);
384
+ } catch {
385
+ return false;
386
+ }
387
+ }
388
+
389
+ async remove(inputPath) {
390
+ const resolved = this.resolve(inputPath);
391
+ this.assertNotSecret(resolved.relPath);
392
+ let stat;
393
+ try {
394
+ stat = await fsp.lstat(resolved.absPath);
395
+ } catch {
396
+ throw new NotFoundError(`Path not found: ${resolved.relPath}`);
397
+ }
398
+ if (stat.isSymbolicLink()) {
399
+ // Remove the link itself, never its target.
400
+ await fsp.unlink(resolved.absPath);
401
+ return { relPath: resolved.relPath, kind: 'symlink' };
402
+ }
403
+ if (stat.isDirectory()) {
404
+ const entries = await fsp.readdir(resolved.absPath);
405
+ if (entries.length > 0) {
406
+ throw new ToolError(`Directory is not empty: ${resolved.relPath}. Delete files individually.`);
407
+ }
408
+ await fsp.rmdir(resolved.absPath);
409
+ return { relPath: resolved.relPath, kind: 'directory' };
410
+ }
411
+ await fsp.unlink(resolved.absPath);
412
+ return { relPath: resolved.relPath, kind: 'file', size: stat.size };
413
+ }
414
+
415
+ async move(fromPath, toPath, { overwrite = false } = {}) {
416
+ const from = this.resolve(fromPath);
417
+ const to = this.resolve(toPath);
418
+ this.assertNotSecret(from.relPath);
419
+ this.assertNotSecret(to.relPath);
420
+ let stat;
421
+ try {
422
+ stat = await fsp.lstat(from.absPath);
423
+ } catch {
424
+ throw new NotFoundError(`Source not found: ${from.relPath}`);
425
+ }
426
+ if (!stat.isFile()) throw new ToolError('move_file only supports regular files.');
427
+ if (!overwrite && fs.existsSync(to.absPath)) {
428
+ throw new ToolError(`Destination already exists: ${to.relPath}`);
429
+ }
430
+ await fsp.mkdir(path.dirname(to.absPath), { recursive: true });
431
+ await fsp.rename(from.absPath, to.absPath);
432
+ return { from: from.relPath, to: to.relPath, size: stat.size };
433
+ }
434
+
435
+ /** List a directory with stable ordering, filtering and pagination. */
436
+ async list(inputPath = '.', { limit = 200, offset = 0, filter, includeGenerated = false, includeHidden = false } = {}) {
437
+ const resolved = this.resolve(inputPath);
438
+ let stat;
439
+ try {
440
+ stat = await fsp.stat(resolved.absPath);
441
+ } catch {
442
+ throw new NotFoundError(`Directory not found: ${resolved.relPath}`);
443
+ }
444
+ if (!stat.isDirectory()) throw new ToolError(`Not a directory: ${resolved.relPath}`);
445
+
446
+ const matcher = filter ? wildcardToRegExp(filter) : null;
447
+ const entries = await fsp.readdir(resolved.absPath, { withFileTypes: true });
448
+ const items = [];
449
+ for (const entry of entries) {
450
+ if (entry.name === '.' || entry.name === '..') continue;
451
+ if (entry.name.startsWith('.ryuu-tmp-')) continue;
452
+ if (!includeHidden && entry.name.startsWith('.')) continue;
453
+ const rel = resolved.relPath === '.' ? entry.name : `${resolved.relPath}/${entry.name}`;
454
+ if (!includeGenerated && (isIgnoredName(entry.name) || isSecretPath(rel))) continue;
455
+ if (matcher && !matcher.test(entry.name)) continue;
456
+ let kind = entry.isDirectory() ? 'dir' : entry.isFile() ? 'file' : entry.isSymbolicLink() ? 'symlink' : 'other';
457
+ let size = null;
458
+ if (kind === 'file') {
459
+ try {
460
+ size = (await fsp.stat(path.join(resolved.absPath, entry.name))).size;
461
+ } catch {
462
+ size = null;
463
+ }
464
+ }
465
+ items.push({ name: entry.name, path: rel, kind, size });
466
+ }
467
+ items.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1));
468
+ const total = items.length;
469
+ const page = items.slice(offset, offset + limit);
470
+ return {
471
+ path: resolved.relPath,
472
+ total,
473
+ offset,
474
+ limit,
475
+ entries: page,
476
+ truncated: offset + page.length < total,
477
+ ignored: includeGenerated ? [] : [...DEFAULT_IGNORED_DIRS].filter((name) => entries.some((e) => e.name === name)),
478
+ };
479
+ }
480
+
481
+ /** Bounded recursive tree. */
482
+ async tree(inputPath = '.', { depth = 3, maxEntries = 200, includeGenerated = false } = {}) {
483
+ const root = this.resolve(inputPath);
484
+ const stat = await fsp.stat(root.absPath).catch(() => null);
485
+ if (!stat || !stat.isDirectory()) throw new NotFoundError(`Directory not found: ${root.relPath}`);
486
+ const lines = [`${root.relPath === '.' ? path.basename(this.root) + '/' : root.relPath + '/'}`];
487
+ let count = 0;
488
+ let truncated = false;
489
+
490
+ const walk = async (absDir, prefix, level) => {
491
+ if (level > depth) return;
492
+ let entries;
493
+ try {
494
+ entries = await fsp.readdir(absDir, { withFileTypes: true });
495
+ } catch {
496
+ return;
497
+ }
498
+ entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
499
+ for (let i = 0; i < entries.length; i += 1) {
500
+ const entry = entries[i];
501
+ const rel = path.relative(this.root, path.join(absDir, entry.name)).split(path.sep).join('/');
502
+ if (isSecretPath(rel)) continue;
503
+ if (!includeGenerated && isIgnoredName(entry.name)) continue;
504
+ if (entry.name.startsWith('.ryuu-tmp-')) continue;
505
+ if (count >= maxEntries) {
506
+ truncated = true;
507
+ return;
508
+ }
509
+ const isLast = i === entries.length - 1;
510
+ const branch = isLast ? '└── ' : '├── ';
511
+ lines.push(`${prefix}${branch}${entry.name}${entry.isDirectory() ? '/' : ''}`);
512
+ count += 1;
513
+ if (entry.isDirectory() && level < depth) {
514
+ await walk(path.join(absDir, entry.name), `${prefix}${isLast ? ' ' : '│ '}`, level + 1);
515
+ }
516
+ }
517
+ };
518
+
519
+ await walk(root.absPath, '', 1);
520
+ if (truncated) lines.push(`… truncated after ${maxEntries} entries (raise max_entries or narrow the path)`);
521
+ return { path: root.relPath, text: lines.join('\n'), entries: count, truncated };
522
+ }
523
+
524
+ /** Metadata about a path (no content). */
525
+ async info(inputPath) {
526
+ const resolved = this.resolve(inputPath);
527
+ this.assertNotSecret(resolved.relPath);
528
+ let stat;
529
+ try {
530
+ stat = await fsp.lstat(resolved.absPath);
531
+ } catch {
532
+ throw new NotFoundError(`Path not found: ${resolved.relPath}`);
533
+ }
534
+ const kind = stat.isSymbolicLink() ? 'symlink' : stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'other';
535
+ const result = {
536
+ path: resolved.relPath,
537
+ kind,
538
+ size: stat.size,
539
+ createdAt: stat.birthtime.toISOString(),
540
+ modifiedAt: stat.mtime.toISOString(),
541
+ mode: (stat.mode & 0o777).toString(8),
542
+ };
543
+ if (kind === 'symlink') {
544
+ try {
545
+ const real = await fsp.realpath(resolved.absPath);
546
+ result.target = path.relative(this.root, real).split(path.sep).join('/') || '.';
547
+ result.insideWorkspace = !result.target.startsWith('..');
548
+ } catch {
549
+ result.target = '(unresolvable)';
550
+ }
551
+ }
552
+ if (kind === 'file') {
553
+ result.isProbablyText = await looksLikeText(resolved.absPath);
554
+ if (result.isProbablyText && stat.size <= 2 * 1024 * 1024) {
555
+ result.lines = await countLines(resolved.absPath);
556
+ }
557
+ }
558
+ return result;
559
+ }
560
+
561
+ /** Relative label for a path, or null if outside. */
562
+ toRelative(absPath) {
563
+ const rel = path.relative(this.root, absPath);
564
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
565
+ return rel.split(path.sep).join('/');
566
+ }
567
+ }
568
+
569
+ export function wildcardToRegExp(pattern) {
570
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
571
+ return new RegExp(`^${escaped}$`, 'i');
572
+ }
573
+
574
+ async function looksLikeText(file) {
575
+ const handle = await fsp.open(file, 'r');
576
+ try {
577
+ const buffer = Buffer.alloc(4096);
578
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
579
+ return !buffer.subarray(0, bytesRead).includes(0);
580
+ } finally {
581
+ await handle.close();
582
+ }
583
+ }
584
+
585
+ async function countLines(file) {
586
+ const text = await fsp.readFile(file, 'utf8');
587
+ if (text.length === 0) return 0;
588
+ return text.split(/\r?\n/).length;
589
+ }