@ruvnet/open-claude-code 2.0.0

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 (63) hide show
  1. package/package.json +27 -0
  2. package/src/agents/loader.mjs +89 -0
  3. package/src/agents/parser.mjs +98 -0
  4. package/src/agents/teams.mjs +123 -0
  5. package/src/auth/oauth.mjs +220 -0
  6. package/src/config/cli-args.mjs +139 -0
  7. package/src/config/env.mjs +261 -0
  8. package/src/config/settings.mjs +119 -0
  9. package/src/core/agent-loop.mjs +462 -0
  10. package/src/core/cache.mjs +105 -0
  11. package/src/core/checkpoints.mjs +141 -0
  12. package/src/core/context-manager.mjs +198 -0
  13. package/src/core/providers.mjs +219 -0
  14. package/src/core/rate-limiter.mjs +119 -0
  15. package/src/core/scheduler.mjs +173 -0
  16. package/src/core/session.mjs +143 -0
  17. package/src/core/streaming.mjs +182 -0
  18. package/src/core/system-prompt.mjs +135 -0
  19. package/src/hooks/engine.mjs +162 -0
  20. package/src/index.mjs +220 -0
  21. package/src/mcp/client.mjs +253 -0
  22. package/src/mcp/transport-shttp.mjs +130 -0
  23. package/src/mcp/transport-sse.mjs +131 -0
  24. package/src/mcp/transport-ws.mjs +134 -0
  25. package/src/permissions/checker.mjs +57 -0
  26. package/src/permissions/injection-check.mjs +60 -0
  27. package/src/permissions/path-check.mjs +102 -0
  28. package/src/permissions/prompt.mjs +73 -0
  29. package/src/permissions/sandbox.mjs +112 -0
  30. package/src/plugins/loader.mjs +138 -0
  31. package/src/skills/loader.mjs +147 -0
  32. package/src/skills/runner.mjs +55 -0
  33. package/src/telemetry/index.mjs +96 -0
  34. package/src/tools/agent.mjs +127 -0
  35. package/src/tools/ask-user.mjs +61 -0
  36. package/src/tools/bash.mjs +148 -0
  37. package/src/tools/cron-create.mjs +120 -0
  38. package/src/tools/cron-delete.mjs +49 -0
  39. package/src/tools/cron-list.mjs +37 -0
  40. package/src/tools/edit.mjs +82 -0
  41. package/src/tools/enter-worktree.mjs +69 -0
  42. package/src/tools/exit-worktree.mjs +57 -0
  43. package/src/tools/glob.mjs +117 -0
  44. package/src/tools/grep.mjs +127 -0
  45. package/src/tools/ls.mjs +58 -0
  46. package/src/tools/lsp.mjs +115 -0
  47. package/src/tools/multi-edit.mjs +94 -0
  48. package/src/tools/notebook-edit.mjs +96 -0
  49. package/src/tools/read-mcp-resource.mjs +57 -0
  50. package/src/tools/read.mjs +138 -0
  51. package/src/tools/registry.mjs +114 -0
  52. package/src/tools/remote-trigger.mjs +84 -0
  53. package/src/tools/send-message.mjs +64 -0
  54. package/src/tools/skill.mjs +52 -0
  55. package/src/tools/todo-write.mjs +68 -0
  56. package/src/tools/tool-search.mjs +77 -0
  57. package/src/tools/web-fetch.mjs +65 -0
  58. package/src/tools/web-search.mjs +89 -0
  59. package/src/tools/write.mjs +55 -0
  60. package/src/ui/commands.mjs +499 -0
  61. package/src/ui/ink-app.mjs +231 -0
  62. package/src/ui/repl.mjs +144 -0
  63. package/test/test.mjs +1857 -0
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Grep Tool — matches Claude Code's exact behavior.
3
+ *
4
+ * Features:
5
+ * - Case insensitive (-i)
6
+ * - Line numbers (-n, default true for content mode)
7
+ * - Context lines (-A, -B, -C)
8
+ * - output_mode: content, files_with_matches, count
9
+ * - glob filter and type filter
10
+ * - head_limit (default 250)
11
+ * - multiline mode
12
+ */
13
+ import { execSync } from 'child_process';
14
+ import path from 'path';
15
+
16
+ export const GrepTool = {
17
+ name: 'Grep',
18
+ description: 'Search file contents with regex (powered by ripgrep or grep).',
19
+ inputSchema: {
20
+ type: 'object',
21
+ properties: {
22
+ pattern: { type: 'string', description: 'Regex pattern to search for' },
23
+ path: { type: 'string', description: 'File or directory to search in' },
24
+ '-i': { type: 'boolean', description: 'Case insensitive' },
25
+ '-n': { type: 'boolean', description: 'Show line numbers (default true)' },
26
+ '-A': { type: 'number', description: 'Lines after match' },
27
+ '-B': { type: 'number', description: 'Lines before match' },
28
+ '-C': { type: 'number', description: 'Context lines (before and after)' },
29
+ context: { type: 'number', description: 'Alias for -C' },
30
+ output_mode: {
31
+ type: 'string',
32
+ enum: ['content', 'files_with_matches', 'count'],
33
+ description: 'Output mode (default: files_with_matches)',
34
+ },
35
+ glob: { type: 'string', description: 'Glob pattern to filter files' },
36
+ type: { type: 'string', description: 'File type filter (e.g. js, py)' },
37
+ head_limit: { type: 'number', description: 'Max output lines (default 250)' },
38
+ multiline: { type: 'boolean', description: 'Enable multiline matching' },
39
+ },
40
+ required: ['pattern'],
41
+ },
42
+ validateInput(input) { return input.pattern ? [] : ['pattern required']; },
43
+ async call(input) {
44
+ try {
45
+ const dir = path.resolve(input.path || '.');
46
+ const mode = input.output_mode || 'files_with_matches';
47
+ const limit = input.head_limit ?? 250;
48
+
49
+ // Build grep command — try rg first, fall back to grep
50
+ const args = [];
51
+ const useRg = hasRipgrep();
52
+
53
+ if (useRg) {
54
+ args.push('rg');
55
+ if (input['-i']) args.push('-i');
56
+ if (input.multiline) args.push('-U', '--multiline-dotall');
57
+
58
+ if (mode === 'files_with_matches') {
59
+ args.push('-l');
60
+ } else if (mode === 'count') {
61
+ args.push('-c');
62
+ } else {
63
+ // content mode
64
+ const showLineNumbers = input['-n'] !== false;
65
+ if (showLineNumbers) args.push('-n');
66
+ }
67
+
68
+ const ctx = input['-C'] || input.context;
69
+ if (ctx && mode === 'content') args.push('-C', String(ctx));
70
+ if (input['-A'] && mode === 'content') args.push('-A', String(input['-A']));
71
+ if (input['-B'] && mode === 'content') args.push('-B', String(input['-B']));
72
+
73
+ if (input.glob) args.push('--glob', input.glob);
74
+ if (input.type) args.push('--type', input.type);
75
+
76
+ args.push('--', input.pattern, dir);
77
+ } else {
78
+ args.push('grep', '-r');
79
+ if (input['-i']) args.push('-i');
80
+
81
+ if (mode === 'files_with_matches') {
82
+ args.push('-l');
83
+ } else if (mode === 'count') {
84
+ args.push('-c');
85
+ } else {
86
+ if (input['-n'] !== false) args.push('-n');
87
+ }
88
+
89
+ const ctx = input['-C'] || input.context;
90
+ if (ctx && mode === 'content') args.push('-C', String(ctx));
91
+ if (input['-A'] && mode === 'content') args.push('-A', String(input['-A']));
92
+ if (input['-B'] && mode === 'content') args.push('-B', String(input['-B']));
93
+
94
+ if (input.glob) args.push('--include', input.glob);
95
+
96
+ args.push('--', input.pattern, dir);
97
+ }
98
+
99
+ // Apply head_limit
100
+ const cmd = limit > 0
101
+ ? `${args.join(' ')} 2>/dev/null | head -${limit}`
102
+ : `${args.join(' ')} 2>/dev/null`;
103
+
104
+ const result = execSync(cmd, {
105
+ encoding: 'utf-8',
106
+ maxBuffer: 10 * 1024 * 1024,
107
+ timeout: 30000,
108
+ });
109
+
110
+ return result.trim() || 'No matches found.';
111
+ } catch {
112
+ return 'No matches found.';
113
+ }
114
+ },
115
+ };
116
+
117
+ let _hasRg = null;
118
+ function hasRipgrep() {
119
+ if (_hasRg !== null) return _hasRg;
120
+ try {
121
+ execSync('which rg', { encoding: 'utf-8', timeout: 5000 });
122
+ _hasRg = true;
123
+ } catch {
124
+ _hasRg = false;
125
+ }
126
+ return _hasRg;
127
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * LS Tool — directory listing with metadata.
3
+ */
4
+
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+
8
+ export const LsTool = {
9
+ name: 'LS',
10
+ description: 'List directory contents with file sizes and types.',
11
+ inputSchema: {
12
+ type: 'object',
13
+ properties: {
14
+ path: { type: 'string', description: 'Directory path to list (default: cwd)' },
15
+ all: { type: 'boolean', description: 'Include hidden files (default: false)' },
16
+ },
17
+ required: [],
18
+ },
19
+
20
+ validateInput() { return []; },
21
+
22
+ async call(input) {
23
+ const dirPath = path.resolve(input.path || '.');
24
+
25
+ try {
26
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
27
+ const results = [];
28
+
29
+ for (const entry of entries) {
30
+ if (!input.all && entry.name.startsWith('.')) continue;
31
+
32
+ const fullPath = path.join(dirPath, entry.name);
33
+ let size = '';
34
+ try {
35
+ const stat = fs.statSync(fullPath);
36
+ size = entry.isDirectory() ? '' : formatSize(stat.size);
37
+ } catch {
38
+ size = '?';
39
+ }
40
+
41
+ const type = entry.isDirectory() ? 'd' : entry.isSymbolicLink() ? 'l' : '-';
42
+ results.push(`${type} ${size.padStart(8)} ${entry.name}${entry.isDirectory() ? '/' : ''}`);
43
+ }
44
+
45
+ if (results.length === 0) return 'Empty directory';
46
+ return `${dirPath}:\n${results.join('\n')}`;
47
+ } catch (err) {
48
+ return `Error: ${err.message}`;
49
+ }
50
+ },
51
+ };
52
+
53
+ function formatSize(bytes) {
54
+ if (bytes < 1024) return `${bytes}B`;
55
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
56
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
57
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
58
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * LSP Tool — Language Server Protocol integration stub.
3
+ *
4
+ * Provides a tool interface for language server features:
5
+ * - diagnostics (errors/warnings)
6
+ * - completions
7
+ * - hover information
8
+ * - go-to-definition
9
+ * - references
10
+ *
11
+ * This is a stub implementation. Full LSP would spawn an actual language
12
+ * server process and communicate via JSON-RPC.
13
+ */
14
+
15
+ import { execSync } from 'child_process';
16
+ import path from 'path';
17
+
18
+ export const LspTool = {
19
+ name: 'LSP',
20
+ description: 'Query language server for diagnostics, completions, hover, and definitions.',
21
+ inputSchema: {
22
+ type: 'object',
23
+ properties: {
24
+ action: {
25
+ type: 'string',
26
+ enum: ['diagnostics', 'completions', 'hover', 'definition', 'references'],
27
+ description: 'LSP action to perform',
28
+ },
29
+ file: {
30
+ type: 'string',
31
+ description: 'File path to query',
32
+ },
33
+ line: {
34
+ type: 'number',
35
+ description: 'Line number (0-based)',
36
+ },
37
+ character: {
38
+ type: 'number',
39
+ description: 'Character position (0-based)',
40
+ },
41
+ language: {
42
+ type: 'string',
43
+ description: 'Language ID (e.g., "typescript", "python")',
44
+ },
45
+ },
46
+ required: ['action', 'file'],
47
+ },
48
+
49
+ validateInput(input) {
50
+ const errors = [];
51
+ if (!input.action) errors.push('action is required');
52
+ if (!input.file) errors.push('file is required');
53
+ return errors;
54
+ },
55
+
56
+ async call(input) {
57
+ const filePath = path.resolve(input.file);
58
+ const action = input.action;
59
+
60
+ switch (action) {
61
+ case 'diagnostics':
62
+ return getDiagnostics(filePath, input.language);
63
+ case 'completions':
64
+ return `[LSP stub] Completions at ${filePath}:${input.line}:${input.character} not yet implemented.`;
65
+ case 'hover':
66
+ return `[LSP stub] Hover at ${filePath}:${input.line}:${input.character} not yet implemented.`;
67
+ case 'definition':
68
+ return `[LSP stub] Go-to-definition at ${filePath}:${input.line}:${input.character} not yet implemented.`;
69
+ case 'references':
70
+ return `[LSP stub] Find references at ${filePath}:${input.line}:${input.character} not yet implemented.`;
71
+ default:
72
+ return `Unknown LSP action: ${action}`;
73
+ }
74
+ },
75
+ };
76
+
77
+ function getDiagnostics(filePath, language) {
78
+ const ext = path.extname(filePath);
79
+ const lang = language || extToLanguage(ext);
80
+
81
+ try {
82
+ switch (lang) {
83
+ case 'typescript':
84
+ case 'javascript': {
85
+ const result = execSync(
86
+ `npx tsc --noEmit --pretty false "${filePath}" 2>&1 || true`,
87
+ { encoding: 'utf-8', timeout: 15000 }
88
+ );
89
+ return result.trim() || 'No diagnostics.';
90
+ }
91
+ case 'python': {
92
+ const result = execSync(
93
+ `python3 -m py_compile "${filePath}" 2>&1 || true`,
94
+ { encoding: 'utf-8', timeout: 10000 }
95
+ );
96
+ return result.trim() || 'No diagnostics.';
97
+ }
98
+ default:
99
+ return `[LSP stub] No diagnostic provider for language: ${lang}`;
100
+ }
101
+ } catch (err) {
102
+ return `Diagnostics error: ${err.message}`;
103
+ }
104
+ }
105
+
106
+ function extToLanguage(ext) {
107
+ const map = {
108
+ '.ts': 'typescript', '.tsx': 'typescript',
109
+ '.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript',
110
+ '.py': 'python',
111
+ '.rs': 'rust', '.go': 'go', '.java': 'java',
112
+ '.rb': 'ruby', '.php': 'php', '.cs': 'csharp',
113
+ };
114
+ return map[ext] || 'unknown';
115
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * MultiEdit Tool — apply multiple edits to one or more files atomically.
3
+ *
4
+ * Each edit is an { file_path, old_string, new_string } triple.
5
+ * All edits are validated before any are applied.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+
11
+ export const MultiEditTool = {
12
+ name: 'MultiEdit',
13
+ description: 'Apply multiple file edits in a single operation. All edits are validated first.',
14
+ inputSchema: {
15
+ type: 'object',
16
+ properties: {
17
+ edits: {
18
+ type: 'array',
19
+ items: {
20
+ type: 'object',
21
+ properties: {
22
+ file_path: { type: 'string' },
23
+ old_string: { type: 'string' },
24
+ new_string: { type: 'string' },
25
+ },
26
+ required: ['file_path', 'old_string', 'new_string'],
27
+ },
28
+ description: 'Array of edits to apply',
29
+ },
30
+ },
31
+ required: ['edits'],
32
+ },
33
+
34
+ validateInput(input) {
35
+ const errors = [];
36
+ if (!input.edits || !Array.isArray(input.edits)) {
37
+ errors.push('edits must be an array');
38
+ return errors;
39
+ }
40
+ for (let i = 0; i < input.edits.length; i++) {
41
+ const e = input.edits[i];
42
+ if (!e.file_path) errors.push(`edit[${i}]: file_path required`);
43
+ if (!e.old_string) errors.push(`edit[${i}]: old_string required`);
44
+ if (e.old_string === e.new_string) errors.push(`edit[${i}]: old_string must differ from new_string`);
45
+ }
46
+ return errors;
47
+ },
48
+
49
+ async call(input) {
50
+ // Phase 1: Validate all edits
51
+ const fileContents = new Map();
52
+ const errors = [];
53
+
54
+ for (let i = 0; i < input.edits.length; i++) {
55
+ const edit = input.edits[i];
56
+ const filePath = path.resolve(edit.file_path);
57
+
58
+ if (!fileContents.has(filePath)) {
59
+ try {
60
+ fileContents.set(filePath, fs.readFileSync(filePath, 'utf-8'));
61
+ } catch (err) {
62
+ errors.push(`edit[${i}]: cannot read ${filePath}: ${err.message}`);
63
+ continue;
64
+ }
65
+ }
66
+
67
+ let content = fileContents.get(filePath);
68
+ if (!content.includes(edit.old_string)) {
69
+ errors.push(`edit[${i}]: old_string not found in ${edit.file_path}`);
70
+ }
71
+ }
72
+
73
+ if (errors.length > 0) {
74
+ return `Validation failed:\n${errors.join('\n')}`;
75
+ }
76
+
77
+ // Phase 2: Apply all edits
78
+ const applied = [];
79
+ for (const edit of input.edits) {
80
+ const filePath = path.resolve(edit.file_path);
81
+ let content = fileContents.get(filePath);
82
+ content = content.replace(edit.old_string, edit.new_string);
83
+ fileContents.set(filePath, content);
84
+ }
85
+
86
+ // Phase 3: Write all files
87
+ for (const [filePath, content] of fileContents) {
88
+ fs.writeFileSync(filePath, content);
89
+ applied.push(filePath);
90
+ }
91
+
92
+ return `Applied ${input.edits.length} edits to ${applied.length} file(s):\n${applied.join('\n')}`;
93
+ },
94
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * NotebookEdit Tool — edit Jupyter notebook (.ipynb) cells.
3
+ *
4
+ * Supports operations:
5
+ * - insert: add a new cell at a position
6
+ * - replace: replace content of an existing cell
7
+ * - delete: remove a cell
8
+ */
9
+
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+
13
+ export const NotebookEditTool = {
14
+ name: 'NotebookEdit',
15
+ description: 'Edit Jupyter notebook cells. Supports insert, replace, and delete operations.',
16
+ inputSchema: {
17
+ type: 'object',
18
+ properties: {
19
+ notebook_path: { type: 'string', description: 'Path to the .ipynb file' },
20
+ operation: {
21
+ type: 'string',
22
+ enum: ['insert', 'replace', 'delete'],
23
+ description: 'Operation to perform',
24
+ },
25
+ cell_index: { type: 'number', description: 'Cell index (0-based)' },
26
+ cell_type: {
27
+ type: 'string',
28
+ enum: ['code', 'markdown', 'raw'],
29
+ description: 'Cell type (for insert/replace)',
30
+ },
31
+ source: { type: 'string', description: 'Cell content (for insert/replace)' },
32
+ },
33
+ required: ['notebook_path', 'operation', 'cell_index'],
34
+ },
35
+
36
+ validateInput(input) {
37
+ const errors = [];
38
+ if (!input.notebook_path) errors.push('notebook_path required');
39
+ if (!['insert', 'replace', 'delete'].includes(input.operation)) {
40
+ errors.push('operation must be insert, replace, or delete');
41
+ }
42
+ if (typeof input.cell_index !== 'number') errors.push('cell_index must be a number');
43
+ if (input.operation !== 'delete' && !input.source) errors.push('source required for insert/replace');
44
+ return errors;
45
+ },
46
+
47
+ async call(input) {
48
+ const filePath = path.resolve(input.notebook_path);
49
+
50
+ try {
51
+ const raw = fs.readFileSync(filePath, 'utf-8');
52
+ const notebook = JSON.parse(raw);
53
+
54
+ if (!notebook.cells || !Array.isArray(notebook.cells)) {
55
+ return 'Error: invalid notebook format (no cells array)';
56
+ }
57
+
58
+ const cellType = input.cell_type || 'code';
59
+ const sourceLines = (input.source || '').split('\n').map(l => l + '\n');
60
+
61
+ switch (input.operation) {
62
+ case 'insert': {
63
+ const newCell = {
64
+ cell_type: cellType,
65
+ metadata: {},
66
+ source: sourceLines,
67
+ ...(cellType === 'code' ? { outputs: [], execution_count: null } : {}),
68
+ };
69
+ const idx = Math.min(input.cell_index, notebook.cells.length);
70
+ notebook.cells.splice(idx, 0, newCell);
71
+ break;
72
+ }
73
+ case 'replace': {
74
+ if (input.cell_index >= notebook.cells.length) {
75
+ return `Error: cell_index ${input.cell_index} out of range (${notebook.cells.length} cells)`;
76
+ }
77
+ notebook.cells[input.cell_index].source = sourceLines;
78
+ if (cellType) notebook.cells[input.cell_index].cell_type = cellType;
79
+ break;
80
+ }
81
+ case 'delete': {
82
+ if (input.cell_index >= notebook.cells.length) {
83
+ return `Error: cell_index ${input.cell_index} out of range (${notebook.cells.length} cells)`;
84
+ }
85
+ notebook.cells.splice(input.cell_index, 1);
86
+ break;
87
+ }
88
+ }
89
+
90
+ fs.writeFileSync(filePath, JSON.stringify(notebook, null, 1));
91
+ return `Notebook updated: ${input.operation} at cell ${input.cell_index} (${notebook.cells.length} cells total)`;
92
+ } catch (err) {
93
+ return `Error: ${err.message}`;
94
+ }
95
+ },
96
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * ReadMcpResource Tool — read a resource from an MCP server.
3
+ *
4
+ * MCP resources are identified by URI and can contain text, JSON,
5
+ * or binary data. This tool reads text/JSON resources.
6
+ */
7
+
8
+ export const ReadMcpResourceTool = {
9
+ name: 'ReadMcpResource',
10
+ description: 'Read a resource from an MCP server by URI.',
11
+ inputSchema: {
12
+ type: 'object',
13
+ properties: {
14
+ uri: {
15
+ type: 'string',
16
+ description: 'Resource URI (e.g., "file:///path" or "mcp://server/resource")',
17
+ },
18
+ server: {
19
+ type: 'string',
20
+ description: 'MCP server name (if URI does not specify)',
21
+ },
22
+ },
23
+ required: ['uri'],
24
+ },
25
+
26
+ // Set by the MCP integration layer
27
+ _mcpClients: null,
28
+
29
+ validateInput(input) {
30
+ return input.uri ? [] : ['uri is required'];
31
+ },
32
+
33
+ async call(input) {
34
+ if (!this._mcpClients || this._mcpClients.length === 0) {
35
+ return 'No MCP servers connected. Configure MCP servers in settings.';
36
+ }
37
+
38
+ for (const client of this._mcpClients) {
39
+ try {
40
+ const result = await client.readResource(input.uri);
41
+ if (result) {
42
+ if (typeof result === 'string') return result;
43
+ if (result.contents && Array.isArray(result.contents)) {
44
+ return result.contents
45
+ .map(c => c.text || JSON.stringify(c))
46
+ .join('\n');
47
+ }
48
+ return JSON.stringify(result, null, 2);
49
+ }
50
+ } catch {
51
+ // Try next client
52
+ }
53
+ }
54
+
55
+ return `Resource not found: ${input.uri}`;
56
+ },
57
+ };
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Read Tool — matches Claude Code's exact behavior.
3
+ *
4
+ * Features:
5
+ * - pages parameter for PDF files
6
+ * - Binary file detection
7
+ * - Default 2000 line limit
8
+ * - Line number prefix (cat -n format)
9
+ * - Graceful file not found handling
10
+ * - Tracks read files for Edit/Write verification
11
+ */
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+
15
+ const DEFAULT_LIMIT = 2000;
16
+
17
+ // Track which files have been read (used by Edit and Write tools)
18
+ const readFiles = new Set();
19
+
20
+ export function hasBeenRead(filePath) {
21
+ return readFiles.has(path.resolve(filePath));
22
+ }
23
+
24
+ export function markRead(filePath) {
25
+ readFiles.add(path.resolve(filePath));
26
+ }
27
+
28
+ // Binary detection: check for null bytes in first 8KB
29
+ function isBinary(buffer) {
30
+ const len = Math.min(buffer.length, 8192);
31
+ for (let i = 0; i < len; i++) {
32
+ if (buffer[i] === 0) return true;
33
+ }
34
+ return false;
35
+ }
36
+
37
+ export const ReadTool = {
38
+ name: 'Read',
39
+ description: 'Read a file from the local filesystem.',
40
+ inputSchema: {
41
+ type: 'object',
42
+ properties: {
43
+ file_path: { type: 'string', description: 'Absolute path to the file' },
44
+ offset: { type: 'number', description: 'Line number to start reading from (0-based)' },
45
+ limit: { type: 'number', description: 'Number of lines to read (default 2000)' },
46
+ pages: { type: 'string', description: 'Page range for PDF files (e.g. "1-5")' },
47
+ },
48
+ required: ['file_path'],
49
+ },
50
+ validateInput(input) {
51
+ const errors = [];
52
+ if (!input.file_path) errors.push('file_path is required');
53
+ return errors;
54
+ },
55
+ async call(input) {
56
+ const filePath = path.resolve(input.file_path);
57
+
58
+ // Check existence
59
+ if (!fs.existsSync(filePath)) {
60
+ return `Error: File not found: ${filePath}`;
61
+ }
62
+
63
+ // Check if directory
64
+ const stat = fs.statSync(filePath);
65
+ if (stat.isDirectory()) {
66
+ return `Error: ${filePath} is a directory, not a file. Use Bash with ls to list directory contents.`;
67
+ }
68
+
69
+ // PDF handling
70
+ if (filePath.endsWith('.pdf')) {
71
+ return readPdf(filePath, input.pages);
72
+ }
73
+
74
+ // Binary detection
75
+ try {
76
+ const fd = fs.openSync(filePath, 'r');
77
+ const buf = Buffer.alloc(8192);
78
+ const bytesRead = fs.readSync(fd, buf, 0, 8192, 0);
79
+ fs.closeSync(fd);
80
+ if (isBinary(buf.subarray(0, bytesRead))) {
81
+ return `Error: ${filePath} appears to be a binary file. Cannot display binary content.`;
82
+ }
83
+ } catch (e) {
84
+ return `Error: ${e.message}`;
85
+ }
86
+
87
+ try {
88
+ const content = fs.readFileSync(filePath, 'utf-8');
89
+ const lines = content.split('\n');
90
+ const start = input.offset || 0;
91
+ const limit = input.limit || DEFAULT_LIMIT;
92
+ const end = Math.min(start + limit, lines.length);
93
+
94
+ // Track as read
95
+ readFiles.add(filePath);
96
+
97
+ // Handle empty files
98
+ if (content === '' || (content.length === 0)) {
99
+ return '[File exists but is empty]';
100
+ }
101
+
102
+ const output = lines
103
+ .slice(start, end)
104
+ .map((l, i) => `${start + i + 1}\t${l}`)
105
+ .join('\n');
106
+
107
+ if (end < lines.length) {
108
+ return output + `\n\n[File has ${lines.length} lines total. Showing lines ${start + 1}-${end}. Use offset/limit for more.]`;
109
+ }
110
+
111
+ return output;
112
+ } catch (e) {
113
+ return `Error: ${e.message}`;
114
+ }
115
+ },
116
+ };
117
+
118
+ function readPdf(filePath, pages) {
119
+ // PDF reading requires external tools; provide a best-effort
120
+ // text extraction using a simple approach
121
+ try {
122
+ const { execSync } = await_import_child_process();
123
+ const pageArg = pages ? `-f ${pages.split('-')[0]} -l ${pages.split('-').pop()}` : '-f 1 -l 20';
124
+ const text = execSync(`pdftotext ${pageArg} "${filePath}" - 2>/dev/null`, {
125
+ encoding: 'utf-8',
126
+ timeout: 30000,
127
+ maxBuffer: 1024 * 1024,
128
+ });
129
+ return text || `[PDF file at ${filePath} — could not extract text. Use a PDF viewer.]`;
130
+ } catch {
131
+ return `[PDF file at ${filePath} — pdftotext not available. Install poppler-utils for PDF support.]`;
132
+ }
133
+ }
134
+
135
+ // Lazy import helper
136
+ function await_import_child_process() {
137
+ return require('child_process');
138
+ }