@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,259 @@
1
+ // Terminal screen manager. Output is split into:
2
+ // - a static log that scrolls up normally (finished messages, tool results), and
3
+ // - a live region at the bottom (streaming text, spinner, input box, dialogs) that is
4
+ // erased and redrawn in place.
5
+ // Every live line is pre-wrapped to (columns - 1) so we always know how many rows it uses.
6
+ //
7
+ // Resizing. A terminal re-flows what is on screen when its width changes: a live row that was
8
+ // 99 columns wide becomes two rows at 80, and where the cursor ends up afterwards differs between
9
+ // terminals (and between a terminal and the ConPTY that Windows puts in front of it). Finding the top
10
+ // of the live region again by moving the cursor up a computed number of rows is therefore a guess,
11
+ // and a wrong guess leaves half of the old input box on screen. So once the resizing has paused, the
12
+ // whole viewport is written again from its top-left corner (see repaint), the last static lines taken
13
+ // from memory and the live region below them, which needs no knowledge of where the cursor was.
14
+ // Nothing is drawn while the size is still changing: a redraw made between two re-flows works from a
15
+ // screen that is about to change again.
16
+
17
+ import { ESC, wrapLine, termWidth, truncate } from './ansi.js';
18
+
19
+ const SYNC_START = '\x1b[?2026h';
20
+ const SYNC_END = '\x1b[?2026l';
21
+
22
+ const RESIZE_QUIET_MS = 80; // repaint once the size has not changed for this long ...
23
+ const RESIZE_MAX_WAIT_MS = 300; // ... or at the latest this long after it started changing
24
+ const HISTORY_KEEP = 600; // static lines remembered for repainting (a screen shows far fewer)
25
+ const HISTORY_LINE_CHARS = 20_000;
26
+
27
+ // How many rows a static line takes when the terminal wraps it at `cols`.
28
+ function rowsOf(line, cols) {
29
+ return wrapLine(line, cols).length;
30
+ }
31
+
32
+ const TITLE_MAX_WIDTH = 100;
33
+ const TITLE_PUSH = '\x1b[22;0t'; // xterm title stack: remember the title the shell had ...
34
+ const TITLE_POP = '\x1b[23;0t'; // ... and bring it back (terminals without a stack ignore both)
35
+
36
+ // Text that is safe inside an OSC title: no control characters (one could end the sequence early and
37
+ // run on as escape codes), single spaces, not too long.
38
+ export function cleanTitle(text) {
39
+ // eslint-disable-next-line no-control-regex
40
+ const flat = String(text ?? '').replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').replace(/\s+/g, ' ').trim();
41
+ return truncate(flat, TITLE_MAX_WIDTH).replace(/\x1b\[0m$/, '');
42
+ }
43
+
44
+ export class Screen {
45
+ constructor(out = process.stdout) {
46
+ this.out = out;
47
+ this.cursorRow = 0; // row of the terminal cursor, relative to the top of the live region
48
+ this.staticQueue = [];
49
+ this.history = []; // the most recent static lines, for repainting after a resize
50
+ this.renderLive = () => ({ lines: [] });
51
+ this.scheduled = null;
52
+ this.resizeTimer = null;
53
+ this.resizeSince = 0;
54
+ this.closed = false;
55
+ this.titleProvider = null; // () => the terminal title (tab label) to show now
56
+ this.title = null; // what was last sent
57
+ this.titleInherited = false; // the process before this one in the session already saved the shell's title
58
+ this.exitTitle = '';
59
+ out.on('resize', () => this.onResize());
60
+ }
61
+
62
+ // Keep the terminal's title (the label of its tab or window) following `provider()`. `exitTitle` is what
63
+ // it shows once we are gone and the shell is back, for terminals that cannot restore the previous one.
64
+ setTitle(provider, exitTitle = '', { inherited = false } = {}) {
65
+ this.titleProvider = provider;
66
+ this.exitTitle = exitTitle;
67
+ this.titleInherited = inherited;
68
+ this.schedule();
69
+ }
70
+
71
+ // The escape sequence that brings the title up to date: nothing when it has not changed.
72
+ titleUpdate() {
73
+ if (!this.titleProvider) return '';
74
+ let text;
75
+ try { text = cleanTitle(this.titleProvider()); } catch { return ''; }
76
+ if (!text || text === this.title) return '';
77
+ const first = this.title === null && !this.titleInherited;
78
+ this.title = text;
79
+ return (first ? TITLE_PUSH : '') + `\x1b]0;${text}\x07`;
80
+ }
81
+
82
+ get width() { return Math.max(20, this.out.columns || termWidth()); }
83
+ get height() { return Math.max(10, this.out.rows || 30); }
84
+ // True from a size change until the repaint that follows it.
85
+ get resizing() { return this.resizeTimer !== null; }
86
+
87
+ setLive(fn) { this.renderLive = fn; this.schedule(); }
88
+
89
+ // Append lines (strings, may contain \n) to the static log.
90
+ log(...items) {
91
+ for (const item of items) {
92
+ if (Array.isArray(item)) this.staticQueue.push(...item);
93
+ else this.staticQueue.push(String(item));
94
+ }
95
+ this.flush();
96
+ }
97
+
98
+ schedule() {
99
+ if (this.scheduled || this.closed) return;
100
+ this.scheduled = setTimeout(() => { this.scheduled = null; this.flush(); }, 16);
101
+ }
102
+
103
+ // Take the queued static output as lines, remembering them for a later repaint.
104
+ takeStatic() {
105
+ if (!this.staticQueue.length) return [];
106
+ const lines = this.staticQueue.join('\n').split(/\r?\n/);
107
+ this.staticQueue = [];
108
+ for (const line of lines) this.history.push(line.length > HISTORY_LINE_CHARS ? line.slice(0, HISTORY_LINE_CHARS) : line);
109
+ if (this.history.length > HISTORY_KEEP * 2) this.history.splice(0, this.history.length - HISTORY_KEEP);
110
+ return lines;
111
+ }
112
+
113
+ // The live region as rows (pre-wrapped to cols-1, capped to fit the viewport) and where the cursor goes.
114
+ layoutLive(cols) {
115
+ let live;
116
+ try {
117
+ live = this.renderLive(cols) || {};
118
+ } catch (e) {
119
+ live = { lines: [`\x1b[31mrender error: ${e.message}\x1b[0m`] };
120
+ }
121
+ const { lines = [], cursor = null } = live;
122
+ const rows = [];
123
+ let cursorRow = null, cursorCol = 0;
124
+ lines.forEach((line, i) => {
125
+ const wrapped = wrapLine(line, cols - 1);
126
+ if (cursor && cursor.row === i) {
127
+ const col = cursor.col;
128
+ const extra = Math.floor(col / (cols - 1));
129
+ cursorRow = rows.length + Math.min(extra, wrapped.length - 1);
130
+ cursorCol = col - extra * (cols - 1);
131
+ }
132
+ rows.push(...wrapped);
133
+ });
134
+ // Cap to the terminal height so there is always a way back to the top.
135
+ const maxRows = Math.max(1, Math.min(this.height, this.out.rows || this.height) - 1);
136
+ if (rows.length > maxRows) {
137
+ const dropped = rows.length - maxRows;
138
+ rows.splice(0, dropped);
139
+ if (cursorRow !== null) cursorRow = Math.max(0, cursorRow - dropped);
140
+ }
141
+ return { rows, cursorRow, cursorCol };
142
+ }
143
+
144
+ // Escape sequence that leaves the cursor where the user types, and the bookkeeping for the next redraw.
145
+ settleCursor({ rows, cursorRow, cursorCol }) {
146
+ const last = Math.max(0, rows.length - 1);
147
+ let buf = '';
148
+ if (cursorRow !== null && rows.length) {
149
+ buf = ESC.up(last - cursorRow) + '\r' + ESC.right(cursorCol) + ESC.showCursor;
150
+ this.cursorRow = cursorRow;
151
+ } else {
152
+ this.cursorRow = last;
153
+ }
154
+ return buf;
155
+ }
156
+
157
+ onResize() {
158
+ if (this.closed) return;
159
+ const now = Date.now();
160
+ if (!this.resizeSince) this.resizeSince = now;
161
+ clearTimeout(this.resizeTimer);
162
+ const wait = Math.max(0, Math.min(RESIZE_QUIET_MS, this.resizeSince + RESIZE_MAX_WAIT_MS - now));
163
+ this.resizeTimer = setTimeout(() => this.settleResize(), wait);
164
+ }
165
+
166
+ settleResize() {
167
+ this.resizeTimer = null;
168
+ this.resizeSince = 0;
169
+ this.repaint();
170
+ }
171
+
172
+ // Write the whole viewport again after a resize: the newest static lines that fit, then the live region,
173
+ // so that the last row of the live region is the last row of the screen (when there is enough output to
174
+ // fill it). Absolute positioning only, no cursor-relative moves: a relative move computed for one width
175
+ // lands in the wrong place when the terminal has already re-flowed to another, which is exactly what
176
+ // happens when the size keeps changing.
177
+ //
178
+ // What was on screen above our output (a short session: the shell that started us) is overwritten.
179
+ repaint() {
180
+ if (this.closed) return;
181
+ if (this.scheduled) { clearTimeout(this.scheduled); this.scheduled = null; }
182
+ clearTimeout(this.resizeTimer);
183
+ this.resizeTimer = null;
184
+ this.resizeSince = 0;
185
+ const cols = this.width;
186
+ const screenRows = Math.max(1, this.out.rows || this.height);
187
+ this.takeStatic();
188
+ const layout = this.layoutLive(cols);
189
+ const room = Math.max(0, screenRows - layout.rows.length);
190
+ const tail = [];
191
+ for (let used = 0, i = this.history.length - 1; i >= 0; i--) {
192
+ const n = rowsOf(this.history[i], cols);
193
+ if (used + n > room) break;
194
+ tail.unshift(this.history[i]);
195
+ used += n;
196
+ }
197
+ // Every row is overwritten (EL clears what the new text does not cover) and what is left below is
198
+ // erased, so no old text survives, wherever the terminal put it when it re-flowed.
199
+ const all = [...tail, ...layout.rows];
200
+ let buf = SYNC_START + ESC.hideCursor + this.titleUpdate() + '\x1b[H';
201
+ buf += all.map((row) => row + '\x1b[0m\x1b[K').join('\r\n');
202
+ buf += ESC.clearDown;
203
+ buf += this.settleCursor(layout);
204
+ buf += SYNC_END;
205
+ this.out.write(buf);
206
+ }
207
+
208
+ flush() {
209
+ if (this.closed) return;
210
+ if (this.resizing) return; // the repaint after the resize writes everything that is waiting
211
+ if (this.scheduled) { clearTimeout(this.scheduled); this.scheduled = null; }
212
+ const cols = this.width;
213
+ let buf = SYNC_START + ESC.hideCursor + this.titleUpdate() + '\r' + ESC.up(this.cursorRow) + ESC.clearDown;
214
+ const lines = this.takeStatic();
215
+ if (lines.length) {
216
+ // Explicit CRLF: not every terminal translates a bare LF into CR+LF.
217
+ buf += lines.map((l) => l + '\x1b[0m').join('\r\n') + '\r\n';
218
+ }
219
+ const layout = this.layoutLive(cols);
220
+ buf += layout.rows.map((r) => r + '\x1b[0m').join('\r\n');
221
+ buf += this.settleCursor(layout);
222
+ buf += SYNC_END;
223
+ this.out.write(buf);
224
+ }
225
+
226
+ // Erase the live region (used before exiting or handing the terminal to a child).
227
+ clearLive() {
228
+ if (this.resizing) this.repaint();
229
+ if (this.scheduled) { clearTimeout(this.scheduled); this.scheduled = null; }
230
+ this.out.write('\r' + ESC.up(this.cursorRow) + ESC.clearDown + ESC.showCursor);
231
+ this.cursorRow = 0;
232
+ }
233
+
234
+ clearScreen() {
235
+ this.out.write(ESC.clearScreen);
236
+ this.cursorRow = 0;
237
+ this.history = [];
238
+ this.flush();
239
+ }
240
+
241
+ // Print any pending output, then remove the live region (input box etc.) for good.
242
+ // `keepTitle`: another process takes the terminal over at once (a restart), so the tab keeps its title.
243
+ close({ keepTitle = false } = {}) {
244
+ if (this.closed) return;
245
+ if (this.resizing) this.repaint(); // the cursor is not where the last draw left it: put things in order first
246
+ this.renderLive = () => ({ lines: [] });
247
+ this.flush();
248
+ this.closed = true;
249
+ clearTimeout(this.resizeTimer);
250
+ // Give the tab back its old label: a terminal with a title stack restores it, and one without is
251
+ // left with a neutral one rather than a title that still looks like a program that is running.
252
+ let restore = '';
253
+ if (this.title !== null && !keepTitle) {
254
+ const neutral = cleanTitle(this.exitTitle);
255
+ restore = (neutral ? `\x1b]0;${neutral}\x07` : '') + TITLE_POP;
256
+ }
257
+ this.out.write(restore + ESC.showCursor);
258
+ }
259
+ }
package/src/updater.js ADDED
@@ -0,0 +1,93 @@
1
+ // Auto-update, as in Claude Code: an interactive session installed with `npm i -g claude-pro` looks for a newer
2
+ // version on the npm registry (at most once every few hours), installs it in the background, and the session
3
+ // then loads it with a restart once it is idle (the conversation carries over). A git checkout (the
4
+ // development copy) is never touched. Off with CLAUDE_PRO_AUTO_UPDATE=0 or DISABLE_AUTOUPDATER=1.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import https from 'node:https';
9
+ import { spawn } from 'node:child_process';
10
+ import { CONFIG_DIR, INSTALL_DIR, VERSION } from './config.js';
11
+
12
+ // The name on npm (scoped: npm refused plain `claude-pro` as too close to another package's name).
13
+ export const PACKAGE_NAME = JSON.parse(fs.readFileSync(path.join(INSTALL_DIR, 'package.json'), 'utf8')).name;
14
+ const STATE_FILE = path.join(CONFIG_DIR, 'update-check.json');
15
+ const CHECK_EVERY_MS = 6 * 3600_000;
16
+
17
+ export function autoUpdateEnabled(env = process.env) {
18
+ if (/^(1|true|yes)$/i.test(env.DISABLE_AUTOUPDATER || '')) return false;
19
+ return !/^(0|false|no|off)$/i.test(env.CLAUDE_PRO_AUTO_UPDATE || '');
20
+ }
21
+
22
+ // The node_modules directory a package is installed in: one level up, or two for a scoped (@scope/name) package.
23
+ export function modulesDir(dir = INSTALL_DIR) {
24
+ const up = path.dirname(dir);
25
+ return path.basename(up).startsWith('@') ? path.dirname(up) : up;
26
+ }
27
+
28
+ // Installed by npm (a package in some node_modules), not run from a source checkout.
29
+ export function installedByNpm(dir = INSTALL_DIR) {
30
+ return path.basename(modulesDir(dir)) === 'node_modules' && !fs.existsSync(path.join(dir, '.git')) && !/[\\/]_npx[\\/]/.test(dir);
31
+ }
32
+
33
+ // 1.2.10 > 1.2.9; a prerelease (1.3.0-beta) is never taken for newer.
34
+ export function isNewer(latest, current) {
35
+ if (!/^\d+\.\d+\.\d+$/.test(String(latest))) return false;
36
+ const a = latest.split('.').map(Number);
37
+ const b = String(current).split('-')[0].split('.').map(Number);
38
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > (b[i] || 0);
39
+ return false;
40
+ }
41
+
42
+ function readState() { try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch { return {}; } }
43
+ function writeState(s) { try { fs.mkdirSync(CONFIG_DIR, { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(s)); } catch { /* ignore */ } }
44
+
45
+ export function latestVersion({ timeoutMs = 10_000 } = {}) {
46
+ return new Promise((resolve, reject) => {
47
+ const req = https.get(`https://registry.npmjs.org/${PACKAGE_NAME.replace('/', '%2f')}/latest`, { headers: { accept: 'application/json' }, timeout: timeoutMs }, (res) => {
48
+ let body = '';
49
+ res.setEncoding('utf8');
50
+ res.on('data', (d) => { body += d; });
51
+ res.on('end', () => {
52
+ if (res.statusCode !== 200) return reject(new Error(`npm registry: HTTP ${res.statusCode}`));
53
+ try { resolve(JSON.parse(body).version); } catch (e) { reject(e); }
54
+ });
55
+ });
56
+ req.on('timeout', () => req.destroy(new Error('npm registry did not answer')));
57
+ req.on('error', reject);
58
+ });
59
+ }
60
+
61
+ function runNpm(args, { timeoutMs = 300_000 } = {}) {
62
+ return new Promise((resolve) => {
63
+ const child = spawn(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, { shell: process.platform === 'win32', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
64
+ let out = '';
65
+ child.stdout.on('data', (d) => { out += d; });
66
+ child.stderr.on('data', (d) => { out += d; });
67
+ const timer = setTimeout(() => child.kill(), timeoutMs);
68
+ child.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, output: e.message }); });
69
+ child.on('close', (code) => { clearTimeout(timer); resolve({ ok: code === 0, output: out.trim() }); });
70
+ });
71
+ }
72
+
73
+ // Look for a newer version and install it. Resolves with {status: 'disabled'|'dev'|'skipped'|'current'|'updated'|'failed', version, error}.
74
+ // `force`: check now, whatever the last check was (the /update command).
75
+ export async function checkForUpdate({ force = false, env = process.env, onInstalling } = {}) {
76
+ if (!force && !autoUpdateEnabled(env)) return { status: 'disabled' };
77
+ if (!installedByNpm()) return { status: 'dev' };
78
+ const state = readState();
79
+ if (!force && state.checkedAt && Date.now() - state.checkedAt < CHECK_EVERY_MS) return { status: 'skipped' };
80
+ writeState({ ...state, checkedAt: Date.now() });
81
+ const latest = await latestVersion();
82
+ if (!isNewer(latest, VERSION)) return { status: 'current', version: latest };
83
+ // Only a global install is updated by `npm i -g`: one in a project's node_modules belongs to that project.
84
+ const root = await runNpm(['root', '-g'], { timeoutMs: 30_000 });
85
+ if (!root.ok || path.resolve(root.output.split(/\r?\n/).pop()) !== path.resolve(modulesDir())) {
86
+ return { status: 'failed', version: latest, error: `this copy (${INSTALL_DIR}) is not the global npm install; update it with npm yourself` };
87
+ }
88
+ onInstalling?.(latest);
89
+ const r = await runNpm(['install', '-g', `${PACKAGE_NAME}@${latest}`]);
90
+ if (!r.ok) return { status: 'failed', version: latest, error: r.output.split('\n').slice(-5).join('\n') };
91
+ writeState({ checkedAt: Date.now(), installed: latest });
92
+ return { status: 'updated', version: latest };
93
+ }
@@ -0,0 +1,197 @@
1
+ // Read an image from the system clipboard (alt+v / ctrl+v in the prompt), like Claude Code.
2
+ // Windows: PowerShell + System.Windows.Forms; macOS: osascript; Linux: wl-paste / xclip.
3
+ // Large images are scaled so the long edge is at most 1568px (the API's recommended size).
4
+
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { spawn } from 'node:child_process';
9
+
10
+ export const MAX_IMAGE_BYTES = 3_750_000; // ~5MB once base64 encoded
11
+ const IMAGE_EXT = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
12
+
13
+ function run(cmd, args, { env, timeout = 15_000 } = {}) {
14
+ return new Promise((resolve) => {
15
+ let out = '', err = '';
16
+ let child;
17
+ try {
18
+ child = spawn(cmd, args, { env: { ...process.env, ...env }, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
19
+ } catch (e) {
20
+ resolve({ code: -1, out: '', err: e.message });
21
+ return;
22
+ }
23
+ const timer = setTimeout(() => child.kill(), timeout);
24
+ child.stdout.on('data', (d) => { out += d; });
25
+ child.stderr.on('data', (d) => { err += d; });
26
+ child.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, out, err: e.message }); });
27
+ child.on('close', (code) => { clearTimeout(timer); resolve({ code, out: out.trim(), err: err.trim() }); });
28
+ });
29
+ }
30
+
31
+ // Prints "IMG <format> <w>x<h>" after writing $env:CLAUDE_PRO_IMG_OUT, or "FILE <path>" when the
32
+ // clipboard holds a copied image file. Exit code 2 = no image. CLAUDE_PRO_IMG_SOURCE (a file) replaces
33
+ // the clipboard as input, which the tests use so they don't clobber the real clipboard.
34
+ const PS_SCRIPT = `
35
+ $ErrorActionPreference = 'Stop'
36
+ Add-Type -AssemblyName System.Windows.Forms
37
+ Add-Type -AssemblyName System.Drawing
38
+ $out = $env:CLAUDE_PRO_IMG_OUT
39
+ $img = $null
40
+ if ($env:CLAUDE_PRO_IMG_SOURCE) {
41
+ $img = [System.Drawing.Image]::FromFile($env:CLAUDE_PRO_IMG_SOURCE)
42
+ } elseif ([System.Windows.Forms.Clipboard]::ContainsImage()) {
43
+ $img = [System.Windows.Forms.Clipboard]::GetImage()
44
+ } elseif ([System.Windows.Forms.Clipboard]::ContainsFileDropList()) {
45
+ foreach ($f in [System.Windows.Forms.Clipboard]::GetFileDropList()) {
46
+ if ($f -match '\\.(png|jpe?g|gif|webp|bmp)$') { Write-Output "FILE $f"; exit 0 }
47
+ }
48
+ }
49
+ if ($img -eq $null) { exit 2 }
50
+ $max = 1568
51
+ $scale = [Math]::Min(1.0, $max / [Math]::Max($img.Width, $img.Height))
52
+ if ($scale -lt 1.0) {
53
+ $w = [int][Math]::Round($img.Width * $scale); $h = [int][Math]::Round($img.Height * $scale)
54
+ $bmp = New-Object System.Drawing.Bitmap $w, $h
55
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
56
+ $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
57
+ $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
58
+ $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
59
+ $g.DrawImage($img, 0, 0, $w, $h)
60
+ $g.Dispose(); $img.Dispose(); $img = $bmp
61
+ }
62
+ $img.Save($out, [System.Drawing.Imaging.ImageFormat]::Png)
63
+ $format = 'png'
64
+ if ((Get-Item $out).Length -gt ${MAX_IMAGE_BYTES}) {
65
+ $codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' }
66
+ $params = New-Object System.Drawing.Imaging.EncoderParameters 1
67
+ $params.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter ([System.Drawing.Imaging.Encoder]::Quality), 85L
68
+ $img.Save($out, $codec, $params)
69
+ $format = 'jpeg'
70
+ }
71
+ Write-Output ("IMG " + $format + " " + $img.Width + "x" + $img.Height)
72
+ `;
73
+
74
+ // Turn a file on disk into an image block, or return an error string.
75
+ export function imageFromFile(file) {
76
+ const media = IMAGE_EXT[path.extname(file).toLowerCase()];
77
+ if (!media) return { error: `Not a supported image type: ${path.basename(file)}` };
78
+ let st;
79
+ try { st = fs.statSync(file); } catch { return { error: `File not found: ${file}` }; }
80
+ if (!st.isFile()) return { error: `Not a file: ${file}` };
81
+ if (st.size > MAX_IMAGE_BYTES) return { error: `Image is too large (${(st.size / 1e6).toFixed(1)}MB, max 3.7MB)` };
82
+ return { image: { media_type: media, data: fs.readFileSync(file).toString('base64'), bytes: st.size, source: file } };
83
+ }
84
+
85
+ async function readWindows(source) {
86
+ const outFile = path.join(os.tmpdir(), `claude-pro-clipboard-${process.pid}-${Date.now()}.img`);
87
+ const encoded = Buffer.from(PS_SCRIPT, 'utf16le').toString('base64');
88
+ const r = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded], {
89
+ env: { CLAUDE_PRO_IMG_OUT: outFile, CLAUDE_PRO_IMG_SOURCE: source || '' },
90
+ });
91
+ try {
92
+ if (r.code === 2) return { error: null };
93
+ if (r.code !== 0) return { error: `Could not read the clipboard: ${(r.err || r.out || `exit ${r.code}`).split('\n')[0]}` };
94
+ if (r.out.startsWith('FILE ')) return imageFromFile(r.out.slice(5).trim());
95
+ const m = /^IMG (\w+) (\d+)x(\d+)/m.exec(r.out);
96
+ if (!m) return { error: `Unexpected clipboard reader output: ${r.out.slice(0, 200)}` };
97
+ const buf = fs.readFileSync(outFile);
98
+ return { image: { media_type: `image/${m[1]}`, data: buf.toString('base64'), bytes: buf.length, width: +m[2], height: +m[3] } };
99
+ } finally {
100
+ fs.rm(outFile, { force: true }, () => {});
101
+ }
102
+ }
103
+
104
+ async function readMac() {
105
+ const outFile = path.join(os.tmpdir(), `claude-pro-clipboard-${process.pid}-${Date.now()}.png`);
106
+ const r = await run('osascript', [
107
+ '-e', `set f to (open for access POSIX file "${outFile}" with write permission)`,
108
+ '-e', 'try', '-e', 'write (the clipboard as «class PNGf») to f', '-e', 'end try', '-e', 'close access f',
109
+ ]);
110
+ try {
111
+ const size = fs.existsSync(outFile) ? fs.statSync(outFile).size : 0;
112
+ if (r.code !== 0 || size === 0) return { error: null };
113
+ return imageFromFile(outFile);
114
+ } finally {
115
+ fs.rm(outFile, { force: true }, () => {});
116
+ }
117
+ }
118
+
119
+ async function readLinux() {
120
+ for (const [cmd, args] of [['wl-paste', ['--type', 'image/png']], ['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']]]) {
121
+ const r = await new Promise((resolve) => {
122
+ const chunks = [];
123
+ const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] });
124
+ // A clipboard owner that never answers must not leave the reader hanging.
125
+ const timer = setTimeout(() => child.kill(), 5000);
126
+ child.stdout.on('data', (d) => chunks.push(d));
127
+ child.on('error', () => { clearTimeout(timer); resolve(null); });
128
+ child.on('close', (code) => { clearTimeout(timer); resolve(code === 0 ? Buffer.concat(chunks) : null); });
129
+ });
130
+ if (r?.length) {
131
+ if (r.length > MAX_IMAGE_BYTES) return { error: 'Image is too large (max 3.7MB)' };
132
+ return { image: { media_type: 'image/png', data: r.toString('base64'), bytes: r.length } };
133
+ }
134
+ }
135
+ return { error: null };
136
+ }
137
+
138
+ // Returns {image} on success, {error: string} on failure, or {error: null} when the clipboard has no image.
139
+ export async function readClipboardImage({ source } = {}) {
140
+ if (process.platform === 'win32') return readWindows(source);
141
+ if (process.platform === 'darwin') return readMac();
142
+ return readLinux();
143
+ }
144
+
145
+ // Watch the clipboard and call `onCopy(chars)` when something new is put on it, so that the program can say
146
+ // "copied" after the user copies text selected in the terminal (the terminal itself does not tell the program).
147
+ // Windows only: one long-lived PowerShell that polls the clipboard's sequence number, which is cheap, and leaves
148
+ // when this process is gone. Returns {close()}; elsewhere it never calls back.
149
+ const WATCH_SCRIPT = `
150
+ $ErrorActionPreference = 'SilentlyContinue'
151
+ Add-Type -Namespace ClaudePro -Name Clip -MemberDefinition '[DllImport("user32.dll")] public static extern uint GetClipboardSequenceNumber();'
152
+ $parent = [int]$env:CLAUDE_PRO_PARENT_PID
153
+ $last = [ClaudePro.Clip]::GetClipboardSequenceNumber()
154
+ $n = 0
155
+ while ($true) {
156
+ Start-Sleep -Milliseconds 250
157
+ $seq = [ClaudePro.Clip]::GetClipboardSequenceNumber()
158
+ if ($seq -ne $last) {
159
+ $last = $seq
160
+ Start-Sleep -Milliseconds 50
161
+ $text = Get-Clipboard -Raw
162
+ $len = if ($text) { $text.Length } else { 0 }
163
+ [Console]::Out.WriteLine("COPY $len")
164
+ [Console]::Out.Flush()
165
+ }
166
+ $n++
167
+ if ($n % 8 -eq 0) { try { [void][System.Diagnostics.Process]::GetProcessById($parent) } catch { exit } }
168
+ }
169
+ `;
170
+
171
+ export function watchClipboard(onCopy) {
172
+ if (process.platform !== 'win32') return { close() {} };
173
+ let child;
174
+ try {
175
+ const encoded = Buffer.from(WATCH_SCRIPT, 'utf16le').toString('base64'); // "-Command -" runs a loop line by line, which breaks it
176
+ child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], {
177
+ env: { ...process.env, CLAUDE_PRO_PARENT_PID: String(process.pid) }, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'],
178
+ });
179
+ } catch {
180
+ return { close() {} };
181
+ }
182
+ child.on('error', () => {});
183
+ let buf = '';
184
+ child.stdout.setEncoding('utf8');
185
+ child.stdout.on('data', (d) => {
186
+ buf += d;
187
+ let i;
188
+ while ((i = buf.indexOf('\n')) >= 0) {
189
+ const m = /^COPY (\d+)/.exec(buf.slice(0, i).trim());
190
+ buf = buf.slice(i + 1);
191
+ if (m) { try { onCopy(Number(m[1])); } catch { /* the callback's problem */ } }
192
+ }
193
+ });
194
+ child.unref();
195
+ child.stdout.unref?.();
196
+ return { close() { try { child.kill(); } catch { /* gone already */ } } };
197
+ }
@@ -0,0 +1,95 @@
1
+ // Line diff (Myers O(ND)) and hunk construction for displaying edits.
2
+
3
+ export function diffLines(a, b) {
4
+ const n = a.length, m = b.length;
5
+ const max = n + m;
6
+ if (max === 0) return [];
7
+ // Guard against pathological sizes: fall back to delete-all / insert-all.
8
+ if (n * m > 25_000_000) {
9
+ return [...a.map((line) => ({ op: '-', line })), ...b.map((line) => ({ op: '+', line }))];
10
+ }
11
+ const v = new Int32Array(2 * max + 2);
12
+ const trace = [];
13
+ const off = max + 1;
14
+ let found = false;
15
+ for (let d = 0; d <= max && !found; d++) {
16
+ trace.push(v.slice());
17
+ for (let k = -d; k <= d; k += 2) {
18
+ let x;
19
+ if (k === -d || (k !== d && v[off + k - 1] < v[off + k + 1])) x = v[off + k + 1];
20
+ else x = v[off + k - 1] + 1;
21
+ let y = x - k;
22
+ while (x < n && y < m && a[x] === b[y]) { x++; y++; }
23
+ v[off + k] = x;
24
+ if (x >= n && y >= m) { found = true; break; }
25
+ }
26
+ }
27
+ // Backtrack.
28
+ const ops = [];
29
+ let x = n, y = m;
30
+ for (let d = trace.length - 1; d >= 0; d--) {
31
+ const vv = trace[d];
32
+ const k = x - y;
33
+ let prevK;
34
+ if (k === -d || (k !== d && vv[off + k - 1] < vv[off + k + 1])) prevK = k + 1;
35
+ else prevK = k - 1;
36
+ const prevX = vv[off + prevK];
37
+ const prevY = prevX - prevK;
38
+ while (x > prevX && y > prevY) { ops.push({ op: ' ', line: a[x - 1] }); x--; y--; }
39
+ if (d > 0) {
40
+ if (x === prevX) ops.push({ op: '+', line: b[y - 1] });
41
+ else ops.push({ op: '-', line: a[x - 1] });
42
+ }
43
+ x = prevX; y = prevY;
44
+ }
45
+ return ops.reverse();
46
+ }
47
+
48
+ // Build hunks with line numbers. Returns [{oldStart, newStart, lines:[{op, line, oldNo, newNo}]}]
49
+ export function makeHunks(ops, context = 3, oldOffset = 0, newOffset = 0) {
50
+ let oldNo = oldOffset, newNo = newOffset;
51
+ const numbered = ops.map((o) => {
52
+ const r = { ...o, oldNo: null, newNo: null };
53
+ if (o.op !== '+') r.oldNo = ++oldNo;
54
+ if (o.op !== '-') r.newNo = ++newNo;
55
+ return r;
56
+ });
57
+ const hunks = [];
58
+ let cur = null;
59
+ let lastChange = -Infinity;
60
+ for (let i = 0; i < numbered.length; i++) {
61
+ if (numbered[i].op === ' ') continue;
62
+ const start = Math.max(0, i - context);
63
+ if (cur && start <= lastChange + context + 1) {
64
+ for (let j = lastChange + 1; j <= i; j++) cur.lines.push(numbered[j]);
65
+ } else {
66
+ if (cur) {
67
+ for (let j = lastChange + 1; j <= Math.min(numbered.length - 1, lastChange + context); j++) cur.lines.push(numbered[j]);
68
+ hunks.push(cur);
69
+ }
70
+ cur = { lines: numbered.slice(start, i + 1) };
71
+ }
72
+ lastChange = i;
73
+ }
74
+ if (cur) {
75
+ for (let j = lastChange + 1; j <= Math.min(numbered.length - 1, lastChange + context); j++) cur.lines.push(numbered[j]);
76
+ hunks.push(cur);
77
+ }
78
+ return hunks;
79
+ }
80
+
81
+ export function countChanges(ops) {
82
+ let added = 0, removed = 0;
83
+ for (const o of ops) {
84
+ if (o.op === '+') added++;
85
+ else if (o.op === '-') removed++;
86
+ }
87
+ return { added, removed };
88
+ }
89
+
90
+ export function splitLines(text) {
91
+ if (text === '') return [];
92
+ const lines = text.replace(/\r\n/g, '\n').split('\n');
93
+ if (lines[lines.length - 1] === '') lines.pop();
94
+ return lines;
95
+ }
@@ -0,0 +1,37 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+
4
+ const isWin = process.platform === 'win32';
5
+
6
+ // Resolve a path the model gave us. Accepts absolute, relative (to cwd), ~, and on Windows
7
+ // also Git-Bash style paths such as /d/foo/bar or /c/Users/...
8
+ export function resolvePath(p, cwd) {
9
+ if (!p) return cwd;
10
+ p = String(p).trim();
11
+ if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) p = path.join(os.homedir(), p.slice(1));
12
+ if (isWin) {
13
+ const m = /^\/([a-zA-Z])(\/.*)?$/.exec(p);
14
+ if (m) p = `${m[1].toUpperCase()}:${(m[2] || '/').replace(/\//g, '\\')}`;
15
+ }
16
+ return path.resolve(cwd, p);
17
+ }
18
+
19
+ // Short path for display: relative to cwd when inside it.
20
+ export function displayPath(p, cwd) {
21
+ if (!p) return '';
22
+ const rel = path.relative(cwd, p);
23
+ if (!rel) return '.';
24
+ if (!rel.startsWith('..') && !path.isAbsolute(rel)) return rel;
25
+ const home = os.homedir();
26
+ if (p.startsWith(home)) return '~' + p.slice(home.length);
27
+ return p;
28
+ }
29
+
30
+ export function isInside(child, parent) {
31
+ const rel = path.relative(parent, child);
32
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
33
+ }
34
+
35
+ export function samePath(a, b) {
36
+ return isWin ? path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase() : path.resolve(a) === path.resolve(b);
37
+ }