codex-workspace-codegraph-mcp 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -0
- package/LICENSE +17 -0
- package/README.en.md +42 -0
- package/README.md +439 -0
- package/SECURITY.md +38 -0
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/VALIDATION.md +51 -0
- package/bin/codex-workspace-mcp.js +16 -0
- package/bin/start-tunnel.js +7 -0
- package/docs/ARCHITECTURE.md +70 -0
- package/package.json +52 -0
- package/scripts/start.ps1 +2 -0
- package/scripts/start.sh +3 -0
- package/src/codegraph.js +236 -0
- package/src/config.js +35 -0
- package/src/exec.js +119 -0
- package/src/feedback.js +272 -0
- package/src/main.js +49 -0
- package/src/mcp.js +102 -0
- package/src/patch.js +240 -0
- package/src/plan.js +29 -0
- package/src/process.js +65 -0
- package/src/prompt.js +30 -0
- package/src/tools.js +296 -0
- package/src/transports.js +177 -0
- package/src/tunnel.js +232 -0
- package/src/utils.js +157 -0
- package/src/workspace.js +297 -0
package/src/tunnel.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import fsSync from 'node:fs';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import https from 'node:https';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import zlib from 'node:zlib';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { findExecutable, parseArgs, randomToken, sleep } from './utils.js';
|
|
11
|
+
|
|
12
|
+
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const PACKAGE_ROOT = path.resolve(MODULE_DIR, '..');
|
|
14
|
+
|
|
15
|
+
function cloudflaredAsset() {
|
|
16
|
+
const { platform, arch } = process;
|
|
17
|
+
if (platform === 'linux') {
|
|
18
|
+
if (arch === 'x64') return { name: 'cloudflared-linux-amd64', archive: false };
|
|
19
|
+
if (arch === 'arm64') return { name: 'cloudflared-linux-arm64', archive: false };
|
|
20
|
+
if (arch === 'arm') return { name: 'cloudflared-linux-arm', archive: false };
|
|
21
|
+
if (arch === 'ia32') return { name: 'cloudflared-linux-386', archive: false };
|
|
22
|
+
}
|
|
23
|
+
if (platform === 'win32') {
|
|
24
|
+
if (arch === 'x64') return { name: 'cloudflared-windows-amd64.exe', archive: false };
|
|
25
|
+
if (arch === 'ia32') return { name: 'cloudflared-windows-386.exe', archive: false };
|
|
26
|
+
if (arch === 'arm64') return { name: 'cloudflared-windows-arm64.exe', archive: false };
|
|
27
|
+
}
|
|
28
|
+
if (platform === 'darwin') {
|
|
29
|
+
if (arch === 'x64') return { name: 'cloudflared-darwin-amd64.tgz', archive: true };
|
|
30
|
+
if (arch === 'arm64') return { name: 'cloudflared-darwin-arm64.tgz', archive: true };
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`暂不支持自动下载 cloudflared: ${platform}/${arch}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function download(url, destination, redirects = 0) {
|
|
36
|
+
if (redirects > 8) return Promise.reject(new Error('cloudflared 下载重定向过多'));
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const request = https.get(url, { headers: { 'user-agent': 'codex-workspace-codegraph-mcp/0.2.0' } }, (response) => {
|
|
39
|
+
if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) {
|
|
40
|
+
response.resume();
|
|
41
|
+
download(new URL(response.headers.location, url).href, destination, redirects + 1).then(resolve, reject);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (response.statusCode !== 200) {
|
|
45
|
+
response.resume();
|
|
46
|
+
reject(new Error(`下载 cloudflared 失败: HTTP ${response.statusCode}`));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const file = fsSync.createWriteStream(destination, { mode: 0o700 });
|
|
50
|
+
response.pipe(file);
|
|
51
|
+
file.on('finish', () => file.close(resolve));
|
|
52
|
+
file.on('error', reject);
|
|
53
|
+
});
|
|
54
|
+
request.on('error', reject);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseTarString(buffer, start, length) {
|
|
59
|
+
return buffer.subarray(start, start + length).toString('utf8').replace(/\0.*$/, '').trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function extractFirstTarFile(tgzPath, outputPath) {
|
|
63
|
+
const compressed = await fs.readFile(tgzPath);
|
|
64
|
+
const tar = zlib.gunzipSync(compressed);
|
|
65
|
+
let offset = 0;
|
|
66
|
+
while (offset + 512 <= tar.length) {
|
|
67
|
+
const header = tar.subarray(offset, offset + 512);
|
|
68
|
+
if (header.every((byte) => byte === 0)) break;
|
|
69
|
+
const name = parseTarString(header, 0, 100);
|
|
70
|
+
const sizeText = parseTarString(header, 124, 12);
|
|
71
|
+
const type = String.fromCharCode(header[156] || 48);
|
|
72
|
+
const size = Number.parseInt(sizeText || '0', 8);
|
|
73
|
+
offset += 512;
|
|
74
|
+
if ((type === '0' || type === '\0') && name && path.basename(name).startsWith('cloudflared')) {
|
|
75
|
+
await fs.writeFile(outputPath, tar.subarray(offset, offset + size), { mode: 0o700 });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
offset += Math.ceil(size / 512) * 512;
|
|
79
|
+
}
|
|
80
|
+
throw new Error('未在 tgz 中找到 cloudflared 可执行文件');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function ensureCloudflared(args) {
|
|
84
|
+
const explicit = String(args.cloudflared || process.env.CWMCP_CLOUDFLARED || '');
|
|
85
|
+
if (explicit) {
|
|
86
|
+
const found = await findExecutable(explicit);
|
|
87
|
+
if (!found) throw new Error(`找不到指定的 cloudflared: ${explicit}`);
|
|
88
|
+
return found;
|
|
89
|
+
}
|
|
90
|
+
const existing = await findExecutable('cloudflared');
|
|
91
|
+
if (existing) return existing;
|
|
92
|
+
if (args['no-download']) throw new Error('未安装 cloudflared,且指定了 --no-download');
|
|
93
|
+
|
|
94
|
+
const asset = cloudflaredAsset();
|
|
95
|
+
const cacheDirectory = path.join(os.homedir(), '.codex-workspace-mcp', 'bin');
|
|
96
|
+
await fs.mkdir(cacheDirectory, { recursive: true });
|
|
97
|
+
const binary = path.join(cacheDirectory, process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared');
|
|
98
|
+
try {
|
|
99
|
+
await fs.access(binary);
|
|
100
|
+
return binary;
|
|
101
|
+
} catch { /* download */ }
|
|
102
|
+
|
|
103
|
+
const temporary = path.join(os.tmpdir(), `${asset.name}-${process.pid}-${Date.now()}`);
|
|
104
|
+
const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset.name}`;
|
|
105
|
+
console.error(`[tunnel] 未检测到 cloudflared,正在下载官方发行文件:${asset.name}`);
|
|
106
|
+
await download(url, temporary);
|
|
107
|
+
if (asset.archive) await extractFirstTarFile(temporary, binary);
|
|
108
|
+
else await fs.rename(temporary, binary);
|
|
109
|
+
await fs.chmod(binary, 0o700);
|
|
110
|
+
await fs.rm(temporary, { force: true }).catch(() => {});
|
|
111
|
+
return binary;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function waitForHealth(port, timeoutMs = 30_000) {
|
|
115
|
+
const deadline = Date.now() + timeoutMs;
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
const attempt = () => {
|
|
118
|
+
const request = http.get(`http://127.0.0.1:${port}/health`, (response) => {
|
|
119
|
+
response.resume();
|
|
120
|
+
if (response.statusCode === 200) resolve();
|
|
121
|
+
else retry();
|
|
122
|
+
});
|
|
123
|
+
request.on('error', retry);
|
|
124
|
+
request.setTimeout(1000, () => request.destroy());
|
|
125
|
+
};
|
|
126
|
+
const retry = () => {
|
|
127
|
+
if (Date.now() >= deadline) reject(new Error(`MCP HTTP 服务未在端口 ${port} 就绪`));
|
|
128
|
+
else setTimeout(attempt, 250);
|
|
129
|
+
};
|
|
130
|
+
attempt();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function spawnTunnel(binary, port, label, emptyConfigPath) {
|
|
135
|
+
const args = ['tunnel', '--config', emptyConfigPath, '--no-autoupdate', '--protocol', 'http2', '--url', `http://127.0.0.1:${port}`];
|
|
136
|
+
const child = spawn(binary, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
137
|
+
let settled = false;
|
|
138
|
+
let resolveUrl;
|
|
139
|
+
let rejectUrl;
|
|
140
|
+
const urlPromise = new Promise((resolve, reject) => { resolveUrl = resolve; rejectUrl = reject; });
|
|
141
|
+
const parse = (chunk) => {
|
|
142
|
+
const text = chunk.toString('utf8');
|
|
143
|
+
for (const line of text.split(/\r?\n/).filter(Boolean)) {
|
|
144
|
+
const match = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i.exec(line);
|
|
145
|
+
if (match && !settled) {
|
|
146
|
+
settled = true;
|
|
147
|
+
resolveUrl(match[0]);
|
|
148
|
+
}
|
|
149
|
+
if (process.env.CWMCP_TUNNEL_VERBOSE === '1') console.error(`[cloudflared:${label}] ${line}`);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
child.stdout.on('data', parse);
|
|
153
|
+
child.stderr.on('data', parse);
|
|
154
|
+
child.on('error', (error) => { if (!settled) { settled = true; rejectUrl(error); } });
|
|
155
|
+
child.on('close', (code) => { if (!settled) { settled = true; rejectUrl(new Error(`${label} tunnel 提前退出,code=${code}`)); } });
|
|
156
|
+
return { child, urlPromise };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function killChild(child) {
|
|
160
|
+
if (!child || child.killed) return;
|
|
161
|
+
child.kill('SIGTERM');
|
|
162
|
+
setTimeout(() => child.kill('SIGKILL'), 2000).unref();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function runTunnelCli(argv = []) {
|
|
166
|
+
const args = parseArgs(argv);
|
|
167
|
+
const workspace = path.resolve(String(args.workspace || process.env.CWMCP_WORKSPACE || process.cwd()));
|
|
168
|
+
const mcpPort = Number(args['mcp-port'] || process.env.CWMCP_MCP_PORT || 8765);
|
|
169
|
+
const webPort = Number(args['web-port'] || process.env.CWMCP_WEB_PORT || 8766);
|
|
170
|
+
const token = String(args.token || process.env.CWMCP_ACCESS_TOKEN || randomToken());
|
|
171
|
+
const cloudflared = await ensureCloudflared(args);
|
|
172
|
+
const emptyConfigPath = path.join(os.tmpdir(), `cwmcp-cloudflared-${process.pid}.yml`);
|
|
173
|
+
await fs.writeFile(emptyConfigPath, '# intentionally empty: isolates Quick Tunnel from ~/.cloudflared/config.yaml\n');
|
|
174
|
+
|
|
175
|
+
const serverArgs = [path.join(PACKAGE_ROOT, 'bin', 'codex-workspace-mcp.js'), '--transport', 'http', '--workspace', workspace, '--host', '127.0.0.1', '--web-host', '127.0.0.1', '--mcp-port', String(mcpPort), '--web-port', String(webPort), '--token', token, '--no-browser'];
|
|
176
|
+
if (args['disable-codegraph']) serverArgs.push('--disable-codegraph');
|
|
177
|
+
if (args.sandbox) serverArgs.push('--sandbox', String(args.sandbox));
|
|
178
|
+
if (args['approval-policy']) serverArgs.push('--approval-policy', String(args['approval-policy']));
|
|
179
|
+
|
|
180
|
+
const server = spawn(process.execPath, serverArgs, {
|
|
181
|
+
cwd: workspace,
|
|
182
|
+
env: { ...process.env, CWMCP_ACCESS_TOKEN: token },
|
|
183
|
+
windowsHide: true,
|
|
184
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
185
|
+
});
|
|
186
|
+
server.stderr.on('data', (chunk) => {
|
|
187
|
+
if (process.env.CWMCP_TUNNEL_VERBOSE === '1') process.stderr.write(`[server] ${chunk}`);
|
|
188
|
+
});
|
|
189
|
+
server.on('error', (error) => console.error(`[server] ${error.message}`));
|
|
190
|
+
|
|
191
|
+
const children = [server];
|
|
192
|
+
const cleanup = async () => {
|
|
193
|
+
for (const child of children) killChild(child);
|
|
194
|
+
await fs.rm(emptyConfigPath, { force: true }).catch(() => {});
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
await waitForHealth(mcpPort);
|
|
199
|
+
const mcpTunnel = spawnTunnel(cloudflared, mcpPort, 'mcp', emptyConfigPath);
|
|
200
|
+
const webTunnel = spawnTunnel(cloudflared, webPort, 'webui', emptyConfigPath);
|
|
201
|
+
children.push(mcpTunnel.child, webTunnel.child);
|
|
202
|
+
let timeoutHandle;
|
|
203
|
+
const timeout = new Promise((_, reject) => {
|
|
204
|
+
timeoutHandle = setTimeout(() => reject(new Error('等待 TryCloudflare URL 超时')), 60_000);
|
|
205
|
+
timeoutHandle.unref();
|
|
206
|
+
});
|
|
207
|
+
const [mcpBase, webBase] = await Promise.race([Promise.all([mcpTunnel.urlPromise, webTunnel.urlPromise]), timeout]);
|
|
208
|
+
clearTimeout(timeoutHandle);
|
|
209
|
+
|
|
210
|
+
console.log('');
|
|
211
|
+
console.log('Codex 风格本地代码 MCP 已启动(不需要 Codex/OpenAI 登录、订阅或 API Key)');
|
|
212
|
+
console.log(`工作区: ${workspace}`);
|
|
213
|
+
console.log(`MCP 本地 URL: http://127.0.0.1:${mcpPort}/mcp/${token}`);
|
|
214
|
+
console.log(`WebUI 本地 URL: http://127.0.0.1:${webPort}/?token=${encodeURIComponent(token)}`);
|
|
215
|
+
console.log('');
|
|
216
|
+
console.log(`MCP 公网 URL: ${mcpBase}/mcp/${token}`);
|
|
217
|
+
console.log(`WebUI 公网 URL: ${webBase}/?token=${encodeURIComponent(token)}`);
|
|
218
|
+
console.log('');
|
|
219
|
+
console.log('以上 URL 包含访问令牌,请按密码处理。按 Ctrl+C 停止 MCP、WebUI 和两个 tunnel。');
|
|
220
|
+
console.log('TryCloudflare Quick Tunnel 仅适合测试;不支持 SSE,因此本服务使用 JSON-RPC POST。');
|
|
221
|
+
|
|
222
|
+
await new Promise((resolve) => {
|
|
223
|
+
const stop = () => resolve();
|
|
224
|
+
process.once('SIGINT', stop);
|
|
225
|
+
process.once('SIGTERM', stop);
|
|
226
|
+
server.once('close', stop);
|
|
227
|
+
});
|
|
228
|
+
} finally {
|
|
229
|
+
await cleanup();
|
|
230
|
+
await sleep(100);
|
|
231
|
+
}
|
|
232
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const FEEDBACK_TIMEOUT_SECONDS = 48 * 60 * 60;
|
|
6
|
+
export const MAX_JSON_BODY_BYTES = 4 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
export function randomToken(bytes = 24) {
|
|
9
|
+
return crypto.randomBytes(bytes).toString('base64url');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function sleep(ms) {
|
|
13
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function clampInteger(value, minimum, maximum, fallback) {
|
|
17
|
+
const parsed = Number.parseInt(String(value ?? ''), 10);
|
|
18
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
19
|
+
return Math.max(minimum, Math.min(maximum, parsed));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function asBoolean(value, fallback = false) {
|
|
23
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
24
|
+
return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseJsonEnv(name, fallback) {
|
|
28
|
+
const raw = process.env[name];
|
|
29
|
+
if (!raw) return fallback;
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(raw);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new Error(`${name} 必须是有效 JSON: ${error.message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function jsonText(value) {
|
|
38
|
+
return JSON.stringify(value, null, 2);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function toolText(value) {
|
|
42
|
+
return {
|
|
43
|
+
content: [{ type: 'text', text: typeof value === 'string' ? value : jsonText(value) }],
|
|
44
|
+
structuredContent: typeof value === 'object' && value !== null ? value : undefined,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function toolError(message, details = undefined) {
|
|
49
|
+
const payload = details === undefined ? { error: message } : { error: message, details };
|
|
50
|
+
return {
|
|
51
|
+
content: [{ type: 'text', text: jsonText(payload) }],
|
|
52
|
+
structuredContent: payload,
|
|
53
|
+
isError: true,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function readJsonBody(request, maxBytes = MAX_JSON_BODY_BYTES) {
|
|
58
|
+
const chunks = [];
|
|
59
|
+
let total = 0;
|
|
60
|
+
for await (const chunk of request) {
|
|
61
|
+
total += chunk.length;
|
|
62
|
+
if (total > maxBytes) throw Object.assign(new Error('请求体过大'), { statusCode: 413 });
|
|
63
|
+
chunks.push(chunk);
|
|
64
|
+
}
|
|
65
|
+
if (chunks.length === 0) return null;
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
68
|
+
} catch {
|
|
69
|
+
throw Object.assign(new Error('请求体不是有效 JSON'), { statusCode: 400 });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function writeJson(response, statusCode, data, headers = {}) {
|
|
74
|
+
const body = Buffer.from(JSON.stringify(data));
|
|
75
|
+
response.writeHead(statusCode, {
|
|
76
|
+
'content-type': 'application/json; charset=utf-8',
|
|
77
|
+
'content-length': body.length,
|
|
78
|
+
'cache-control': 'no-store',
|
|
79
|
+
...headers,
|
|
80
|
+
});
|
|
81
|
+
response.end(body);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function writeText(response, statusCode, text, contentType = 'text/plain; charset=utf-8', headers = {}) {
|
|
85
|
+
const body = Buffer.from(text);
|
|
86
|
+
response.writeHead(statusCode, {
|
|
87
|
+
'content-type': contentType,
|
|
88
|
+
'content-length': body.length,
|
|
89
|
+
'cache-control': 'no-store',
|
|
90
|
+
...headers,
|
|
91
|
+
});
|
|
92
|
+
response.end(body);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function fileExists(filePath) {
|
|
96
|
+
try {
|
|
97
|
+
await fs.access(filePath);
|
|
98
|
+
return true;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function findExecutable(command) {
|
|
105
|
+
if (!command) return null;
|
|
106
|
+
if (command.includes(path.sep) || (path.sep === '\\' && command.includes('/'))) {
|
|
107
|
+
return (await fileExists(command)) ? command : null;
|
|
108
|
+
}
|
|
109
|
+
const pathValue = process.env.PATH || '';
|
|
110
|
+
const extensions = process.platform === 'win32'
|
|
111
|
+
? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';')
|
|
112
|
+
: [''];
|
|
113
|
+
for (const directory of pathValue.split(path.delimiter)) {
|
|
114
|
+
if (!directory) continue;
|
|
115
|
+
for (const extension of extensions) {
|
|
116
|
+
const candidate = path.join(directory, command + extension.toLowerCase());
|
|
117
|
+
if (await fileExists(candidate)) return candidate;
|
|
118
|
+
const upperCandidate = path.join(directory, command + extension.toUpperCase());
|
|
119
|
+
if (await fileExists(upperCandidate)) return upperCandidate;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function parseArgs(argv) {
|
|
126
|
+
const result = { _: [] };
|
|
127
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
128
|
+
const arg = argv[index];
|
|
129
|
+
if (!arg.startsWith('--')) {
|
|
130
|
+
result._.push(arg);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const [rawKey, inlineValue] = arg.slice(2).split('=', 2);
|
|
134
|
+
if (inlineValue !== undefined) {
|
|
135
|
+
result[rawKey] = inlineValue;
|
|
136
|
+
} else if (argv[index + 1] && !argv[index + 1].startsWith('--')) {
|
|
137
|
+
result[rawKey] = argv[index + 1];
|
|
138
|
+
index += 1;
|
|
139
|
+
} else {
|
|
140
|
+
result[rawKey] = true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function normalizeSlashes(value) {
|
|
147
|
+
return value.split(path.sep).join('/');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function redactEnvironment(environment) {
|
|
151
|
+
const redacted = {};
|
|
152
|
+
const secretPattern = /(token|secret|password|passwd|key|credential|cookie|authorization)/i;
|
|
153
|
+
for (const [key, value] of Object.entries(environment)) {
|
|
154
|
+
redacted[key] = secretPattern.test(key) ? '<redacted>' : value;
|
|
155
|
+
}
|
|
156
|
+
return redacted;
|
|
157
|
+
}
|
package/src/workspace.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { fileExists, normalizeSlashes } from './utils.js';
|
|
5
|
+
import { runProcess } from './process.js';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_IGNORES = new Set(['.git', 'node_modules', '.next', 'dist', 'build', 'target', '.venv', 'venv', '__pycache__', '.codegraph']);
|
|
8
|
+
|
|
9
|
+
function isInside(root, candidate) {
|
|
10
|
+
const relative = path.relative(root, candidate);
|
|
11
|
+
return relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function nearestExistingAncestor(candidate) {
|
|
15
|
+
let current = candidate;
|
|
16
|
+
while (true) {
|
|
17
|
+
try {
|
|
18
|
+
await fs.lstat(current);
|
|
19
|
+
return current;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error.code !== 'ENOENT') throw error;
|
|
22
|
+
const parent = path.dirname(current);
|
|
23
|
+
if (parent === current) throw error;
|
|
24
|
+
current = parent;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class Workspace {
|
|
30
|
+
constructor(root, options = {}) {
|
|
31
|
+
this.requestedRoot = path.resolve(root);
|
|
32
|
+
this.root = this.requestedRoot;
|
|
33
|
+
this.maxReadBytes = options.maxReadBytes ?? 2_097_152;
|
|
34
|
+
this.maxOutputBytes = options.maxOutputBytes ?? 1_048_576;
|
|
35
|
+
this.commandTimeoutMs = options.commandTimeoutMs ?? 120_000;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async initialize() {
|
|
39
|
+
this.root = await fs.realpath(this.requestedRoot);
|
|
40
|
+
const stat = await fs.stat(this.root);
|
|
41
|
+
if (!stat.isDirectory()) throw new Error(`工作区不是目录: ${this.root}`);
|
|
42
|
+
return this;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async resolve(userPath = '.', { allowMissing = false } = {}) {
|
|
46
|
+
if (typeof userPath !== 'string' || userPath.includes('\0')) throw new Error('路径无效');
|
|
47
|
+
const lexical = path.resolve(this.root, userPath || '.');
|
|
48
|
+
if (!isInside(this.root, lexical)) throw new Error(`路径越过工作区边界: ${userPath}`);
|
|
49
|
+
|
|
50
|
+
if (!allowMissing) {
|
|
51
|
+
const real = await fs.realpath(lexical);
|
|
52
|
+
if (!isInside(this.root, real)) throw new Error(`符号链接越过工作区边界: ${userPath}`);
|
|
53
|
+
return real;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ancestor = await nearestExistingAncestor(lexical);
|
|
57
|
+
const realAncestor = await fs.realpath(ancestor);
|
|
58
|
+
if (!isInside(this.root, realAncestor)) throw new Error(`路径父目录越过工作区边界: ${userPath}`);
|
|
59
|
+
return lexical;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
relative(absolutePath) {
|
|
63
|
+
return normalizeSlashes(path.relative(this.root, absolutePath) || '.');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async metadata(userPath) {
|
|
67
|
+
const absolute = await this.resolve(userPath);
|
|
68
|
+
const stat = await fs.lstat(absolute);
|
|
69
|
+
return {
|
|
70
|
+
path: this.relative(absolute),
|
|
71
|
+
type: stat.isFile() ? 'file' : stat.isDirectory() ? 'directory' : stat.isSymbolicLink() ? 'symlink' : 'other',
|
|
72
|
+
size: stat.size,
|
|
73
|
+
modified_at: stat.mtime.toISOString(),
|
|
74
|
+
mode: `0${(stat.mode & 0o777).toString(8)}`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async readFile(userPath, options = {}) {
|
|
79
|
+
const absolute = await this.resolve(userPath);
|
|
80
|
+
const stat = await fs.stat(absolute);
|
|
81
|
+
if (!stat.isFile()) throw new Error(`不是文件: ${userPath}`);
|
|
82
|
+
const maxBytes = Math.min(options.maxBytes ?? this.maxReadBytes, this.maxReadBytes);
|
|
83
|
+
const buffer = await fs.readFile(absolute);
|
|
84
|
+
if (buffer.includes(0)) throw new Error(`检测到二进制文件: ${userPath}`);
|
|
85
|
+
const truncatedByBytes = buffer.length > maxBytes;
|
|
86
|
+
const text = buffer.subarray(0, maxBytes).toString('utf8');
|
|
87
|
+
const lines = text.split(/\r?\n/);
|
|
88
|
+
const startLine = Math.max(1, Number(options.startLine || 1));
|
|
89
|
+
const endLine = Math.min(lines.length, Number(options.endLine || lines.length));
|
|
90
|
+
const selected = lines.slice(startLine - 1, endLine);
|
|
91
|
+
const numbered = options.lineNumbers === false
|
|
92
|
+
? selected.join('\n')
|
|
93
|
+
: selected.map((line, index) => `${String(startLine + index).padStart(6)}\t${line}`).join('\n');
|
|
94
|
+
return {
|
|
95
|
+
path: this.relative(absolute),
|
|
96
|
+
start_line: startLine,
|
|
97
|
+
end_line: startLine + Math.max(0, selected.length - 1),
|
|
98
|
+
total_lines_in_loaded_prefix: lines.length,
|
|
99
|
+
file_size: stat.size,
|
|
100
|
+
truncated: truncatedByBytes || endLine < lines.length,
|
|
101
|
+
content: numbered,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async listDirectory(userPath = '.', options = {}) {
|
|
106
|
+
const absolute = await this.resolve(userPath);
|
|
107
|
+
const entries = await fs.readdir(absolute, { withFileTypes: true });
|
|
108
|
+
const includeHidden = options.includeHidden === true;
|
|
109
|
+
const result = [];
|
|
110
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
111
|
+
if (!includeHidden && entry.name.startsWith('.')) continue;
|
|
112
|
+
const itemPath = path.join(absolute, entry.name);
|
|
113
|
+
let size = null;
|
|
114
|
+
if (entry.isFile()) {
|
|
115
|
+
try { size = (await fs.stat(itemPath)).size; } catch { /* ignored */ }
|
|
116
|
+
}
|
|
117
|
+
result.push({
|
|
118
|
+
name: entry.name,
|
|
119
|
+
path: this.relative(itemPath),
|
|
120
|
+
type: entry.isFile() ? 'file' : entry.isDirectory() ? 'directory' : entry.isSymbolicLink() ? 'symlink' : 'other',
|
|
121
|
+
size,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async findFiles(options = {}) {
|
|
128
|
+
const base = await this.resolve(options.path || '.');
|
|
129
|
+
const limit = Math.max(1, Math.min(5000, Number(options.limit || 500)));
|
|
130
|
+
const pattern = String(options.pattern || '').toLowerCase();
|
|
131
|
+
const extensions = Array.isArray(options.extensions)
|
|
132
|
+
? new Set(options.extensions.map((value) => String(value).replace(/^\./, '').toLowerCase()))
|
|
133
|
+
: null;
|
|
134
|
+
const results = [];
|
|
135
|
+
|
|
136
|
+
const visit = async (directory) => {
|
|
137
|
+
if (results.length >= limit) return;
|
|
138
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
if (results.length >= limit) break;
|
|
141
|
+
if (entry.isDirectory() && DEFAULT_IGNORES.has(entry.name)) continue;
|
|
142
|
+
const absolute = path.join(directory, entry.name);
|
|
143
|
+
if (entry.isDirectory()) {
|
|
144
|
+
await visit(absolute);
|
|
145
|
+
} else if (entry.isFile()) {
|
|
146
|
+
const relative = this.relative(absolute);
|
|
147
|
+
if (pattern && !relative.toLowerCase().includes(pattern)) continue;
|
|
148
|
+
if (extensions) {
|
|
149
|
+
const extension = path.extname(entry.name).slice(1).toLowerCase();
|
|
150
|
+
if (!extensions.has(extension)) continue;
|
|
151
|
+
}
|
|
152
|
+
results.push(relative);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
await visit(base);
|
|
157
|
+
return { files: results, truncated: results.length >= limit };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async searchText(options = {}) {
|
|
161
|
+
const query = String(options.query || '');
|
|
162
|
+
if (!query) throw new Error('query 不能为空');
|
|
163
|
+
const base = await this.resolve(options.path || '.');
|
|
164
|
+
const limit = Math.max(1, Math.min(2000, Number(options.limit || 200)));
|
|
165
|
+
const fixed = options.regex !== true;
|
|
166
|
+
const caseSensitive = options.caseSensitive === true;
|
|
167
|
+
const glob = Array.isArray(options.glob) ? options.glob : options.glob ? [String(options.glob)] : [];
|
|
168
|
+
|
|
169
|
+
const rgArgs = ['--line-number', '--column', '--no-heading', '--color', 'never', '--max-count', String(limit)];
|
|
170
|
+
if (fixed) rgArgs.push('--fixed-strings');
|
|
171
|
+
if (!caseSensitive) rgArgs.push('--ignore-case');
|
|
172
|
+
for (const value of glob) rgArgs.push('--glob', value);
|
|
173
|
+
rgArgs.push('--', query, base);
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const result = await runProcess('rg', rgArgs, {
|
|
177
|
+
cwd: this.root,
|
|
178
|
+
timeoutMs: this.commandTimeoutMs,
|
|
179
|
+
maxOutputBytes: this.maxOutputBytes,
|
|
180
|
+
});
|
|
181
|
+
if (result.code === 0 || result.code === 1) {
|
|
182
|
+
const matches = result.stdout.split(/\r?\n/).filter(Boolean).slice(0, limit).map((line) => {
|
|
183
|
+
const match = /^(.*?):(\d+):(\d+):(.*)$/.exec(line);
|
|
184
|
+
if (!match) return { raw: line };
|
|
185
|
+
return { path: this.relative(match[1]), line: Number(match[2]), column: Number(match[3]), text: match[4] };
|
|
186
|
+
});
|
|
187
|
+
return { engine: 'ripgrep', matches, truncated: matches.length >= limit };
|
|
188
|
+
}
|
|
189
|
+
} catch { /* fallback below */ }
|
|
190
|
+
|
|
191
|
+
return this.#searchTextFallback(base, query, { limit, fixed, caseSensitive });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async #searchTextFallback(base, query, options) {
|
|
195
|
+
const files = (await this.findFiles({ path: this.relative(base), limit: 5000 })).files;
|
|
196
|
+
const matches = [];
|
|
197
|
+
const flags = options.caseSensitive ? 'g' : 'gi';
|
|
198
|
+
const regex = options.fixed ? null : new RegExp(query, flags);
|
|
199
|
+
const needle = options.caseSensitive ? query : query.toLowerCase();
|
|
200
|
+
for (const relative of files) {
|
|
201
|
+
if (matches.length >= options.limit) break;
|
|
202
|
+
const absolute = await this.resolve(relative);
|
|
203
|
+
const stat = await fs.stat(absolute);
|
|
204
|
+
if (stat.size > this.maxReadBytes) continue;
|
|
205
|
+
const buffer = await fs.readFile(absolute);
|
|
206
|
+
if (buffer.includes(0)) continue;
|
|
207
|
+
const lines = buffer.toString('utf8').split(/\r?\n/);
|
|
208
|
+
for (let index = 0; index < lines.length && matches.length < options.limit; index += 1) {
|
|
209
|
+
const haystack = options.caseSensitive ? lines[index] : lines[index].toLowerCase();
|
|
210
|
+
const column = options.fixed ? haystack.indexOf(needle) : lines[index].search(regex);
|
|
211
|
+
if (column >= 0) matches.push({ path: relative, line: index + 1, column: column + 1, text: lines[index] });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { engine: 'node-fallback', matches, truncated: matches.length >= options.limit };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async discoverInstructions(cwd = '.') {
|
|
218
|
+
const current = await this.resolve(cwd);
|
|
219
|
+
const currentStat = await fs.stat(current);
|
|
220
|
+
const directory = currentStat.isDirectory() ? current : path.dirname(current);
|
|
221
|
+
const relative = path.relative(this.root, directory);
|
|
222
|
+
const segments = relative ? relative.split(path.sep) : [];
|
|
223
|
+
const directories = [this.root];
|
|
224
|
+
let cursor = this.root;
|
|
225
|
+
for (const segment of segments) {
|
|
226
|
+
cursor = path.join(cursor, segment);
|
|
227
|
+
directories.push(cursor);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const documents = [];
|
|
231
|
+
let totalBytes = 0;
|
|
232
|
+
const maxBytes = 64 * 1024;
|
|
233
|
+
for (const item of directories) {
|
|
234
|
+
const override = path.join(item, 'AGENTS.override.md');
|
|
235
|
+
const normal = path.join(item, 'AGENTS.md');
|
|
236
|
+
const selected = (await fileExists(override)) ? override : (await fileExists(normal)) ? normal : null;
|
|
237
|
+
if (!selected) continue;
|
|
238
|
+
const buffer = await fs.readFile(selected);
|
|
239
|
+
const remaining = maxBytes - totalBytes;
|
|
240
|
+
if (remaining <= 0) break;
|
|
241
|
+
const content = buffer.subarray(0, remaining).toString('utf8');
|
|
242
|
+
totalBytes += Buffer.byteLength(content);
|
|
243
|
+
documents.push({ path: this.relative(selected), content, truncated: buffer.length > remaining });
|
|
244
|
+
}
|
|
245
|
+
return { workspace: this.root, cwd: this.relative(directory), documents };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async writeFile(userPath, content, options = {}) {
|
|
249
|
+
const absolute = await this.resolve(userPath, { allowMissing: true });
|
|
250
|
+
const exists = await fileExists(absolute);
|
|
251
|
+
if (options.createOnly && exists) throw new Error(`文件已存在: ${userPath}`);
|
|
252
|
+
if (options.overwrite === false && exists) throw new Error(`拒绝覆盖已有文件: ${userPath}`);
|
|
253
|
+
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
|
254
|
+
const temporary = `${absolute}.cwmcp-${process.pid}-${Date.now()}.tmp`;
|
|
255
|
+
await fs.writeFile(temporary, String(content), 'utf8');
|
|
256
|
+
await fs.rename(temporary, absolute);
|
|
257
|
+
return { path: this.relative(absolute), bytes: Buffer.byteLength(String(content)), created: !exists };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async editFile(userPath, oldText, newText, expectedOccurrences = 1) {
|
|
261
|
+
const absolute = await this.resolve(userPath);
|
|
262
|
+
const original = await fs.readFile(absolute, 'utf8');
|
|
263
|
+
if (!oldText) throw new Error('old_text 不能为空');
|
|
264
|
+
const count = original.split(oldText).length - 1;
|
|
265
|
+
if (count !== expectedOccurrences) {
|
|
266
|
+
throw new Error(`old_text 实际出现 ${count} 次,期望 ${expectedOccurrences} 次;未写入`);
|
|
267
|
+
}
|
|
268
|
+
const updated = original.split(oldText).join(newText);
|
|
269
|
+
await this.writeFile(this.relative(absolute), updated, { overwrite: true });
|
|
270
|
+
return { path: this.relative(absolute), replacements: count, bytes_before: Buffer.byteLength(original), bytes_after: Buffer.byteLength(updated) };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async createDirectory(userPath) {
|
|
274
|
+
const absolute = await this.resolve(userPath, { allowMissing: true });
|
|
275
|
+
await fs.mkdir(absolute, { recursive: true });
|
|
276
|
+
return { path: this.relative(absolute) };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async movePath(fromPath, toPath, overwrite = false) {
|
|
280
|
+
const from = await this.resolve(fromPath);
|
|
281
|
+
const to = await this.resolve(toPath, { allowMissing: true });
|
|
282
|
+
if (!overwrite && await fileExists(to)) throw new Error(`目标已存在: ${toPath}`);
|
|
283
|
+
await fs.mkdir(path.dirname(to), { recursive: true });
|
|
284
|
+
if (overwrite && await fileExists(to)) await fs.rm(to, { recursive: true, force: true });
|
|
285
|
+
await fs.rename(from, to);
|
|
286
|
+
return { from: this.relative(from), to: this.relative(to) };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async deletePath(userPath, recursive = false) {
|
|
290
|
+
const absolute = await this.resolve(userPath);
|
|
291
|
+
if (absolute === this.root) throw new Error('拒绝删除工作区根目录');
|
|
292
|
+
const stat = await fs.lstat(absolute);
|
|
293
|
+
if (stat.isDirectory() && !recursive) throw new Error('删除目录必须设置 recursive=true');
|
|
294
|
+
await fs.rm(absolute, { recursive, force: false });
|
|
295
|
+
return { path: this.relative(absolute), deleted: true, type: stat.isDirectory() ? 'directory' : 'file' };
|
|
296
|
+
}
|
|
297
|
+
}
|