@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,304 @@
1
+ import { defineTool } from './registry.js';
2
+ import { createUnifiedDiff, diffStats } from '../services/diff.js';
3
+ import { ToolError } from '../utils/errors.js';
4
+
5
+ const pathSchema = { type: 'string', minLength: 1, maxLength: 4096, description: 'Path relative to the workspace root.' };
6
+
7
+ const objectSchema = (properties, required = []) => ({
8
+ type: 'object',
9
+ properties,
10
+ required,
11
+ additionalProperties: false,
12
+ });
13
+
14
+ const MAX_WRITE_CHARS = 1_000_000;
15
+
16
+ function assertContentSize(content) {
17
+ if (content.length > MAX_WRITE_CHARS) {
18
+ throw new ToolError(`Content is too large (${content.length} chars, max ${MAX_WRITE_CHARS}). Split the change into smaller files.`);
19
+ }
20
+ }
21
+
22
+ function previewDiff(relPath, before, after) {
23
+ const diff = createUnifiedDiff(before, after, { oldLabel: `a/${relPath}`, newLabel: `b/${relPath}` });
24
+ if (!diff) return `(no changes) ${relPath} is already up to date`;
25
+ const stats = diffStats(before, after);
26
+ return `${diff}\n\n(${stats.added} added, ${stats.removed} removed)`;
27
+ }
28
+
29
+ /**
30
+ * @param {object} deps
31
+ * @param {import('../services/workspace.js').Workspace} deps.workspace
32
+ * @param {object} deps.config
33
+ */
34
+ export function createFilesystemTools({ workspace, config }) {
35
+ async function computeEdit({ path: target, old_text: original, new_text: replacement, replace_all = false }) {
36
+ const file = await workspace.readText(target, { maxBytes: config.maxFileBytes });
37
+ if (original === replacement) throw new ToolError('old_text and new_text are identical; nothing to change.');
38
+ let occurrences = 0;
39
+ let index = file.text.indexOf(original);
40
+ while (index !== -1) {
41
+ occurrences += 1;
42
+ index = file.text.indexOf(original, index + original.length);
43
+ }
44
+ if (occurrences === 0) {
45
+ throw new ToolError(
46
+ `old_text was not found in ${file.relPath}. Re-read the file and copy the exact text (including indentation).`,
47
+ );
48
+ }
49
+ if (occurrences > 1 && !replace_all) {
50
+ throw new ToolError(
51
+ `old_text matches ${occurrences} locations in ${file.relPath}. Include more surrounding context or set replace_all=true.`,
52
+ );
53
+ }
54
+ const after = replace_all
55
+ ? file.text.split(original).join(replacement)
56
+ : file.text.slice(0, file.text.indexOf(original)) +
57
+ replacement +
58
+ file.text.slice(file.text.indexOf(original) + original.length);
59
+ return { file, after, occurrences };
60
+ }
61
+
62
+ return [
63
+ defineTool({
64
+ name: 'list_files',
65
+ description: 'List files and directories inside the workspace. Supports substring/wildcard filtering and pagination. Secret files and generated directories are excluded by default.',
66
+ risk: 'read',
67
+ schema: objectSchema({
68
+ path: { ...pathSchema, description: 'Directory to list (default workspace root).' },
69
+ filter: { type: 'string', maxLength: 200, description: 'Optional case-insensitive name filter; * and ? wildcards allowed.' },
70
+ limit: { type: 'integer', minimum: 1, maximum: 500, description: 'Maximum entries to return (default 200).' },
71
+ offset: { type: 'integer', minimum: 0, maximum: 100000, description: 'Pagination offset.' },
72
+ include_generated: { type: 'boolean', description: 'Include node_modules, .git, dist, etc. (usually not needed).' },
73
+ }),
74
+ handler: async (args) => workspace.list(args.path || '.', {
75
+ limit: Math.min(args.limit ?? 200, 500),
76
+ offset: args.offset ?? 0,
77
+ filter: args.filter,
78
+ includeGenerated: args.include_generated ?? false,
79
+ includeHidden: args.include_generated ?? false,
80
+ }),
81
+ }),
82
+
83
+ defineTool({
84
+ name: 'tree',
85
+ description: 'Show a bounded directory tree of the workspace.',
86
+ risk: 'read',
87
+ schema: objectSchema({
88
+ path: { ...pathSchema, description: 'Directory to print (default workspace root).' },
89
+ depth: { type: 'integer', minimum: 1, maximum: 8, description: 'Maximum depth (default 3).' },
90
+ max_entries: { type: 'integer', minimum: 1, maximum: 2000, description: 'Maximum entries (default 200).' },
91
+ include_generated: { type: 'boolean' },
92
+ }),
93
+ handler: async (args) => workspace.tree(args.path || '.', {
94
+ depth: args.depth ?? 3,
95
+ maxEntries: Math.min(args.max_entries ?? 200, 2000),
96
+ includeGenerated: args.include_generated ?? false,
97
+ }),
98
+ }),
99
+
100
+ defineTool({
101
+ name: 'read_file',
102
+ description:
103
+ 'Read a UTF-8 text file from the workspace. Refuses secret files, binary files, and invalid encodings. A full read refuses files above the configured size limit; with start_line/end_line the requested range is streamed, so it also works for files above that limit.',
104
+ risk: 'read',
105
+ schema: objectSchema({
106
+ path: pathSchema,
107
+ start_line: { type: 'integer', minimum: 1, description: 'First line to return (1-based).' },
108
+ end_line: { type: 'integer', minimum: 1, description: 'Last line to return (inclusive).' },
109
+ max_bytes: { type: 'integer', minimum: 1, maximum: 5 * 1024 * 1024, description: 'Per-call read/output limit.' },
110
+ }, ['path']),
111
+ handler: async (args) => {
112
+ const maxBytes = Math.min(args.max_bytes ?? config.maxFileBytes, config.maxFileBytes);
113
+ const startLine = args.start_line ?? 1;
114
+ const endLine = args.end_line ?? null;
115
+ if (endLine !== null && endLine < startLine) {
116
+ throw new ToolError('end_line must be greater than or equal to start_line.');
117
+ }
118
+
119
+ // A requested range is streamed: only those lines are decoded and kept,
120
+ // so the size limit no longer blocks reading part of a large file.
121
+ if (args.start_line !== undefined || args.end_line !== undefined) {
122
+ const range = await workspace.readTextRange(args.path, { startLine, endLine, maxBytes });
123
+ return {
124
+ path: range.relPath,
125
+ start_line: range.startLine,
126
+ end_line: range.endLine,
127
+ total_lines: range.totalLines,
128
+ size: range.size,
129
+ eol: range.eol === '\r\n' ? 'CRLF' : 'LF',
130
+ truncated: range.truncated,
131
+ ...(range.capped
132
+ ? { note: `The requested range exceeds the ${maxBytes}-byte read limit; read a narrower range for the rest.` }
133
+ : {}),
134
+ text: range.text,
135
+ };
136
+ }
137
+
138
+ const file = await workspace.readText(args.path, { maxBytes });
139
+ const lines = file.text.split(/\r?\n/);
140
+ if (startLine > lines.length && lines.length > 0) {
141
+ throw new ToolError(`start_line ${startLine} is beyond the end of ${file.relPath} (${lines.length} lines).`);
142
+ }
143
+ const end = Math.min(endLine ?? lines.length, lines.length);
144
+ const slice = lines.slice(startLine - 1, end);
145
+ return {
146
+ path: file.relPath,
147
+ start_line: startLine,
148
+ end_line: end,
149
+ total_lines: lines.length,
150
+ size: file.size,
151
+ eol: file.eol === '\r\n' ? 'CRLF' : 'LF',
152
+ truncated: end < lines.length,
153
+ text: slice.join('\n'),
154
+ };
155
+ },
156
+ }),
157
+
158
+ defineTool({
159
+ name: 'file_info',
160
+ description: 'Report metadata for a workspace path: kind, size, timestamps, line count, text/binary hint.',
161
+ risk: 'read',
162
+ schema: objectSchema({ path: pathSchema }, ['path']),
163
+ handler: async (args) => workspace.info(args.path),
164
+ }),
165
+
166
+ defineTool({
167
+ name: 'create_file',
168
+ description: 'Create a new text file in the workspace. Fails if the file already exists unless overwrite=true.',
169
+ risk: 'write',
170
+ schema: objectSchema({
171
+ path: pathSchema,
172
+ content: { type: 'string', maxLength: MAX_WRITE_CHARS },
173
+ overwrite: { type: 'boolean', description: 'Replace an existing file (default false).' },
174
+ }, ['path', 'content']),
175
+ preview: async (args) => {
176
+ let before = '';
177
+ try {
178
+ const existing = await workspace.readText(args.path, { maxBytes: config.maxFileBytes });
179
+ if (!args.overwrite) return `File already exists: ${args.path}. create_file would fail; use edit_file or write_file.`;
180
+ before = existing.text;
181
+ } catch {
182
+ before = '';
183
+ }
184
+ return previewDiff(args.path, before, args.content);
185
+ },
186
+ handler: async (args) => {
187
+ assertContentSize(args.content);
188
+ return workspace.createText(args.path, args.content, { overwrite: args.overwrite ?? false });
189
+ },
190
+ }),
191
+
192
+ defineTool({
193
+ name: 'write_file',
194
+ description: 'Write a complete text file, replacing existing content. Provide expected_mtime_ms from a previous read/info call to prevent silently overwriting concurrent edits.',
195
+ risk: 'write',
196
+ schema: objectSchema({
197
+ path: pathSchema,
198
+ content: { type: 'string', maxLength: MAX_WRITE_CHARS },
199
+ expected_mtime_ms: { type: 'number', description: 'If set, abort when the file mtime differs.' },
200
+ }, ['path', 'content']),
201
+ preview: async (args) => {
202
+ let before = '';
203
+ try {
204
+ before = (await workspace.readText(args.path, { maxBytes: config.maxFileBytes })).text;
205
+ } catch {
206
+ before = '';
207
+ }
208
+ return previewDiff(args.path, before, args.content);
209
+ },
210
+ handler: async (args) => {
211
+ assertContentSize(args.content);
212
+ return workspace.writeText(args.path, args.content, { expectedMtimeMs: args.expected_mtime_ms });
213
+ },
214
+ }),
215
+
216
+ defineTool({
217
+ name: 'edit_file',
218
+ description: 'Apply a targeted text replacement. old_text must match exactly (including indentation); it must be unique unless replace_all=true. Returns the resulting diff and new mtime. Refuses to overwrite concurrent edits.',
219
+ risk: 'write',
220
+ schema: objectSchema({
221
+ path: pathSchema,
222
+ old_text: { type: 'string', minLength: 1, maxLength: 100000 },
223
+ new_text: { type: 'string', maxLength: 100000 },
224
+ replace_all: { type: 'boolean', description: 'Replace every occurrence (default false).' },
225
+ expected_mtime_ms: { type: 'number', description: 'Abort if the file changed since this mtime.' },
226
+ }, ['path', 'old_text', 'new_text']),
227
+ preview: async (args) => {
228
+ const { file, after } = await computeEdit(args);
229
+ return previewDiff(file.relPath, file.text, after);
230
+ },
231
+ handler: async (args) => {
232
+ assertContentSize(args.new_text);
233
+ const { file, after, occurrences } = await computeEdit(args);
234
+ const written = await workspace.writeText(args.path, after, {
235
+ expectedMtimeMs: args.expected_mtime_ms ?? file.mtimeMs,
236
+ });
237
+ return {
238
+ path: written.relPath,
239
+ occurrences,
240
+ bytes_written: written.size,
241
+ lines: written.lines,
242
+ mtime_ms: written.mtimeMs,
243
+ diff: createUnifiedDiff(file.text, after, { oldLabel: `a/${written.relPath}`, newLabel: `b/${written.relPath}`, maxLines: 200 }),
244
+ };
245
+ },
246
+ }),
247
+
248
+ defineTool({
249
+ name: 'diff_file',
250
+ description: 'Show a unified diff without writing anything. Compares the current file against proposed new content, or against provided old/new text.',
251
+ risk: 'read',
252
+ schema: objectSchema({
253
+ path: pathSchema,
254
+ new_text: { type: 'string', maxLength: MAX_WRITE_CHARS, description: 'Proposed full file content.' },
255
+ old_text: { type: 'string', maxLength: MAX_WRITE_CHARS, description: 'Optional explicit old content; defaults to the file on disk.' },
256
+ }, ['path']),
257
+ handler: async (args) => {
258
+ let before = args.old_text;
259
+ if (before === undefined) {
260
+ before = (await workspace.readText(args.path, { maxBytes: config.maxFileBytes })).text;
261
+ }
262
+ if (args.new_text === undefined) throw new ToolError('new_text is required.');
263
+ const diff = createUnifiedDiff(before, args.new_text, { oldLabel: `a/${args.path}`, newLabel: `b/${args.path}` });
264
+ return { path: args.path, diff: diff || '(no changes)', stats: diffStats(before, args.new_text) };
265
+ },
266
+ }),
267
+
268
+ defineTool({
269
+ name: 'move_file',
270
+ description: 'Move or rename a file inside the workspace. Requires approval.',
271
+ risk: 'destructive',
272
+ schema: objectSchema({
273
+ from: pathSchema,
274
+ to: pathSchema,
275
+ overwrite: { type: 'boolean' },
276
+ }, ['from', 'to']),
277
+ preview: async (args) => `Move ${args.from} -> ${args.to}${args.overwrite ? ' (may overwrite destination)' : ''}`,
278
+ handler: async (args) => workspace.move(args.from, args.to, { overwrite: args.overwrite ?? false }),
279
+ }),
280
+
281
+ defineTool({
282
+ name: 'delete_file',
283
+ description: 'Delete a single file or an empty directory inside the workspace. Requires approval. Never follows symlinks.',
284
+ risk: 'destructive',
285
+ schema: objectSchema({
286
+ path: pathSchema,
287
+ confirm: { type: 'boolean', description: 'Must be true; a second explicit acknowledgement.' },
288
+ }, ['path', 'confirm']),
289
+ preview: async (args) => {
290
+ if (args.confirm !== true) return `delete_file requires confirm=true for ${args.path}`;
291
+ try {
292
+ const info = await workspace.info(args.path);
293
+ return `Delete ${info.kind} ${info.path} (${info.size} bytes)`;
294
+ } catch {
295
+ return `Delete ${args.path} (path not found)`;
296
+ }
297
+ },
298
+ handler: async (args) => {
299
+ if (args.confirm !== true) throw new ToolError('delete_file requires confirm=true.');
300
+ return workspace.remove(args.path);
301
+ },
302
+ }),
303
+ ];
304
+ }
@@ -0,0 +1,114 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { defineTool } from './registry.js';
4
+ import { ToolError } from '../utils/errors.js';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const MAX_BUFFER = 2 * 1024 * 1024;
8
+ const GIT_TIMEOUT_MS = 15000;
9
+
10
+ async function runGit(args, { cwd, timeout = GIT_TIMEOUT_MS } = {}) {
11
+ try {
12
+ const { stdout, stderr } = await execFileAsync('git', args, {
13
+ cwd,
14
+ timeout,
15
+ maxBuffer: MAX_BUFFER,
16
+ windowsHide: true,
17
+ env: { ...process.env, GIT_PAGER: 'cat', GIT_TERMINAL_PROMPT: '0' },
18
+ });
19
+ return { ok: true, stdout, stderr, code: 0 };
20
+ } catch (error) {
21
+ if (error.code === 'ENOENT') {
22
+ return { ok: false, available: false, stdout: '', stderr: 'git is not installed or not on PATH', code: null };
23
+ }
24
+ if (error.killed) {
25
+ return { ok: false, stdout: error.stdout || '', stderr: `git command timed out after ${timeout}ms`, code: null };
26
+ }
27
+ return {
28
+ ok: false,
29
+ stdout: error.stdout || '',
30
+ stderr: error.stderr || error.message,
31
+ code: typeof error.code === 'number' ? error.code : null,
32
+ };
33
+ }
34
+ }
35
+
36
+ function boundLines(text, maxLines) {
37
+ const lines = String(text || '').split('\n');
38
+ if (lines.length <= maxLines) return { text: lines.join('\n'), truncated: false };
39
+ return { text: `${lines.slice(0, maxLines).join('\n')}\n…[diff truncated]`, truncated: true };
40
+ }
41
+
42
+ export function createGitTools({ workspace, logger = null }) {
43
+ return [
44
+ defineTool({
45
+ name: 'inspect_git',
46
+ description:
47
+ 'Read-only Git inspection: status, diff, log, show or branch. Never runs destructive git commands. Returns available=false when Git is not installed.',
48
+ risk: 'read',
49
+ schema: {
50
+ type: 'object',
51
+ properties: {
52
+ action: { type: 'string', enum: ['status', 'diff', 'log', 'show', 'branch'] },
53
+ path: { type: 'string', minLength: 1, maxLength: 4096, description: 'Optional workspace path to scope diff/status.' },
54
+ staged: { type: 'boolean', description: 'Show staged diff (diff action only).' },
55
+ max_lines: { type: 'integer', minimum: 20, maximum: 2000, description: 'Diff/log line cap (default 200).' },
56
+ limit: { type: 'integer', minimum: 1, maximum: 100, description: 'Log entry count (default 10).' },
57
+ },
58
+ required: ['action'],
59
+ additionalProperties: false,
60
+ },
61
+ handler: async (args) => {
62
+ const maxLines = args.max_lines ?? 200;
63
+ let relPath = null;
64
+ if (args.path) relPath = workspace.resolve(args.path).relPath;
65
+ let gitArgs;
66
+ switch (args.action) {
67
+ case 'status':
68
+ gitArgs = ['status', '--porcelain=v1', '--branch', ...(relPath ? ['--', relPath] : [])];
69
+ break;
70
+ case 'diff':
71
+ gitArgs = ['diff', '--no-color', '--stat', ...(args.staged ? ['--cached'] : []), ...(relPath ? ['--', relPath] : [])];
72
+ break;
73
+ case 'log':
74
+ gitArgs = ['log', '--oneline', '--decorate', '-n', String(args.limit ?? 10)];
75
+ break;
76
+ case 'show':
77
+ gitArgs = ['show', '--stat', '--no-color', 'HEAD'];
78
+ break;
79
+ case 'branch':
80
+ gitArgs = ['branch', '--no-color', '-vv'];
81
+ break;
82
+ default:
83
+ throw new ToolError(`Unsupported git action: ${args.action}`);
84
+ }
85
+
86
+ const result = await runGit(gitArgs, { cwd: workspace.root });
87
+ if (result.available === false) {
88
+ return { available: false, action: args.action, message: 'Git is not installed or not available on PATH.' };
89
+ }
90
+ const bounded = boundLines(result.stdout, maxLines);
91
+ let detail = null;
92
+ if (args.action === 'diff' && result.ok) {
93
+ const full = await runGit(
94
+ ['diff', '--no-color', ...(args.staged ? ['--cached'] : []), ...(relPath ? ['--', relPath] : [])],
95
+ { cwd: workspace.root },
96
+ );
97
+ detail = boundLines(full.stdout, maxLines);
98
+ }
99
+ logger?.debug('git inspected', { action: args.action, ok: result.ok });
100
+ return {
101
+ available: true,
102
+ action: args.action,
103
+ ok: result.ok,
104
+ exit_code: result.code,
105
+ cwd: workspace.root,
106
+ output: bounded.text,
107
+ truncated: bounded.truncated || Boolean(detail?.truncated),
108
+ ...(detail ? { diff: detail.text } : {}),
109
+ ...(result.stderr ? { stderr: result.stderr.slice(0, 2000) } : {}),
110
+ };
111
+ },
112
+ }),
113
+ ];
114
+ }
@@ -0,0 +1,48 @@
1
+ import { ToolRegistry } from './registry.js';
2
+ import { createFilesystemTools } from './filesystem.js';
3
+ import { createSearchTools } from './search.js';
4
+ import { createGitTools } from './git.js';
5
+ import { createProjectTools } from './project.js';
6
+ import { createShellTools } from './shell.js';
7
+ import { createWebTools } from './web.js';
8
+ import { createUrlTools } from './url.js';
9
+ import { createWebSearchManager } from '../services/websearch/manager.js';
10
+
11
+ /**
12
+ * Build the complete built-in tool registry. Collisions throw here.
13
+ *
14
+ * One WebSearchManager is shared by `web_search` and `read_url`, so provider
15
+ * connections are reused and a page-extraction fallback can use a provider
16
+ * discovered by an earlier search. The manager is exposed on the registry so
17
+ * `/web` and `close()` can reach the same instance.
18
+ */
19
+ export function buildToolRegistry({
20
+ workspace,
21
+ config,
22
+ processes,
23
+ logger = null,
24
+ webManager = null,
25
+ onEvent = null,
26
+ }) {
27
+ const registry = new ToolRegistry({ logger });
28
+ const manager = webManager || createWebSearchManager({ config, logger, onEvent });
29
+ const groups = [
30
+ createFilesystemTools({ workspace, config }),
31
+ createSearchTools({ workspace, config, logger }),
32
+ createGitTools({ workspace, logger }),
33
+ createProjectTools({ workspace, logger }),
34
+ createShellTools({ workspace, config, processes, logger }),
35
+ // read_url needs no key, so it is always available: search snippets are
36
+ // leads, and the model must be able to open the source page. When a local
37
+ // fetch cannot read the page, an extraction-capable provider is tried.
38
+ createUrlTools({ logger, webManager: manager }),
39
+ // Empty when no provider is configured: the model never sees a tool that
40
+ // cannot run, and the system prompt catalog stays accurate.
41
+ createWebTools({ config, logger, manager }),
42
+ ];
43
+ for (const tools of groups) {
44
+ for (const tool of tools) registry.register(tool);
45
+ }
46
+ registry.webSearchManager = manager;
47
+ return registry;
48
+ }
@@ -0,0 +1,160 @@
1
+ import fsp from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { defineTool } from './registry.js';
4
+ import { isIgnoredName, isSecretPath } from '../services/workspace.js';
5
+ import { ToolError } from '../utils/errors.js';
6
+
7
+ const LANGUAGE_BY_EXT = {
8
+ '.js': 'JavaScript', '.mjs': 'JavaScript', '.cjs': 'JavaScript',
9
+ '.ts': 'TypeScript', '.tsx': 'TypeScript', '.jsx': 'JavaScript',
10
+ '.py': 'Python', '.go': 'Go', '.rs': 'Rust', '.java': 'Java', '.rb': 'Ruby',
11
+ '.php': 'PHP', '.cs': 'C#', '.cpp': 'C++', '.c': 'C', '.sh': 'Shell',
12
+ '.css': 'CSS', '.scss': 'SCSS', '.html': 'HTML', '.md': 'Markdown',
13
+ '.json': 'JSON', '.yml': 'YAML', '.yaml': 'YAML', '.sql': 'SQL',
14
+ };
15
+
16
+ const MAX_FILES = 3000;
17
+
18
+ export async function buildProjectSummary({ workspace }) {
19
+ const summary = {
20
+ workspace: workspace.root,
21
+ name: null,
22
+ version: null,
23
+ description: null,
24
+ packageManager: null,
25
+ scripts: {},
26
+ dependencies: [],
27
+ devDependencies: [],
28
+ engines: null,
29
+ entryPoints: [],
30
+ languages: {},
31
+ modules: [],
32
+ topLevel: [],
33
+ totalFilesScanned: 0,
34
+ hasReadme: false,
35
+ hasTests: false,
36
+ hasGit: false,
37
+ truncated: false,
38
+ };
39
+
40
+ try {
41
+ const listing = await workspace.list('.', { limit: 50, includeGenerated: false, includeHidden: true });
42
+ summary.topLevel = listing.entries.map((entry) => `${entry.kind === 'dir' ? 'dir ' : 'file'} ${entry.name}`);
43
+ summary.hasReadme = listing.entries.some((entry) => /^readme(\.(md|txt|rst))?$/i.test(entry.name));
44
+ summary.hasTests = listing.entries.some((entry) => /^(tests?|__tests__|spec)$/i.test(entry.name));
45
+ // Immediate sub-directories of every top-level directory: the module
46
+ // layout the model needs to know (e.g. plugins/<category>/<command>).
47
+ for (const entry of listing.entries) {
48
+ if (entry.kind !== 'dir') continue;
49
+ if (summary.modules.length >= 120) break;
50
+ const children = await workspace.list(entry.name, { limit: 60, includeGenerated: false, includeHidden: false });
51
+ for (const child of children.entries) {
52
+ if (child.kind !== 'dir') continue;
53
+ summary.modules.push(`${entry.name}/${child.name}`);
54
+ if (summary.modules.length >= 120) break;
55
+ }
56
+ }
57
+ } catch {
58
+ // Listing failures are reported by the languages walk below.
59
+ }
60
+
61
+ summary.hasGit = await fileExists(path.join(workspace.root, '.git'));
62
+
63
+ let pkg = null;
64
+ try {
65
+ pkg = JSON.parse(await fsp.readFile(path.join(workspace.root, 'package.json'), 'utf8'));
66
+ } catch {
67
+ pkg = null;
68
+ }
69
+ if (pkg) {
70
+ summary.name = pkg.name || null;
71
+ summary.version = pkg.version || null;
72
+ summary.description = pkg.description || null;
73
+ summary.scripts = pkg.scripts || {};
74
+ summary.dependencies = Object.keys(pkg.dependencies || {}).sort();
75
+ summary.devDependencies = Object.keys(pkg.devDependencies || {}).sort();
76
+ summary.engines = pkg.engines || null;
77
+ summary.packageManager = pkg.packageManager || null;
78
+ summary.entryPoints = [pkg.main, pkg.module, pkg.bin && (typeof pkg.bin === 'string' ? pkg.bin : Object.values(pkg.bin)[0])]
79
+ .filter(Boolean);
80
+ }
81
+ if (!summary.packageManager) {
82
+ for (const [file, manager] of [['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['package-lock.json', 'npm'], ['bun.lockb', 'bun']]) {
83
+ if (await fileExists(path.join(workspace.root, file))) {
84
+ summary.packageManager = manager;
85
+ break;
86
+ }
87
+ }
88
+ }
89
+
90
+ const stack = [workspace.root];
91
+ let scanned = 0;
92
+ while (stack.length > 0 && scanned < MAX_FILES) {
93
+ const dir = stack.pop();
94
+ let entries;
95
+ try {
96
+ entries = await fsp.readdir(dir, { withFileTypes: true });
97
+ } catch {
98
+ continue;
99
+ }
100
+ for (const entry of entries) {
101
+ if (entry.isSymbolicLink()) continue;
102
+ if (entry.isDirectory()) {
103
+ if (isIgnoredName(entry.name)) continue;
104
+ stack.push(path.join(dir, entry.name));
105
+ continue;
106
+ }
107
+ if (!entry.isFile()) continue;
108
+ const rel = path.relative(workspace.root, path.join(dir, entry.name)).split(path.sep).join('/');
109
+ if (isSecretPath(rel)) continue;
110
+ scanned += 1;
111
+ if (scanned >= MAX_FILES) {
112
+ summary.truncated = true;
113
+ break;
114
+ }
115
+ const language = LANGUAGE_BY_EXT[path.extname(entry.name).toLowerCase()];
116
+ if (language) summary.languages[language] = (summary.languages[language] || 0) + 1;
117
+ }
118
+ }
119
+ summary.totalFilesScanned = scanned;
120
+ summary.languages = Object.fromEntries(
121
+ Object.entries(summary.languages).sort((a, b) => b[1] - a[1]),
122
+ );
123
+ return summary;
124
+ }
125
+
126
+ async function fileExists(target) {
127
+ try {
128
+ await fsp.access(target);
129
+ return true;
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ export function createProjectTools({ workspace }) {
136
+ return [
137
+ defineTool({
138
+ name: 'project_summary',
139
+ description:
140
+ 'Summarize the project from real files: manifests, scripts, dependencies, entry points, language mix, top-level layout, tests and Git presence. Use this instead of guessing.',
141
+ risk: 'read',
142
+ schema: {
143
+ type: 'object',
144
+ properties: {
145
+ path: { type: 'string', minLength: 1, maxLength: 4096, description: 'Subdirectory to summarize (default workspace root).' },
146
+ },
147
+ additionalProperties: false,
148
+ },
149
+ handler: async (args) => {
150
+ if (args.path && args.path !== '.') {
151
+ const resolved = workspace.resolve(args.path);
152
+ const stat = await fsp.stat(resolved.absPath).catch(() => null);
153
+ if (!stat) throw new ToolError(`Path not found: ${args.path}`);
154
+ if (!stat.isDirectory()) throw new ToolError('project_summary expects a directory.');
155
+ }
156
+ return buildProjectSummary({ workspace });
157
+ },
158
+ }),
159
+ ];
160
+ }