@yanzc/claude-pro 0.0.0-stage → 0.4.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 (58) hide show
  1. package/.env.example +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +314 -3
  4. package/bin/claude-pro.js +32 -0
  5. package/package.json +40 -6
  6. package/src/agent.js +752 -0
  7. package/src/agents.js +186 -0
  8. package/src/api.js +529 -0
  9. package/src/cache.js +150 -0
  10. package/src/checkpoints.js +184 -0
  11. package/src/claude-code.js +60 -0
  12. package/src/codex/client.js +307 -0
  13. package/src/codex/translate-request.js +239 -0
  14. package/src/codex/translate-stream.js +328 -0
  15. package/src/commands.js +839 -0
  16. package/src/config.js +453 -0
  17. package/src/context.js +109 -0
  18. package/src/filestate.js +57 -0
  19. package/src/gateway.js +143 -0
  20. package/src/hooks.js +81 -0
  21. package/src/main.js +193 -0
  22. package/src/mcp.js +290 -0
  23. package/src/modes.js +154 -0
  24. package/src/oauth.js +287 -0
  25. package/src/peers.js +157 -0
  26. package/src/print.js +106 -0
  27. package/src/prompts.js +179 -0
  28. package/src/ratelimit.js +244 -0
  29. package/src/restart.js +115 -0
  30. package/src/runtime.js +162 -0
  31. package/src/selfcheck.js +71 -0
  32. package/src/session.js +139 -0
  33. package/src/supervisor.js +110 -0
  34. package/src/tasks.js +76 -0
  35. package/src/tools/bash.js +227 -0
  36. package/src/tools/effort.js +41 -0
  37. package/src/tools/files.js +352 -0
  38. package/src/tools/index.js +52 -0
  39. package/src/tools/interaction.js +157 -0
  40. package/src/tools/notebook.js +91 -0
  41. package/src/tools/peers.js +45 -0
  42. package/src/tools/restart.js +50 -0
  43. package/src/tools/search.js +458 -0
  44. package/src/tools/skill.js +32 -0
  45. package/src/tools/task.js +139 -0
  46. package/src/tools/web.js +283 -0
  47. package/src/ui/ansi.js +228 -0
  48. package/src/ui/dialogs.js +122 -0
  49. package/src/ui/editor.js +389 -0
  50. package/src/ui/markdown.js +249 -0
  51. package/src/ui/render.js +155 -0
  52. package/src/ui/repl.js +1460 -0
  53. package/src/ui/screen.js +259 -0
  54. package/src/updater.js +93 -0
  55. package/src/utils/clipboard.js +197 -0
  56. package/src/utils/diff.js +95 -0
  57. package/src/utils/paths.js +37 -0
  58. package/src/utils/shell.js +251 -0
