@znmin/claude-remote-cli 0.3.2 → 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.
- package/README.md +53 -3
- package/THIRD_PARTY_NOTICES.md +16 -16
- package/bin/claude-remote.js +3 -3
- package/bin/claude.js +3 -3
- package/package.json +67 -67
- package/scripts/postinstall.js +11 -11
- package/src/cli.js +187 -173
- package/src/client.js +232 -232
- package/src/installer.js +96 -96
- package/src/local/browser.js +290 -290
- package/src/local/clipboard.js +87 -0
- package/src/local/mcp-config.js +320 -0
- package/src/local/mcp-proxy.js +199 -0
- package/src/local/processes.js +144 -144
- package/src/local/server.js +216 -193
- package/src/local/shell.js +72 -72
- package/src/local/tunnel.js +98 -98
- package/src/local/workspace.js +225 -225
- package/src/local-mode.js +229 -181
- package/src/powershell-conflicts.js +67 -67
- package/src/remote-claude.js +39 -39
- package/src/remote-clipboard.js +97 -0
- package/src/ssh-auth.js +113 -113
- package/src/ssh-policy.js +92 -92
- package/src/ssh.js +16 -16
- package/src/system.js +179 -179
package/src/local/workspace.js
CHANGED
|
@@ -1,225 +1,225 @@
|
|
|
1
|
-
import * as fs from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { execFile } from 'node:child_process';
|
|
4
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
-
import { rename } from '../system.js';
|
|
6
|
-
|
|
7
|
-
const hash = text => createHash('sha256').update(text).digest('hex');
|
|
8
|
-
// Windows paths are case-insensitive: C:\Project and c:\project are the same folder.
|
|
9
|
-
const fold = process.platform === 'win32' ? value => value.toLowerCase() : value => value;
|
|
10
|
-
const inside = (root, target) => fold(target) === fold(root) || fold(target).startsWith(fold(root) + path.sep);
|
|
11
|
-
// Lines are shown without a trailing \r, so CRLF files read like LF files.
|
|
12
|
-
const stripCR = line => line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
13
|
-
|
|
14
|
-
// Keep read_file results below the MCP response limit (JSON escaping adds overhead).
|
|
15
|
-
const READ_BUDGET = 28000;
|
|
16
|
-
const SKIPPED = new Set(['.git', 'node_modules', '.venv', '__pycache__']);
|
|
17
|
-
const MAX_FILES = 10000;
|
|
18
|
-
const escapeRegExp = text => text.replace(/[.+^${}()|[\]\\*?]/g, '\\$&');
|
|
19
|
-
|
|
20
|
-
// Minimal glob: *, **, ?, {a,b}. Without a slash it matches the file name only.
|
|
21
|
-
export function globMatcher(glob) {
|
|
22
|
-
let source = '';
|
|
23
|
-
for (let i = 0; i < glob.length; i++) {
|
|
24
|
-
const char = glob[i];
|
|
25
|
-
if (char === '*' && glob[i + 1] === '*') {
|
|
26
|
-
i++;
|
|
27
|
-
if (glob[i + 1] === '/') { i++; source += '(?:.*/)?'; } else source += '.*';
|
|
28
|
-
} else if (char === '*') source += '[^/]*';
|
|
29
|
-
else if (char === '?') source += '[^/]';
|
|
30
|
-
else if (char === '{' && glob.indexOf('}', i) > i) {
|
|
31
|
-
const end = glob.indexOf('}', i);
|
|
32
|
-
source += '(?:' + glob.slice(i + 1, end).split(',').map(escapeRegExp).join('|') + ')';
|
|
33
|
-
i = end;
|
|
34
|
-
} else source += escapeRegExp(char);
|
|
35
|
-
}
|
|
36
|
-
const pattern = new RegExp('^' + source + '$');
|
|
37
|
-
const byName = !glob.includes('/');
|
|
38
|
-
return relative => pattern.test(byName ? path.posix.basename(relative) : relative);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function gitFiles(directory) {
|
|
42
|
-
return new Promise(resolve => {
|
|
43
|
-
execFile('git', ['-C', directory, 'ls-files', '-z', '--cached', '--others', '--exclude-standard'],
|
|
44
|
-
{ maxBuffer: 64 * 1024 * 1024, timeout: 15000 }, (error, stdout) => {
|
|
45
|
-
resolve(error ? null : stdout.split('\0').filter(Boolean));
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export class Workspace {
|
|
51
|
-
constructor(root) { this.root = root; }
|
|
52
|
-
|
|
53
|
-
async resolve(input = '.', writing = false) {
|
|
54
|
-
const candidate = path.resolve(this.root, input);
|
|
55
|
-
if (!inside(this.root, candidate)) throw new Error('文件路径必须位于当前项目内。');
|
|
56
|
-
let canonical;
|
|
57
|
-
try { canonical = await fs.realpath(candidate); }
|
|
58
|
-
catch (error) {
|
|
59
|
-
if (error.code !== 'ENOENT' || !writing) throw error;
|
|
60
|
-
let parent = path.dirname(candidate);
|
|
61
|
-
while (true) {
|
|
62
|
-
try { canonical = path.join(await fs.realpath(parent), path.relative(parent, candidate)); break; }
|
|
63
|
-
catch (e) {
|
|
64
|
-
if (e.code !== 'ENOENT' || parent === path.dirname(parent)) throw e;
|
|
65
|
-
parent = path.dirname(parent);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
if (!inside(this.root, canonical)) throw new Error('符号链接指向项目外部,无法访问。');
|
|
70
|
-
return canonical;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async read(file, offset = 1, limit = 200) {
|
|
74
|
-
const target = await this.resolve(file);
|
|
75
|
-
const stat = await fs.stat(target);
|
|
76
|
-
if (!stat.isFile() || stat.size > 2 * 1024 * 1024) throw new Error('仅支持读取 2 MiB 以内的文本文件。');
|
|
77
|
-
const content = await fs.readFile(target, 'utf8');
|
|
78
|
-
if (content.includes('\0')) throw new Error('这是二进制文件,请通过终端或专用工具处理。');
|
|
79
|
-
const lines = content.split('\n');
|
|
80
|
-
if (lines.length > 1 && lines.at(-1) === '') lines.pop(); // A trailing newline is not an extra line.
|
|
81
|
-
const total = content === '' ? 0 : lines.length;
|
|
82
|
-
const output = [];
|
|
83
|
-
let used = 0, sizeLimited = false;
|
|
84
|
-
for (let i = offset - 1; i < Math.min(total, offset - 1 + limit); i++) {
|
|
85
|
-
const line = `${i + 1}: ${stripCR(lines[i])}`;
|
|
86
|
-
if (used + line.length > READ_BUDGET) {
|
|
87
|
-
sizeLimited = true;
|
|
88
|
-
if (!output.length) output.push(line.slice(0, READ_BUDGET) + ` …[本行过长已截断,共 ${lines[i].length} 字符,请用终端查看]`);
|
|
89
|
-
break;
|
|
90
|
-
}
|
|
91
|
-
output.push(line);
|
|
92
|
-
used += line.length + 1;
|
|
93
|
-
}
|
|
94
|
-
const next = offset + output.length;
|
|
95
|
-
return { path: path.relative(this.root, target), sha256: hash(content), total_lines: total,
|
|
96
|
-
content: output.join('\n'),
|
|
97
|
-
...(content.includes('\r\n') ? { line_endings: 'crlf' } : {}),
|
|
98
|
-
...(next <= total ? { next_offset: next } : {}),
|
|
99
|
-
...(sizeLimited ? { truncated_by_size: true } : {}) };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
async atomicWrite(target, content, mode = 0o644) {
|
|
103
|
-
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
104
|
-
const temp = path.join(path.dirname(target), '.claude-write-' + randomUUID());
|
|
105
|
-
try {
|
|
106
|
-
await fs.writeFile(temp, content, { flag: 'wx', mode });
|
|
107
|
-
await rename(temp, target);
|
|
108
|
-
} finally { await fs.rm(temp, { force: true }); }
|
|
109
|
-
return { path: path.relative(this.root, target), sha256: hash(content), bytes: Buffer.byteLength(content) };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async write(file, content, expectedSha256) {
|
|
113
|
-
const target = await this.resolve(file, true);
|
|
114
|
-
let mode;
|
|
115
|
-
try {
|
|
116
|
-
if ((await fs.stat(target)).size > 2 * 1024 * 1024) throw new Error('覆盖文件大小上限为 2 MiB。');
|
|
117
|
-
const old = await fs.readFile(target, 'utf8');
|
|
118
|
-
mode = (await fs.stat(target)).mode & 0o777;
|
|
119
|
-
if (!expectedSha256 || hash(old) !== expectedSha256)
|
|
120
|
-
throw new Error('文件已存在或发生变化;先 read_file,再提供匹配的 expected_sha256。');
|
|
121
|
-
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
122
|
-
return this.atomicWrite(target, content, mode);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async edit(file, oldText, newText, replaceAll = false) {
|
|
126
|
-
const target = await this.resolve(file);
|
|
127
|
-
const stat = await fs.stat(target);
|
|
128
|
-
if (stat.size > 2 * 1024 * 1024) throw new Error('编辑文件大小上限为 2 MiB。');
|
|
129
|
-
const content = await fs.readFile(target, 'utf8');
|
|
130
|
-
// read_file hides \r, so text copied from it uses \n; match CRLF files too.
|
|
131
|
-
if (!content.includes(oldText) && content.includes('\r\n') && !oldText.includes('\r')) {
|
|
132
|
-
oldText = oldText.replaceAll('\n', '\r\n');
|
|
133
|
-
newText = newText.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n');
|
|
134
|
-
}
|
|
135
|
-
const matches = content.split(oldText).length - 1;
|
|
136
|
-
if (!matches) throw new Error('找不到 old_text,请重新读取文件。');
|
|
137
|
-
if (matches > 1 && !replaceAll) throw new Error('old_text 出现多次,请提供更精确的上下文或使用 replace_all。');
|
|
138
|
-
const next = replaceAll ? content.split(oldText).join(newText) : content.replace(oldText, () => newText);
|
|
139
|
-
return this.atomicWrite(target, next, stat.mode & 0o777);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
async list(directory = '.') {
|
|
143
|
-
const target = await this.resolve(directory);
|
|
144
|
-
const entries = await fs.readdir(target, { withFileTypes: true });
|
|
145
|
-
return { path: path.relative(this.root, target) || '.', truncated: entries.length > 500,
|
|
146
|
-
entries: entries.sort((a, b) => a.name.localeCompare(b.name)).slice(0, 500).map(entry => ({
|
|
147
|
-
name: entry.name, type: entry.isSymbolicLink() ? 'symlink' : entry.isDirectory() ? 'directory' : 'file'
|
|
148
|
-
})) };
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Candidate files relative to `directory`. Inside a Git work tree this follows
|
|
152
|
-
// .gitignore; otherwise it walks the tree and skips common dependency folders.
|
|
153
|
-
async candidates(directory) {
|
|
154
|
-
const fromGit = await gitFiles(directory);
|
|
155
|
-
// An empty list usually means the directory itself is ignored; search it explicitly.
|
|
156
|
-
if (fromGit?.length) {
|
|
157
|
-
const files = fromGit.filter(file => !file.split('/').some(part => SKIPPED.has(part))).sort();
|
|
158
|
-
return { files: files.slice(0, MAX_FILES), limited: files.length > MAX_FILES, errors: 0, source: 'git' };
|
|
159
|
-
}
|
|
160
|
-
const files = [];
|
|
161
|
-
let errors = 0, limited = false;
|
|
162
|
-
const walk = async relative => {
|
|
163
|
-
let entries;
|
|
164
|
-
try { entries = await fs.readdir(path.join(directory, relative), { withFileTypes: true }); }
|
|
165
|
-
catch { errors++; return; }
|
|
166
|
-
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
167
|
-
for (const entry of entries) {
|
|
168
|
-
if (files.length >= MAX_FILES) { limited = true; return; }
|
|
169
|
-
const child = relative ? relative + '/' + entry.name : entry.name;
|
|
170
|
-
if (entry.isDirectory()) { if (!SKIPPED.has(entry.name)) await walk(child); }
|
|
171
|
-
else if (entry.isFile()) files.push(child);
|
|
172
|
-
}
|
|
173
|
-
};
|
|
174
|
-
await walk('');
|
|
175
|
-
return { files, limited, errors, source: 'walk' };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
async search(pattern, directory = '.', maxResults = 100, caseSensitive = true, { regex = false, glob } = {}) {
|
|
179
|
-
const root = await this.resolve(directory);
|
|
180
|
-
let test;
|
|
181
|
-
if (regex) {
|
|
182
|
-
let expression;
|
|
183
|
-
try { expression = new RegExp(pattern, caseSensitive ? '' : 'i'); }
|
|
184
|
-
catch (error) { throw new Error('正则表达式无效:' + error.message); }
|
|
185
|
-
test = line => expression.test(line);
|
|
186
|
-
} else {
|
|
187
|
-
const needle = caseSensitive ? pattern : pattern.toLowerCase();
|
|
188
|
-
test = line => (caseSensitive ? line : line.toLowerCase()).includes(needle);
|
|
189
|
-
}
|
|
190
|
-
const matchesGlob = glob ? globMatcher(glob) : () => true;
|
|
191
|
-
const rootStat = await fs.stat(root);
|
|
192
|
-
const listing = rootStat.isFile() ? { files: [''], limited: false, errors: 0, source: 'file' } : await this.candidates(root);
|
|
193
|
-
const files = listing.files.filter(file => file === '' || matchesGlob(file));
|
|
194
|
-
const results = [];
|
|
195
|
-
let scanned = 0, errors = listing.errors, truncated = listing.limited;
|
|
196
|
-
const scan = async relative => {
|
|
197
|
-
const target = relative ? path.join(root, relative) : root;
|
|
198
|
-
try {
|
|
199
|
-
const stat = await fs.lstat(target);
|
|
200
|
-
if (!stat.isFile() || stat.size > 1024 * 1024) return null;
|
|
201
|
-
const text = await fs.readFile(target, 'utf8');
|
|
202
|
-
scanned++;
|
|
203
|
-
if (text.includes('\0')) return null;
|
|
204
|
-
const found = [];
|
|
205
|
-
for (const [i, line] of text.split('\n').entries())
|
|
206
|
-
if (test(stripCR(line))) found.push({ path: path.relative(this.root, target), line: i + 1, text: stripCR(line).slice(0, 1000) });
|
|
207
|
-
return found;
|
|
208
|
-
} catch (error) {
|
|
209
|
-
if (error.code !== 'ENOENT') errors++; // Deleted tracked files are not errors.
|
|
210
|
-
return null;
|
|
211
|
-
}
|
|
212
|
-
};
|
|
213
|
-
// Read in parallel batches but keep results in a stable path order.
|
|
214
|
-
for (let i = 0; i < files.length && !truncated; i += 32) {
|
|
215
|
-
for (const found of await Promise.all(files.slice(i, i + 32).map(scan))) {
|
|
216
|
-
for (const match of found || []) {
|
|
217
|
-
if (results.length >= maxResults) { truncated = true; break; }
|
|
218
|
-
results.push(match);
|
|
219
|
-
}
|
|
220
|
-
if (truncated) break;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
return { results, scanned_files: scanned, truncated, source: listing.source, ...(errors ? { unreadable: errors } : {}) };
|
|
224
|
-
}
|
|
225
|
-
}
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { rename } from '../system.js';
|
|
6
|
+
|
|
7
|
+
const hash = text => createHash('sha256').update(text).digest('hex');
|
|
8
|
+
// Windows paths are case-insensitive: C:\Project and c:\project are the same folder.
|
|
9
|
+
const fold = process.platform === 'win32' ? value => value.toLowerCase() : value => value;
|
|
10
|
+
const inside = (root, target) => fold(target) === fold(root) || fold(target).startsWith(fold(root) + path.sep);
|
|
11
|
+
// Lines are shown without a trailing \r, so CRLF files read like LF files.
|
|
12
|
+
const stripCR = line => line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
13
|
+
|
|
14
|
+
// Keep read_file results below the MCP response limit (JSON escaping adds overhead).
|
|
15
|
+
const READ_BUDGET = 28000;
|
|
16
|
+
const SKIPPED = new Set(['.git', 'node_modules', '.venv', '__pycache__']);
|
|
17
|
+
const MAX_FILES = 10000;
|
|
18
|
+
const escapeRegExp = text => text.replace(/[.+^${}()|[\]\\*?]/g, '\\$&');
|
|
19
|
+
|
|
20
|
+
// Minimal glob: *, **, ?, {a,b}. Without a slash it matches the file name only.
|
|
21
|
+
export function globMatcher(glob) {
|
|
22
|
+
let source = '';
|
|
23
|
+
for (let i = 0; i < glob.length; i++) {
|
|
24
|
+
const char = glob[i];
|
|
25
|
+
if (char === '*' && glob[i + 1] === '*') {
|
|
26
|
+
i++;
|
|
27
|
+
if (glob[i + 1] === '/') { i++; source += '(?:.*/)?'; } else source += '.*';
|
|
28
|
+
} else if (char === '*') source += '[^/]*';
|
|
29
|
+
else if (char === '?') source += '[^/]';
|
|
30
|
+
else if (char === '{' && glob.indexOf('}', i) > i) {
|
|
31
|
+
const end = glob.indexOf('}', i);
|
|
32
|
+
source += '(?:' + glob.slice(i + 1, end).split(',').map(escapeRegExp).join('|') + ')';
|
|
33
|
+
i = end;
|
|
34
|
+
} else source += escapeRegExp(char);
|
|
35
|
+
}
|
|
36
|
+
const pattern = new RegExp('^' + source + '$');
|
|
37
|
+
const byName = !glob.includes('/');
|
|
38
|
+
return relative => pattern.test(byName ? path.posix.basename(relative) : relative);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function gitFiles(directory) {
|
|
42
|
+
return new Promise(resolve => {
|
|
43
|
+
execFile('git', ['-C', directory, 'ls-files', '-z', '--cached', '--others', '--exclude-standard'],
|
|
44
|
+
{ maxBuffer: 64 * 1024 * 1024, timeout: 15000 }, (error, stdout) => {
|
|
45
|
+
resolve(error ? null : stdout.split('\0').filter(Boolean));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class Workspace {
|
|
51
|
+
constructor(root) { this.root = root; }
|
|
52
|
+
|
|
53
|
+
async resolve(input = '.', writing = false) {
|
|
54
|
+
const candidate = path.resolve(this.root, input);
|
|
55
|
+
if (!inside(this.root, candidate)) throw new Error('文件路径必须位于当前项目内。');
|
|
56
|
+
let canonical;
|
|
57
|
+
try { canonical = await fs.realpath(candidate); }
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error.code !== 'ENOENT' || !writing) throw error;
|
|
60
|
+
let parent = path.dirname(candidate);
|
|
61
|
+
while (true) {
|
|
62
|
+
try { canonical = path.join(await fs.realpath(parent), path.relative(parent, candidate)); break; }
|
|
63
|
+
catch (e) {
|
|
64
|
+
if (e.code !== 'ENOENT' || parent === path.dirname(parent)) throw e;
|
|
65
|
+
parent = path.dirname(parent);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!inside(this.root, canonical)) throw new Error('符号链接指向项目外部,无法访问。');
|
|
70
|
+
return canonical;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async read(file, offset = 1, limit = 200) {
|
|
74
|
+
const target = await this.resolve(file);
|
|
75
|
+
const stat = await fs.stat(target);
|
|
76
|
+
if (!stat.isFile() || stat.size > 2 * 1024 * 1024) throw new Error('仅支持读取 2 MiB 以内的文本文件。');
|
|
77
|
+
const content = await fs.readFile(target, 'utf8');
|
|
78
|
+
if (content.includes('\0')) throw new Error('这是二进制文件,请通过终端或专用工具处理。');
|
|
79
|
+
const lines = content.split('\n');
|
|
80
|
+
if (lines.length > 1 && lines.at(-1) === '') lines.pop(); // A trailing newline is not an extra line.
|
|
81
|
+
const total = content === '' ? 0 : lines.length;
|
|
82
|
+
const output = [];
|
|
83
|
+
let used = 0, sizeLimited = false;
|
|
84
|
+
for (let i = offset - 1; i < Math.min(total, offset - 1 + limit); i++) {
|
|
85
|
+
const line = `${i + 1}: ${stripCR(lines[i])}`;
|
|
86
|
+
if (used + line.length > READ_BUDGET) {
|
|
87
|
+
sizeLimited = true;
|
|
88
|
+
if (!output.length) output.push(line.slice(0, READ_BUDGET) + ` …[本行过长已截断,共 ${lines[i].length} 字符,请用终端查看]`);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
output.push(line);
|
|
92
|
+
used += line.length + 1;
|
|
93
|
+
}
|
|
94
|
+
const next = offset + output.length;
|
|
95
|
+
return { path: path.relative(this.root, target), sha256: hash(content), total_lines: total,
|
|
96
|
+
content: output.join('\n'),
|
|
97
|
+
...(content.includes('\r\n') ? { line_endings: 'crlf' } : {}),
|
|
98
|
+
...(next <= total ? { next_offset: next } : {}),
|
|
99
|
+
...(sizeLimited ? { truncated_by_size: true } : {}) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async atomicWrite(target, content, mode = 0o644) {
|
|
103
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
104
|
+
const temp = path.join(path.dirname(target), '.claude-write-' + randomUUID());
|
|
105
|
+
try {
|
|
106
|
+
await fs.writeFile(temp, content, { flag: 'wx', mode });
|
|
107
|
+
await rename(temp, target);
|
|
108
|
+
} finally { await fs.rm(temp, { force: true }); }
|
|
109
|
+
return { path: path.relative(this.root, target), sha256: hash(content), bytes: Buffer.byteLength(content) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async write(file, content, expectedSha256) {
|
|
113
|
+
const target = await this.resolve(file, true);
|
|
114
|
+
let mode;
|
|
115
|
+
try {
|
|
116
|
+
if ((await fs.stat(target)).size > 2 * 1024 * 1024) throw new Error('覆盖文件大小上限为 2 MiB。');
|
|
117
|
+
const old = await fs.readFile(target, 'utf8');
|
|
118
|
+
mode = (await fs.stat(target)).mode & 0o777;
|
|
119
|
+
if (!expectedSha256 || hash(old) !== expectedSha256)
|
|
120
|
+
throw new Error('文件已存在或发生变化;先 read_file,再提供匹配的 expected_sha256。');
|
|
121
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
122
|
+
return this.atomicWrite(target, content, mode);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async edit(file, oldText, newText, replaceAll = false) {
|
|
126
|
+
const target = await this.resolve(file);
|
|
127
|
+
const stat = await fs.stat(target);
|
|
128
|
+
if (stat.size > 2 * 1024 * 1024) throw new Error('编辑文件大小上限为 2 MiB。');
|
|
129
|
+
const content = await fs.readFile(target, 'utf8');
|
|
130
|
+
// read_file hides \r, so text copied from it uses \n; match CRLF files too.
|
|
131
|
+
if (!content.includes(oldText) && content.includes('\r\n') && !oldText.includes('\r')) {
|
|
132
|
+
oldText = oldText.replaceAll('\n', '\r\n');
|
|
133
|
+
newText = newText.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n');
|
|
134
|
+
}
|
|
135
|
+
const matches = content.split(oldText).length - 1;
|
|
136
|
+
if (!matches) throw new Error('找不到 old_text,请重新读取文件。');
|
|
137
|
+
if (matches > 1 && !replaceAll) throw new Error('old_text 出现多次,请提供更精确的上下文或使用 replace_all。');
|
|
138
|
+
const next = replaceAll ? content.split(oldText).join(newText) : content.replace(oldText, () => newText);
|
|
139
|
+
return this.atomicWrite(target, next, stat.mode & 0o777);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async list(directory = '.') {
|
|
143
|
+
const target = await this.resolve(directory);
|
|
144
|
+
const entries = await fs.readdir(target, { withFileTypes: true });
|
|
145
|
+
return { path: path.relative(this.root, target) || '.', truncated: entries.length > 500,
|
|
146
|
+
entries: entries.sort((a, b) => a.name.localeCompare(b.name)).slice(0, 500).map(entry => ({
|
|
147
|
+
name: entry.name, type: entry.isSymbolicLink() ? 'symlink' : entry.isDirectory() ? 'directory' : 'file'
|
|
148
|
+
})) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Candidate files relative to `directory`. Inside a Git work tree this follows
|
|
152
|
+
// .gitignore; otherwise it walks the tree and skips common dependency folders.
|
|
153
|
+
async candidates(directory) {
|
|
154
|
+
const fromGit = await gitFiles(directory);
|
|
155
|
+
// An empty list usually means the directory itself is ignored; search it explicitly.
|
|
156
|
+
if (fromGit?.length) {
|
|
157
|
+
const files = fromGit.filter(file => !file.split('/').some(part => SKIPPED.has(part))).sort();
|
|
158
|
+
return { files: files.slice(0, MAX_FILES), limited: files.length > MAX_FILES, errors: 0, source: 'git' };
|
|
159
|
+
}
|
|
160
|
+
const files = [];
|
|
161
|
+
let errors = 0, limited = false;
|
|
162
|
+
const walk = async relative => {
|
|
163
|
+
let entries;
|
|
164
|
+
try { entries = await fs.readdir(path.join(directory, relative), { withFileTypes: true }); }
|
|
165
|
+
catch { errors++; return; }
|
|
166
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
if (files.length >= MAX_FILES) { limited = true; return; }
|
|
169
|
+
const child = relative ? relative + '/' + entry.name : entry.name;
|
|
170
|
+
if (entry.isDirectory()) { if (!SKIPPED.has(entry.name)) await walk(child); }
|
|
171
|
+
else if (entry.isFile()) files.push(child);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
await walk('');
|
|
175
|
+
return { files, limited, errors, source: 'walk' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async search(pattern, directory = '.', maxResults = 100, caseSensitive = true, { regex = false, glob } = {}) {
|
|
179
|
+
const root = await this.resolve(directory);
|
|
180
|
+
let test;
|
|
181
|
+
if (regex) {
|
|
182
|
+
let expression;
|
|
183
|
+
try { expression = new RegExp(pattern, caseSensitive ? '' : 'i'); }
|
|
184
|
+
catch (error) { throw new Error('正则表达式无效:' + error.message); }
|
|
185
|
+
test = line => expression.test(line);
|
|
186
|
+
} else {
|
|
187
|
+
const needle = caseSensitive ? pattern : pattern.toLowerCase();
|
|
188
|
+
test = line => (caseSensitive ? line : line.toLowerCase()).includes(needle);
|
|
189
|
+
}
|
|
190
|
+
const matchesGlob = glob ? globMatcher(glob) : () => true;
|
|
191
|
+
const rootStat = await fs.stat(root);
|
|
192
|
+
const listing = rootStat.isFile() ? { files: [''], limited: false, errors: 0, source: 'file' } : await this.candidates(root);
|
|
193
|
+
const files = listing.files.filter(file => file === '' || matchesGlob(file));
|
|
194
|
+
const results = [];
|
|
195
|
+
let scanned = 0, errors = listing.errors, truncated = listing.limited;
|
|
196
|
+
const scan = async relative => {
|
|
197
|
+
const target = relative ? path.join(root, relative) : root;
|
|
198
|
+
try {
|
|
199
|
+
const stat = await fs.lstat(target);
|
|
200
|
+
if (!stat.isFile() || stat.size > 1024 * 1024) return null;
|
|
201
|
+
const text = await fs.readFile(target, 'utf8');
|
|
202
|
+
scanned++;
|
|
203
|
+
if (text.includes('\0')) return null;
|
|
204
|
+
const found = [];
|
|
205
|
+
for (const [i, line] of text.split('\n').entries())
|
|
206
|
+
if (test(stripCR(line))) found.push({ path: path.relative(this.root, target), line: i + 1, text: stripCR(line).slice(0, 1000) });
|
|
207
|
+
return found;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (error.code !== 'ENOENT') errors++; // Deleted tracked files are not errors.
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
// Read in parallel batches but keep results in a stable path order.
|
|
214
|
+
for (let i = 0; i < files.length && !truncated; i += 32) {
|
|
215
|
+
for (const found of await Promise.all(files.slice(i, i + 32).map(scan))) {
|
|
216
|
+
for (const match of found || []) {
|
|
217
|
+
if (results.length >= maxResults) { truncated = true; break; }
|
|
218
|
+
results.push(match);
|
|
219
|
+
}
|
|
220
|
+
if (truncated) break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { results, scanned_files: scanned, truncated, source: listing.source, ...(errors ? { unreadable: errors } : {}) };
|
|
224
|
+
}
|
|
225
|
+
}
|