@@ -0,0 +1,227 @@
1
+ // Bash tool plus background shell management (BashOutput / KillShell).
2
+
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { findShell, killTree, spawnShell, watchShell } from '../utils/shell.js';
7
+ import { stripAnsi } from '../ui/ansi.js';
8
+ import { toolResultsDir } from '../session.js';
9
+
10
+ const background = new Map(); // id -> {child, command, output, readPos, status, exitCode, startedAt}
11
+ let bgCounter = 0;
12
+
13
+ function truncateOutput(text, max) {
14
+ if (text.length <= max) return text;
15
+ const head = text.slice(0, Math.floor(max * 0.4));
16
+ const tail = text.slice(-Math.floor(max * 0.6));
17
+ const dropped = text.slice(head.length, text.length - tail.length).split('\n').length;
18
+ return `${head}\n\n... [${dropped} lines truncated] ...\n\n${tail}`;
19
+ }
20
+
21
+ // Keep the full text of truncated output so the model can still get at it.
22
+ function saveFullOutput(root, text) {
23
+ try {
24
+ const dir = toolResultsDir(root);
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ const file = path.join(dir, `bash-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`);
27
+ fs.writeFileSync(file, text);
28
+ return file;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ // Append to a background shell's buffer. Past `max` characters only the newest `keep` are kept, and
35
+ // `readPos` moves back by what was removed so it still marks the text the model has already read.
36
+ export function appendOutput(bg, text, max = 5_000_000, keep = 2_000_000) {
37
+ bg.output += text;
38
+ if (bg.output.length > max) {
39
+ const cut = bg.output.length - keep;
40
+ bg.output = bg.output.slice(cut);
41
+ bg.readPos = Math.max(0, bg.readPos - cut);
42
+ }
43
+ }
44
+
45
+ function childEnv() {
46
+ const env = { ...process.env };
47
+ // Keep child processes non-interactive and colour free.
48
+ Object.assign(env, { GIT_EDITOR: 'true', GIT_PAGER: 'cat', PAGER: 'cat', NO_COLOR: '1', CLAUDE_PRO: '1' });
49
+ delete env.FORCE_COLOR;
50
+ // What commands run from here start must not believe they are a restartable session of this program.
51
+ for (const key of ['CLAUDE_PRO_SUPERVISED', 'CLAUDE_PRO_RESTART_FILE', 'CLAUDE_PRO_RESTARTED']) delete env[key];
52
+ return env;
53
+ }
54
+
55
+ export function shutdownBackgroundShells() {
56
+ for (const bg of background.values()) if (bg.status === 'running') killTree(bg.child);
57
+ }
58
+
59
+ export function listBackgroundShells() {
60
+ return [...background.entries()].map(([id, bg]) => ({ id, command: bg.command, status: bg.status, exitCode: bg.exitCode }));
61
+ }
62
+
63
+ export function makeBashTool(config) {
64
+ const shell = findShell(config);
65
+ const isCmd = shell.kind === 'cmd';
66
+ return {
67
+ name: 'Bash',
68
+ description: `Executes a given ${isCmd ? 'cmd.exe' : 'bash'} command with an optional timeout. The working directory persists between commands; shell state (variables, functions) does not.
69
+
70
+ IMPORTANT: This tool is for terminal operations like git, npm, docker, running builds and tests. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
71
+
72
+ Before executing the command:
73
+ 1. If the command will create new directories or files, first verify the parent directory exists (e.g. with ls).
74
+ 2. Always quote file paths that contain spaces with double quotes.
75
+
76
+ Usage notes:
77
+ - The command argument is required.
78
+ - You can specify an optional timeout in milliseconds (up to ${config.bashMaxTimeout}ms / ${config.bashMaxTimeout / 60000} minutes). Default is ${config.bashDefaultTimeout}ms.
79
+ - Write a clear, concise description of what the command does in 5-10 words.
80
+ - If the output exceeds ${config.bashMaxOutput} characters, the middle is cut from what is returned to you, and the full output is saved to a file whose path is included so you can read it with Read or Grep.
81
+ - You can use the \`run_in_background\` parameter to run the command in the background (dev servers, watchers, long builds). Use BashOutput to read its output later and KillShell to stop it. Never use \`&\` at the end of the command for this.
82
+ - Commands run non-interactively: stdin is closed unless you pass the \`stdin\` parameter, whose text is fed to the command (answers to prompts, data for a script, a heredoc-free way to pipe input). Do not run commands that wait for keyboard interaction (use flags like --yes, and avoid interactive editors, \`git rebase -i\`, \`git add -i\`).
83
+ - Avoid using search/read commands like \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` for file work; use Glob, Grep, Read, Edit and Write instead.
84
+ - When issuing multiple commands: if they are independent, make multiple Bash tool calls in parallel in a single message; if they depend on each other, chain them with '&&'.
85
+ - Try to maintain your current working directory by using absolute paths and avoiding \`cd\`.${os.platform() === 'win32' && !isCmd ? '\n- This is Git Bash on Windows: use Unix syntax (forward slashes, /dev/null, $VAR). Windows paths like C:/Users/... also work.' : ''}
86
+
87
+ # Committing changes with git
88
+ Only create commits when requested by the user. When asked to commit: run git status, git diff and git log in parallel to understand the change and the repo's message style; stage only relevant files (never secrets such as .env); write a concise message focused on the "why"; pass the message via a HEREDOC. Do not force push, skip hooks (--no-verify), amend commits, change git config or push unless the user asked for that.`,
89
+ input_schema: {
90
+ type: 'object',
91
+ properties: {
92
+ command: { type: 'string', description: 'The command to execute' },
93
+ timeout: { type: 'number', description: `Optional timeout in milliseconds (max ${config.bashMaxTimeout})` },
94
+ description: { type: 'string', description: 'Clear, concise description of what this command does in 5-10 words, in active voice.' },
95
+ run_in_background: { type: 'boolean', description: 'Set to true to run this command in the background. Use BashOutput to read the output later.' },
96
+ stdin: { type: 'string', description: 'Optional text to feed to the command on standard input' },
97
+ },
98
+ required: ['command'],
99
+ },
100
+ summarize(input) { return input.command || ''; },
101
+ async call(input, ctx) {
102
+ const command = String(input.command || '').trim();
103
+ if (!command) return { isError: true, content: 'Empty command' };
104
+ const stdinText = typeof input.stdin === 'string' ? input.stdin : undefined;
105
+ const cwdFile = path.join(os.tmpdir(), `claude-pro-cwd-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
106
+ let cwd = ctx.cwd;
107
+ if (!fs.existsSync(cwd)) cwd = ctx.projectRoot;
108
+
109
+ if (input.run_in_background) {
110
+ const child = spawnShell(shell, command, { cwd, env: childEnv(), stdin: stdinText });
111
+ const id = `bash_${++bgCounter}`;
112
+ const bg = { child, command, output: '', readPos: 0, status: 'running', exitCode: null, startedAt: Date.now() };
113
+ background.set(id, bg);
114
+ // Decode as a stream: a multi-byte character (CJK text) can be split between two chunks.
115
+ child.stdout.setEncoding('utf8');
116
+ child.stderr.setEncoding('utf8');
117
+ const onData = (d) => appendOutput(bg, stripAnsi(d));
118
+ child.stdout.on('data', onData);
119
+ child.stderr.on('data', onData);
120
+ child.on('close', (code) => { bg.status = bg.status === 'killed' ? 'killed' : 'completed'; bg.exitCode = code; });
121
+ child.on('error', (e) => { bg.status = 'failed'; bg.output += `\n${e.message}`; });
122
+ return { content: `Command running in background with ID: ${id}`, display: { summary: `Running in background (${id})` } };
123
+ }
124
+
125
+ const timeout = Math.min(Math.max(1000, input.timeout || config.bashDefaultTimeout), config.bashMaxTimeout);
126
+ return await new Promise((resolve, reject) => {
127
+ let output = '';
128
+ let timedOut = false;
129
+ let child;
130
+ try {
131
+ child = spawnShell(shell, command, { cwd, env: childEnv(), cwdFile, stdin: stdinText });
132
+ } catch (e) {
133
+ resolve({ isError: true, content: `Failed to start shell: ${e.message}` });
134
+ return;
135
+ }
136
+ const timer = setTimeout(() => { timedOut = true; killTree(child); }, timeout);
137
+ const onAbort = () => killTree(child);
138
+ ctx.signal?.addEventListener('abort', onAbort, { once: true });
139
+ const finish = (code, err) => {
140
+ clearTimeout(timer);
141
+ ctx.signal?.removeEventListener('abort', onAbort);
142
+ try {
143
+ const newCwd = fs.readFileSync(cwdFile, 'utf8').trim();
144
+ if (newCwd && fs.existsSync(newCwd)) ctx.setCwd(path.resolve(newCwd));
145
+ } catch { /* command may have exited early */ }
146
+ fs.rm(cwdFile, { force: true }, () => {});
147
+ if (ctx.signal?.aborted) {
148
+ reject(Object.assign(new Error('aborted'), { name: 'AbortError', partialOutput: output }));
149
+ return;
150
+ }
151
+ let text = stripAnsi(output).replace(/\r\n/g, '\n').replace(/\s+$/, '');
152
+ if (text.length > config.bashMaxOutput) {
153
+ const saved = saveFullOutput(ctx.projectRoot, text);
154
+ const size = text.length;
155
+ text = truncateOutput(text, config.bashMaxOutput);
156
+ if (saved) text += `\n\n(Output was ${size} characters and has been truncated. The full output is saved at ${saved}; use Read with offset/limit or Grep on that file to see the rest.)`;
157
+ }
158
+ if (err) text += `${text ? '\n' : ''}${err.message}`;
159
+ if (timedOut) text += `${text ? '\n' : ''}Command timed out after ${timeout}ms`;
160
+ const isError = timedOut || !!err || (code !== 0 && code !== null);
161
+ if (code && code !== 0 && !timedOut) text += `${text ? '\n' : ''}Exit code ${code}`;
162
+ resolve({ isError, content: text || '(no output)', display: { output: text, exitCode: code } });
163
+ };
164
+ watchShell(child, {
165
+ onOutput: (text) => {
166
+ output += text;
167
+ if (output.length > 10_000_000) output = output.slice(-5_000_000);
168
+ ctx.onProgress?.(stripAnsi(output));
169
+ },
170
+ done: finish,
171
+ });
172
+ });
173
+ },
174
+ };
175
+ }
176
+
177
+ export const BashOutputTool = {
178
+ name: 'BashOutput',
179
+ readOnly: true,
180
+ description: `Retrieves output from a running or completed background bash shell.
181
+ - Takes a bash_id parameter identifying the shell
182
+ - Always returns only new output since the last check, along with the shell status
183
+ - Supports optional regex filtering to show only lines matching a pattern`,
184
+ input_schema: {
185
+ type: 'object',
186
+ properties: {
187
+ bash_id: { type: 'string', description: 'The ID of the background shell to retrieve output from' },
188
+ filter: { type: 'string', description: 'Optional regular expression to filter the output lines' },
189
+ },
190
+ required: ['bash_id'],
191
+ },
192
+ summarize(input) { return input.bash_id; },
193
+ async call(input) {
194
+ const bg = background.get(input.bash_id);
195
+ if (!bg) return { isError: true, content: `No background shell with id ${input.bash_id}. Known: ${[...background.keys()].join(', ') || 'none'}` };
196
+ let fresh = bg.output.slice(bg.readPos);
197
+ bg.readPos = bg.output.length;
198
+ if (input.filter) {
199
+ try {
200
+ const re = new RegExp(input.filter);
201
+ fresh = fresh.split('\n').filter((l) => re.test(l)).join('\n');
202
+ } catch (e) { return { isError: true, content: `Invalid filter regex: ${e.message}` }; }
203
+ }
204
+ fresh = truncateOutput(fresh.replace(/\r\n/g, '\n'), 30_000);
205
+ const status = `<status>${bg.status}</status>${bg.exitCode !== null ? `\n<exit_code>${bg.exitCode}</exit_code>` : ''}`;
206
+ return { content: `${status}\n<output>\n${fresh || '(no new output)'}\n</output>`, display: { summary: `${bg.status}${fresh ? `, ${fresh.split('\n').length} new lines` : ', no new output'}` } };
207
+ },
208
+ };
209
+
210
+ export const KillShellTool = {
211
+ name: 'KillShell',
212
+ description: 'Kills a running background bash shell by its ID.',
213
+ input_schema: {
214
+ type: 'object',
215
+ properties: { shell_id: { type: 'string', description: 'The ID of the background shell to kill' } },
216
+ required: ['shell_id'],
217
+ },
218
+ summarize(input) { return input.shell_id; },
219
+ async call(input) {
220
+ const bg = background.get(input.shell_id);
221
+ if (!bg) return { isError: true, content: `No background shell with id ${input.shell_id}` };
222
+ if (bg.status !== 'running') return { content: `Shell ${input.shell_id} is not running (status: ${bg.status})` };
223
+ bg.status = 'killed';
224
+ killTree(bg.child);
225
+ return { content: `Successfully killed shell: ${input.shell_id} (${bg.command})` };
226
+ },
227
+ };
@@ -0,0 +1,41 @@
1
+ // Effort: the model changes its own reasoning effort in the middle of a request, typically raising it for a hard
2
+ // problem. The change lasts until the user's next message, which goes back to the effort the user chose.
3
+
4
+ import { defaultEffort } from '../api.js';
5
+
6
+ export const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
7
+
8
+ export function makeEffortTool(config) {
9
+ return {
10
+ name: 'Effort',
11
+ description: `Change your own reasoning effort (output_config.effort) for the rest of the user's current request. From your next step on, every request uses the new level; at the user's next message it goes back to the level the user chose.
12
+
13
+ Raise it when the task turns out to be hard and the current level is not enough: a bug you could not find after a couple of real attempts, a subtle concurrency or algorithmic problem, a design decision with many interacting constraints, a fix that keeps failing. Then work on with the higher level; do not ask the user first.
14
+
15
+ Do not raise it for routine work, and do not switch back and forth: the prompt cache is kept per effort level, so each change makes the next request write the whole conversation to the cache again (a one-off cost about the size of the conversation). Lowering it is worth it only when the hard part is done and a long stretch of routine work remains.`,
16
+ input_schema: {
17
+ type: 'object',
18
+ properties: {
19
+ level: { type: 'string', enum: EFFORT_LEVELS, description: 'The new effort level' },
20
+ reason: { type: 'string', description: 'Why, in a few words. Shown to the user.' },
21
+ },
22
+ required: ['level', 'reason'],
23
+ },
24
+ summarize(input) { return `${input.level}${input.reason ? `: ${input.reason}` : ''}`; },
25
+ async call(input) {
26
+ const level = String(input.level || '').trim().toLowerCase();
27
+ if (!EFFORT_LEVELS.includes(level)) return { isError: true, content: `Unknown effort "${input.level}". Use one of: ${EFFORT_LEVELS.join(', ')}.` };
28
+ const shown = () => config.effort || defaultEffort(config.mainModel.id, config) || 'the model default';
29
+ const before = shown();
30
+ if (level === before) return { content: `The effort is already ${level}.` };
31
+ config.effortBoost = null;
32
+ const users = shown(); // the level the user chose, which the next message goes back to
33
+ config.effortBoost = level === users ? null : level;
34
+ if (!config.effortBoost) return { content: `Effort back to ${level}, the level the user chose.`, display: { summary: `Effort ${before} → ${level}` } };
35
+ return {
36
+ content: `Effort changed from ${before} to ${level} for the rest of this request; it goes back to ${users} at the user's next message.`,
37
+ display: { summary: `Effort ${before} → ${level}` },
38
+ };
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,352 @@
1
+ // Read, Write and Edit tools. Edits require that the file was read first and hasn't
2
+ // changed on disk since, which prevents the model from clobbering changes it never saw.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { resolvePath, displayPath } from '../utils/paths.js';
7
+ import { diffLines, makeHunks, countChanges, splitLines } from '../utils/diff.js';
8
+
9
+ const MAX_LINES = 10_000;
10
+ const MAX_LINE_CHARS = 20_000;
11
+ const MAX_OUTPUT_CHARS = 400_000; // a read without offset/limit bigger than this is refused
12
+ const HARD_MAX_CHARS = 400_000; // never return more than this in one read (about 100k tokens), even with an explicit limit
13
+ const IMAGE_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
14
+
15
+ export function mtime(file) {
16
+ try { return fs.statSync(file).mtimeMs; } catch { return null; }
17
+ }
18
+
19
+ function isBinary(buf) {
20
+ const len = Math.min(buf.length, 8000);
21
+ for (let i = 0; i < len; i++) if (buf[i] === 0) return true;
22
+ return false;
23
+ }
24
+
25
+ // `crlf` is true when CRLF is the file's main line ending. Editing rewrites every line ending, so a
26
+ // mostly-LF file with one stray CRLF must stay LF instead of being converted wholesale.
27
+ function readText(file, buf = fs.readFileSync(file)) {
28
+ const raw = buf.toString('utf8');
29
+ const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
30
+ const crlfCount = (text.match(/\r\n/g) || []).length;
31
+ const bareLfCount = (text.match(/\n/g) || []).length - crlfCount;
32
+ return { text, bom: text !== raw, crlf: crlfCount > 0 && crlfCount >= bareLfCount };
33
+ }
34
+
35
+ export function numberLines(lines, start) {
36
+ return lines.map((l, i) => {
37
+ const line = l.length > MAX_LINE_CHARS ? l.slice(0, MAX_LINE_CHARS) + '… [line truncated]' : l;
38
+ return `${String(start + i).padStart(6)}\t${line}`;
39
+ }).join('\n');
40
+ }
41
+
42
+ function renderNotebook(json) {
43
+ const cells = json.cells || [];
44
+ return cells.map((cell, i) => {
45
+ const src = Array.isArray(cell.source) ? cell.source.join('') : (cell.source || '');
46
+ let out = `<cell id="${cell.id || i}" type="${cell.cell_type}">\n${src}\n</cell>`;
47
+ for (const o of cell.outputs || []) {
48
+ const text = o.text ? [].concat(o.text).join('') : o.data?.['text/plain'] ? [].concat(o.data['text/plain']).join('') : '';
49
+ if (text) out += `\n<output>\n${text.slice(0, 5000)}\n</output>`;
50
+ }
51
+ return out;
52
+ }).join('\n\n');
53
+ }
54
+
55
+ export const ReadTool = {
56
+ name: 'Read',
57
+ readOnly: true,
58
+ description: `Reads a file from the local filesystem. You can access any file directly by using this tool.
59
+ Assume this tool is able to read all files on the machine. If the user provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
60
+
61
+ Usage:
62
+ - The file_path parameter should be an absolute path
63
+ - By default, it reads up to ${MAX_LINES} lines starting from the beginning of the file
64
+ - You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
65
+ - Any lines longer than ${MAX_LINE_CHARS} characters will be truncated
66
+ - Results are returned using cat -n format, with line numbers starting at 1
67
+ - This tool can read images (PNG, JPG, GIF, WEBP) and Jupyter notebooks (.ipynb)
68
+ - This tool can only read files, not directories. To list a directory, use ls via the Bash tool.
69
+ - You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel.
70
+ - If you read a file that exists but has empty contents you will receive a warning in place of file contents.`,
71
+ input_schema: {
72
+ type: 'object',
73
+ properties: {
74
+ file_path: { type: 'string', description: 'The absolute path to the file to read' },
75
+ offset: { type: 'number', description: 'The line number to start reading from. Only provide if the file is too large to read at once' },
76
+ limit: { type: 'number', description: 'The number of lines to read. Only provide if the file is too large to read at once.' },
77
+ },
78
+ required: ['file_path'],
79
+ },
80
+ summarize(input, ctx) {
81
+ const p = displayPath(resolvePath(input.file_path, ctx.cwd), ctx.cwd);
82
+ return input.offset || input.limit ? `${p}, lines ${input.offset || 1}-${(input.offset || 1) + (input.limit || MAX_LINES) - 1}` : p;
83
+ },
84
+ filePath(input, ctx) { return resolvePath(input.file_path, ctx.cwd); },
85
+ async call(input, ctx) {
86
+ const file = resolvePath(input.file_path, ctx.cwd);
87
+ let stat;
88
+ try { stat = fs.statSync(file); } catch {
89
+ return { isError: true, content: `File does not exist: ${file}` };
90
+ }
91
+ if (stat.isDirectory()) return { isError: true, content: `EISDIR: ${file} is a directory. Use the Bash tool with ls to list directories.` };
92
+ const ext = path.extname(file).toLowerCase();
93
+
94
+ if (IMAGE_TYPES[ext]) {
95
+ if (stat.size > 5 * 1024 * 1024) return { isError: true, content: `Image too large (${Math.round(stat.size / 1024)}KB, max 5MB).` };
96
+ const data = fs.readFileSync(file).toString('base64');
97
+ ctx.fileState.set(file, { mtimeMs: stat.mtimeMs });
98
+ return {
99
+ content: [{ type: 'image', source: { type: 'base64', media_type: IMAGE_TYPES[ext], data } }],
100
+ display: { summary: `Read image (${Math.round(stat.size / 1024)}KB)` },
101
+ };
102
+ }
103
+ if (ext === '.ipynb') {
104
+ try {
105
+ const nb = JSON.parse(fs.readFileSync(file, 'utf8'));
106
+ ctx.fileState.set(file, { mtimeMs: stat.mtimeMs });
107
+ return { content: renderNotebook(nb), display: { summary: `Read ${(nb.cells || []).length} cells` } };
108
+ } catch (e) {
109
+ return { isError: true, content: `Could not parse notebook: ${e.message}` };
110
+ }
111
+ }
112
+ const buf = fs.readFileSync(file);
113
+ if (isBinary(buf)) return { isError: true, content: `This appears to be a binary file (${stat.size} bytes) and cannot be displayed as text.` };
114
+
115
+ const { text } = readText(file, buf);
116
+ const lines = splitLines(text);
117
+ // `partial` records that part of the file was not shown, so Write can refuse to overwrite it.
118
+ const seen = (partial) => ctx.fileState.set(file, { mtimeMs: stat.mtimeMs, partial });
119
+ if (lines.length === 0) {
120
+ seen(false);
121
+ return { content: '<system-reminder>Warning: the file exists but the contents are empty.</system-reminder>', display: { summary: 'Read 0 lines (empty file)' } };
122
+ }
123
+ const start = Math.max(1, Math.floor(input.offset || 1));
124
+ if (start > lines.length) {
125
+ seen(true);
126
+ return { content: `<system-reminder>Warning: the file exists but is shorter than the provided offset (${start}). The file has ${lines.length} lines.</system-reminder>`, display: { summary: 'Read 0 lines' } };
127
+ }
128
+ const limit = Math.max(1, Math.floor(input.limit || MAX_LINES));
129
+ const slice = lines.slice(start - 1, start - 1 + limit);
130
+ let content = numberLines(slice, start);
131
+ if (content.length > MAX_OUTPUT_CHARS && !input.limit) {
132
+ return { isError: true, content: `File content (${Math.round(content.length / 1024)}KB) exceeds the maximum allowed size. Use the offset and limit parameters to read specific portions of the file, or use the Grep tool to search for specific content.` };
133
+ }
134
+ let cut = false;
135
+ let end = start + slice.length - 1;
136
+ if (content.length > HARD_MAX_CHARS) {
137
+ const kept = content.slice(0, HARD_MAX_CHARS);
138
+ const whole = kept.endsWith('\n'); // the cut fell between two lines
139
+ end = start + (kept.match(/\n/g) || []).length - (whole ? 1 : 0); // the last line shown
140
+ content = `${kept}${whole ? '' : '\n'}… [output cut at ${HARD_MAX_CHARS} characters: line ${end} is the last one shown${whole ? '' : ', and only in part'}; read on from there with offset/limit]`;
141
+ cut = true;
142
+ }
143
+ seen(cut || start > 1 || end < lines.length);
144
+ if (end < lines.length) content += `\n\n(File has ${lines.length} lines total; showing lines ${start}-${end}. Use offset/limit to read more.)`;
145
+ return { content, display: { summary: `Read ${slice.length} line${slice.length === 1 ? '' : 's'}` } };
146
+ },
147
+ };
148
+
149
+ // Validate that a file may be written: it must have been read and be unchanged since. With
150
+ // `full`, which Write needs because it replaces the whole file, the read must also have covered
151
+ // all of it; Edit only replaces text it matched exactly, so a partial read is enough there.
152
+ export function checkFreshness(file, ctx, { full = false } = {}) {
153
+ const state = ctx.fileState.get(file);
154
+ if (!state) return 'File has not been read yet. Read it first before writing to it.';
155
+ if (full && state.partial) {
156
+ return 'File was only partly read (offset/limit), so Write would overwrite content you have not seen. Read the whole file first, or use Edit for targeted changes.';
157
+ }
158
+ const now = mtime(file);
159
+ if (now !== null && state.mtimeMs !== undefined && Math.abs(now - state.mtimeMs) > 1) {
160
+ return 'File has been modified since it was last read, either by the user or by a linter. Read it again before attempting to write it.';
161
+ }
162
+ return null;
163
+ }
164
+
165
+ function writeText(file, text, { crlf, bom }) {
166
+ let out = text;
167
+ if (crlf) out = out.replace(/\r?\n/g, '\r\n');
168
+ if (bom) out = String.fromCharCode(0xfeff) + out;
169
+ fs.mkdirSync(path.dirname(file), { recursive: true });
170
+ fs.writeFileSync(file, out, 'utf8');
171
+ }
172
+
173
+ // Where the new text of an edit is, for its result: "lines 12-18", "line 12", or "text removed at line 12".
174
+ // The result used to show a snippet of the file around the change; it repeated new_string, which the model has
175
+ // just written, and stayed in the conversation to be re-read by every later request. The line numbers are what
176
+ // it still needs (for a Read with offset/limit); Claude Code's Edit also answers with a short confirmation.
177
+ function changedLines(line, newStr) {
178
+ if (!newStr) return `text removed at line ${line}`;
179
+ const count = newStr.split('\n').length - (newStr.endsWith('\n') ? 1 : 0);
180
+ return count > 1 ? `lines ${line}-${line + count - 1}` : `line ${line}`;
181
+ }
182
+
183
+ export const WriteTool = {
184
+ name: 'Write',
185
+ editsFiles: true,
186
+ description: `Writes a file to the local filesystem.
187
+
188
+ Usage:
189
+ - This tool will overwrite the existing file if there is one at the provided path.
190
+ - If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first.
191
+ - Prefer editing an existing file when that is the natural place for a change, but create new files whenever the task needs them (code, tests, scripts, configs, data).
192
+ - Do not create documentation files (*.md, READMEs) unless the user asks for them or the task clearly calls for one.
193
+ - Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.`,
194
+ input_schema: {
195
+ type: 'object',
196
+ properties: {
197
+ file_path: { type: 'string', description: 'The absolute path to the file to write (must be absolute, not relative)' },
198
+ content: { type: 'string', description: 'The content to write to the file' },
199
+ },
200
+ required: ['file_path', 'content'],
201
+ },
202
+ summarize(input, ctx) { return displayPath(resolvePath(input.file_path, ctx.cwd), ctx.cwd); },
203
+ filePath(input, ctx) { return resolvePath(input.file_path, ctx.cwd); },
204
+ validate(input, ctx) {
205
+ if (typeof input.content !== 'string') return 'content must be a string';
206
+ const file = resolvePath(input.file_path, ctx.cwd);
207
+ if (fs.existsSync(file)) {
208
+ if (fs.statSync(file).isDirectory()) return `${file} is a directory`;
209
+ return checkFreshness(file, ctx, { full: true });
210
+ }
211
+ return null;
212
+ },
213
+ preview(input, ctx) {
214
+ const file = resolvePath(input.file_path, ctx.cwd);
215
+ const exists = fs.existsSync(file);
216
+ const newLines = splitLines(input.content);
217
+ if (!exists) return { kind: 'create', path: file, lines: newLines };
218
+ const ops = diffLines(splitLines(readText(file).text), newLines);
219
+ return { kind: 'diff', path: file, hunks: makeHunks(ops), ...countChanges(ops) };
220
+ },
221
+ async call(input, ctx) {
222
+ const err = this.validate(input, ctx);
223
+ if (err) return { isError: true, content: err };
224
+ const file = resolvePath(input.file_path, ctx.cwd);
225
+ const exists = fs.existsSync(file);
226
+ const prev = exists ? readText(file) : { text: '', crlf: false, bom: false };
227
+ const preview = this.preview(input, ctx);
228
+ writeText(file, input.content, prev);
229
+ ctx.fileState.set(file, { mtimeMs: mtime(file) });
230
+ const n = splitLines(input.content).length;
231
+ if (!exists) {
232
+ return {
233
+ content: `File created successfully at: ${file}`,
234
+ display: { summary: `Wrote ${n} line${n === 1 ? '' : 's'} to ${displayPath(file, ctx.cwd)}`, preview },
235
+ };
236
+ }
237
+ return {
238
+ content: `The file ${file} has been updated successfully.`,
239
+ display: { summary: changeSummary(preview), preview },
240
+ };
241
+ },
242
+ };
243
+
244
+ function changeSummary({ added, removed }) {
245
+ const parts = [];
246
+ if (added) parts.push(`${added} addition${added === 1 ? '' : 's'}`);
247
+ if (removed) parts.push(`${removed} removal${removed === 1 ? '' : 's'}`);
248
+ return parts.length ? `Updated with ${parts.join(' and ')}` : 'No changes';
249
+ }
250
+
251
+ // Apply old->new replacement to text. Returns {result, count, index} or {error}.
252
+ export function applyEdit(text, oldStr, newStr, replaceAll) {
253
+ if (oldStr === newStr) return { error: 'No changes to make: old_string and new_string are exactly the same.' };
254
+ if (oldStr === '') {
255
+ if (text.length) return { error: 'Cannot use an empty old_string on a non-empty file. Use Write to replace the whole file.' };
256
+ return { result: newStr, count: 1, index: 0 };
257
+ }
258
+ let actualOld = oldStr;
259
+ let count = text.split(actualOld).length - 1;
260
+ if (count === 0) {
261
+ // Tolerate trailing whitespace differences at line ends.
262
+ const norm = (s) => s.replace(/[ \t]+\n/g, '\n');
263
+ if (norm(text).includes(norm(oldStr)) && norm(text) !== text) {
264
+ return { error: 'String to replace not found exactly (whitespace differs at line ends). Read the file again and copy the text exactly, including whitespace.' };
265
+ }
266
+ // Curly quote normalisation.
267
+ const straight = (s) => s.replace(/[‘’]/g, "'").replace(/[“”]/g, '"');
268
+ // Curly quotes are single UTF-16 units, so indices line up between the two forms.
269
+ const idx = straight(text).indexOf(straight(oldStr));
270
+ if (idx >= 0) {
271
+ actualOld = text.slice(idx, idx + oldStr.length);
272
+ count = text.split(actualOld).length - 1;
273
+ }
274
+ }
275
+ if (count === 0) return { error: `String to replace not found in file.\nString: ${oldStr}` };
276
+ if (count > 1 && !replaceAll) {
277
+ return { error: `Found ${count} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${oldStr}` };
278
+ }
279
+ const index = text.indexOf(actualOld);
280
+ const result = replaceAll ? text.split(actualOld).join(newStr) : text.slice(0, index) + newStr + text.slice(index + actualOld.length);
281
+ return { result, count, index };
282
+ }
283
+
284
+ export const EditTool = {
285
+ name: 'Edit',
286
+ editsFiles: true,
287
+ description: `Performs exact string replacements in files.
288
+
289
+ Usage:
290
+ - You must use your \`Read\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
291
+ - When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
292
+ - Prefer editing existing files when that is the natural place for a change; create new files whenever the task needs them.
293
+ - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
294
+ - The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
295
+ - Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
296
+ - To create a new file, pass an empty old_string with the full content as new_string (the file must not exist).`,
297
+ input_schema: {
298
+ type: 'object',
299
+ properties: {
300
+ file_path: { type: 'string', description: 'The absolute path to the file to modify' },
301
+ old_string: { type: 'string', description: 'The text to replace' },
302
+ new_string: { type: 'string', description: 'The text to replace it with (must be different from old_string)' },
303
+ replace_all: { type: 'boolean', default: false, description: 'Replace all occurrences of old_string (default false)' },
304
+ },
305
+ required: ['file_path', 'old_string', 'new_string'],
306
+ },
307
+ summarize(input, ctx) { return displayPath(resolvePath(input.file_path, ctx.cwd), ctx.cwd); },
308
+ filePath(input, ctx) { return resolvePath(input.file_path, ctx.cwd); },
309
+ // Compute the edit without writing. Returns {error} or {file, before, after, ...}.
310
+ plan(input, ctx) {
311
+ const file = resolvePath(input.file_path, ctx.cwd);
312
+ const oldStr = String(input.old_string ?? '').replace(/\r\n/g, '\n');
313
+ const newStr = String(input.new_string ?? '').replace(/\r\n/g, '\n');
314
+ if (!fs.existsSync(file)) {
315
+ if (oldStr !== '') return { error: `File does not exist: ${file}` };
316
+ return { file, created: true, before: '', after: newStr, meta: { crlf: false, bom: false } };
317
+ }
318
+ if (fs.statSync(file).isDirectory()) return { error: `${file} is a directory` };
319
+ const fresh = checkFreshness(file, ctx);
320
+ if (fresh) return { error: fresh };
321
+ const { text, crlf, bom } = readText(file);
322
+ const before = text.replace(/\r\n/g, '\n');
323
+ const r = applyEdit(before, oldStr, newStr, !!input.replace_all);
324
+ if (r.error) return { error: r.error };
325
+ return { file, before, after: r.result, index: r.index, count: r.count, meta: { crlf, bom }, newStr };
326
+ },
327
+ validate(input, ctx) { return this.plan(input, ctx).error || null; },
328
+ // `p` is an already computed plan, so call() does not read the file and redo the edit a second time.
329
+ preview(input, ctx, p = this.plan(input, ctx)) {
330
+ if (p.error) return null;
331
+ if (p.created) return { kind: 'create', path: p.file, lines: splitLines(p.after) };
332
+ const ops = diffLines(splitLines(p.before), splitLines(p.after));
333
+ return { kind: 'diff', path: p.file, hunks: makeHunks(ops), ...countChanges(ops) };
334
+ },
335
+ async call(input, ctx) {
336
+ const p = this.plan(input, ctx);
337
+ if (p.error) return { isError: true, content: p.error };
338
+ const preview = this.preview(input, ctx, p);
339
+ const prior = ctx.fileState.get(p.file);
340
+ writeText(p.file, p.after, p.meta);
341
+ // An edit does not show the rest of the file, so a partial read stays partial (a created file is all ours).
342
+ ctx.fileState.set(p.file, { mtimeMs: mtime(p.file), partial: !p.created && !!prior?.partial });
343
+ if (p.created) {
344
+ return { content: `File created successfully at: ${p.file}`, display: { summary: `Created ${displayPath(p.file, ctx.cwd)}`, preview } };
345
+ }
346
+ const line = p.before.slice(0, p.index).split('\n').length;
347
+ const text = input.replace_all
348
+ ? `The file ${p.file} has been updated successfully. All ${p.count} occurrences were replaced.`
349
+ : `The file ${p.file} has been updated successfully (${changedLines(line, p.newStr)}).`;
350
+ return { content: text, display: { summary: changeSummary(preview), preview } };
351
+ },
352
+ };