@znmin/claude-remote-cli 0.2.5 → 0.2.7
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 +21 -7
- package/package.json +8 -2
- package/src/cli.js +12 -3
- package/src/client.js +4 -1
- package/src/local/browser.js +149 -24
- package/src/local/processes.js +51 -13
- package/src/local/server.js +61 -17
- package/src/local/tunnel.js +3 -3
- package/src/local/workspace.js +124 -27
- package/src/local-mode.js +112 -32
- package/src/remote-claude.js +3 -3
- package/src/system.js +66 -2
package/README.md
CHANGED
|
@@ -25,10 +25,10 @@ npm install -g git+https://github.com/znmin/claude-remote-cli.git
|
|
|
25
25
|
也可以从已下载的分发包安装:
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
npm install -g ./znmin-claude-remote-cli-0.2.
|
|
28
|
+
npm install -g ./znmin-claude-remote-cli-0.2.7.tgz
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
若系统 npm 全局目录不可写,可使用 `npm install -g --prefix "$HOME/.local" ./znmin-claude-remote-cli-0.2.
|
|
31
|
+
若系统 npm 全局目录不可写,可使用 `npm install -g --prefix "$HOME/.local" ./znmin-claude-remote-cli-0.2.7.tgz`,并确保 `~/.local/bin` 在 PATH 中;卸载时也使用相同的 `--prefix`。
|
|
32
32
|
|
|
33
33
|
npm 中不带作用域的 `claude-remote-cli` 属于其他项目。本项目使用独立的 `@znmin/claude-remote-cli` 作用域包名;请安装完整包名,或使用上述 GitHub 地址及本项目分发的 `.tgz` 文件。
|
|
34
34
|
|
|
@@ -117,11 +117,22 @@ claude-remote configure my-server --mode local
|
|
|
117
117
|
|
|
118
118
|
切换模式不会自动停止已有后台同步。若从旧模式切换且不希望旧项目继续同步,先运行 `claude-remote stop --all`。本地模式本身不创建同步会话,不上传整个项目。
|
|
119
119
|
|
|
120
|
-
无需用户手工配置 MCP,默认提供
|
|
120
|
+
无需用户手工配置 MCP,默认提供 24 个工具:
|
|
121
121
|
|
|
122
122
|
- 项目与文件:`workspace_info`、`read_file`、`write_file`、`edit_file`、`list_directory`、`search_text`。
|
|
123
123
|
- 本地终端:`run_command`、`process_status`、`list_processes`、`stop_process`。
|
|
124
|
-
- 浏览器:`browser_open`、`browser_snapshot`、`browser_click`、`browser_fill`、`browser_press`、`browser_screenshot`、`browser_logs`、`browser_close`。
|
|
124
|
+
- 浏览器:`browser_open`、`browser_navigate`、`browser_snapshot`、`browser_click`、`browser_fill`、`browser_press`、`browser_select`、`browser_hover`、`browser_scroll`、`browser_wait_for`、`browser_evaluate`、`browser_screenshot`、`browser_logs`、`browser_close`。
|
|
125
|
+
|
|
126
|
+
0.2.6 起:
|
|
127
|
+
|
|
128
|
+
- `read_file` 单次返回内容有字符上限,超出时提前结束并给出 `next_offset`,从该行继续读取;行数统计不再把末尾换行算作一行。
|
|
129
|
+
- 命令输出默认返回末尾 8000 字符;`process_status` 可用 `stream`、`offset`、`limit` 从头分页读取(每个输出流保留最近 256 KiB)。
|
|
130
|
+
- `search_text` 支持 `regex`(正则)和 `glob`(如 `*.ts`、`src/**/*.{js,jsx}`);在 Git 仓库中遵循 `.gitignore`,无权限读取的目录会被跳过并计数,不再导致整次搜索失败。
|
|
131
|
+
- 浏览器元素选择器在多次快照间保持不变;快照包含 iframe(选择器形如 `frame=1 >> …`)和开放的 Shadow DOM 元素;截图支持整页和单个元素。
|
|
132
|
+
- 本地项目的 `CLAUDE.md`、`.claude/CLAUDE.md`、`CLAUDE.local.md` 在启动时读取并放入系统提示(合计最多 64 KiB),不再依赖模型自行读取。
|
|
133
|
+
- 关闭终端窗口(SIGHUP)或被 `kill`(SIGTERM)时也会清理浏览器、后台命令、SSH 隧道和服务器临时文件。
|
|
134
|
+
- 0.2.7 起,SSH 断线等原因导致远程 Claude 异常结束时,会自动关闭它遗留的鼠标上报、焦点事件、括号粘贴和扩展键盘等终端模式,避免移动鼠标或按键时输出乱码。若仍显示异常(例如停留在全屏界面),可运行 `reset`。
|
|
135
|
+
- 启动时浏览器检查与 SSH 连接并行,浏览器已就绪时不再调用 Playwright 安装程序;服务器上的版本检查和配置写入合并为一次往返。
|
|
125
136
|
|
|
126
137
|
Git、npm、pnpm、Python、Go、Docker、数据库 CLI、`gh` 等共用本地终端,不需要逐项适配,仍需在本机安装并具备必要的登录、连接配置及权限。终端继承启动用户环境和登录 shell,默认工作目录为当前项目。长任务使用后台进程,再查询日志和停止;退出会话会停止仍受本工具管理的进程组,自行 daemonize 或逃离原进程组的程序不在自动清理保证内。
|
|
127
138
|
|
|
@@ -144,7 +155,9 @@ Linux 上仍需安装 Playwright 所需的系统共享库,有窗口模式还
|
|
|
144
155
|
|
|
145
156
|
本地模式要求服务器允许 SSH TCP 转发,Claude Code 版本至少 **2.1.248**;已有版本过旧时会提示在服务器运行 `claude update`。每个会话创建仅监听本机回环地址的工具服务、随机令牌和 SSH 反向隧道;令牌通过 SSH 标准输入写入服务器 0600 临时配置,不出现在命令行参数或同步项目中。退出时清理配置、连接和浏览器。
|
|
146
157
|
|
|
147
|
-
启动时使用 `--restricted`、`--tools "WebSearch,WebFetch"`、`--strict-mcp-config`,限定工具和设置来源、关闭普通 hooks。0.2.2 起恢复 `/config`、`/model`、`/help` 等内置交互命令,不再使用禁用整个命令菜单的 `--disable-slash-commands`;通过 `disableSkillShellExecution` 和 `disableBundledSkills` 单独关闭技能内联 shell 和内置技能工作流。恢复菜单不代表所有依赖服务器项目目录或原生工具的命令都适用于本地模式。设置面板修改的是服务器上的 Claude 配置;受限模式仍只加载托管配置和本次 `--settings`,不会恢复读取普通用户/项目 settings.json,因此偏好设置的跨会话持久化仍受该限制影响,可用 `claude --model sonnet` 明确指定启动模型。保留服务器上的订阅认证,不使用跳过 OAuth 的 `--bare`。本地连接失效时不会将文件或命令操作退回服务器执行。支持 `--continue`、`--resume`、`--model`、`--effort`、`-p`
|
|
158
|
+
启动时使用 `--restricted`、`--tools "WebSearch,WebFetch"`、`--strict-mcp-config`,限定工具和设置来源、关闭普通 hooks。0.2.2 起恢复 `/config`、`/model`、`/help` 等内置交互命令,不再使用禁用整个命令菜单的 `--disable-slash-commands`;通过 `disableSkillShellExecution` 和 `disableBundledSkills` 单独关闭技能内联 shell 和内置技能工作流。恢复菜单不代表所有依赖服务器项目目录或原生工具的命令都适用于本地模式。设置面板修改的是服务器上的 Claude 配置;受限模式仍只加载托管配置和本次 `--settings`,不会恢复读取普通用户/项目 settings.json,因此偏好设置的跨会话持久化仍受该限制影响,可用 `claude --model sonnet` 明确指定启动模型。保留服务器上的订阅认证,不使用跳过 OAuth 的 `--bare`。本地连接失效时不会将文件或命令操作退回服务器执行。支持 `--continue`、`--resume`、`--model`、`--effort`、`--name`、`--fallback-model`、`-p` 和输入/输出格式;`--permission-mode` 支持 `default`、`manual`、`acceptEdits`、`plan`、`auto`、`dontAsk`(受限模式本身拒绝 `bypassPermissions`)。暂不接受替换工具配置、加载插件、切换云会话等参数。
|
|
159
|
+
|
|
160
|
+
本地模式的路由规则、本地 CLAUDE.md 和用户传入的 `--append-system-prompt` 内容合并为一个文件,通过 SSH 标准输入写入服务器临时目录(权限 0600),再用 `--append-system-prompt-file` 加载;用户的追加内容排在路由规则之后,不会替换它。退出时与 MCP 配置一起删除。
|
|
148
161
|
|
|
149
162
|
这是工具路由约束,不是对 Claude Code 主进程的系统隔离。认证、会话和日志仍在服务器,管理员管理策略继续生效。文件内容、命令结果和截图会经过服务器交给模型,不整体同步不代表数据不离开本机。
|
|
150
163
|
|
|
@@ -156,7 +169,7 @@ claude-remote flush # 等待当前项目完成一次同步
|
|
|
156
169
|
claude-remote stop # 停止当前目录在当前配置服务器上的同步
|
|
157
170
|
claude-remote stop --all # 停止本工具的所有同步会话
|
|
158
171
|
claude-remote stop --name 名称 # 按 status 中的名称停止旧服务器上的映射
|
|
159
|
-
claude-remote doctor #
|
|
172
|
+
claude-remote doctor # 检查本地安装(含 Node 版本、SSH 工具、本地浏览器缓存)和服务器配置,不连接服务器
|
|
160
173
|
claude-remote install-sync # 手动重试同步工具准备
|
|
161
174
|
claude-remote --help
|
|
162
175
|
```
|
|
@@ -216,13 +229,14 @@ CLAUDE_REMOTE_DEBUG=1 claude
|
|
|
216
229
|
|
|
217
230
|
```bash
|
|
218
231
|
npm ci --ignore-scripts
|
|
232
|
+
npm run lint
|
|
219
233
|
npm test
|
|
220
234
|
|
|
221
235
|
# 可选:真实本地同步与受控浏览器测试
|
|
222
236
|
CLAUDE_REMOTE_INTEGRATION=1 CLAUDE_REMOTE_BROWSER_TEST=1 npm test
|
|
223
237
|
|
|
224
238
|
npm pack
|
|
225
|
-
npm install -g ./znmin-claude-remote-cli-0.2.
|
|
239
|
+
npm install -g ./znmin-claude-remote-cli-0.2.7.tgz
|
|
226
240
|
|
|
227
241
|
# 确定包名、版本和使用条款后,使用有发布权限的 npm 账号发布
|
|
228
242
|
npm login
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@znmin/claude-remote-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Run Claude Code on an SSH server with local filesystem, terminal and browser tools, or remote bidirectional sync",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -28,8 +28,9 @@
|
|
|
28
28
|
],
|
|
29
29
|
"scripts": {
|
|
30
30
|
"postinstall": "node scripts/postinstall.js",
|
|
31
|
+
"lint": "eslint .",
|
|
31
32
|
"test": "node --test test/*.test.js",
|
|
32
|
-
"prepublishOnly": "npm test"
|
|
33
|
+
"prepublishOnly": "npm run lint && npm test"
|
|
33
34
|
},
|
|
34
35
|
"keywords": [
|
|
35
36
|
"claude",
|
|
@@ -55,5 +56,10 @@
|
|
|
55
56
|
"homepage": "https://github.com/znmin/claude-remote-cli#readme",
|
|
56
57
|
"bugs": {
|
|
57
58
|
"url": "https://github.com/znmin/claude-remote-cli/issues"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@eslint/js": "9.39.5",
|
|
62
|
+
"eslint": "9.39.5",
|
|
63
|
+
"globals": "17.14.0"
|
|
58
64
|
}
|
|
59
65
|
}
|
package/src/cli.js
CHANGED
|
@@ -2,7 +2,7 @@ import { parseArgs } from 'node:util';
|
|
|
2
2
|
import { createInterface } from 'node:readline/promises';
|
|
3
3
|
import { Client, DEFAULTS } from './client.js';
|
|
4
4
|
import { cachedBinary, ensureMutagen, VERSION } from './installer.js';
|
|
5
|
-
import { exists, readJSON, which } from './system.js';
|
|
5
|
+
import { exists, installSignalHandlers, readJSON, which } from './system.js';
|
|
6
6
|
import { readFileSync } from 'node:fs';
|
|
7
7
|
|
|
8
8
|
const PACKAGE = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
@@ -118,8 +118,16 @@ export async function main(asClaude, args = process.argv.slice(2), client = new
|
|
|
118
118
|
await installBrowser(client.log, { debug: client.debug }); console.log('浏览器已就绪。'); return 0;
|
|
119
119
|
}
|
|
120
120
|
if (action === 'doctor') {
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
122
|
+
const nodeOk = major > 20 || major === 20 && minor >= 11;
|
|
123
|
+
console.log(`Node.js:${process.version}${nodeOk ? '' : '(需要 20.11 或更高版本)'}\n平台:${process.platform}/${process.arch}`);
|
|
124
|
+
for (const tool of ['ssh', 'ssh-keygen', 'scp', 'tar']) console.log(`${tool}:${await which(tool, client.env.PATH) || '未安装'}`);
|
|
125
|
+
try {
|
|
126
|
+
const { browserReady } = await import('./local/browser.js');
|
|
127
|
+
const { chromium } = await import('playwright');
|
|
128
|
+
const ready = await browserReady();
|
|
129
|
+
console.log(`本地浏览器:${ready ? chromium.executablePath() : '尚未准备(本地模式首次启动或 claude-remote install-browser 时下载)'}`);
|
|
130
|
+
} catch (error) { console.log('本地浏览器:无法检查(' + error.message + ')'); }
|
|
123
131
|
const cached = cachedBinary(client.env);
|
|
124
132
|
console.log(`同步工具:${client.env.CLAUDE_REMOTE_MUTAGEN || (await exists(cached) ? cached : await which('mutagen', client.env.PATH)) || '首次使用时自动下载'}(目标版本 ${VERSION})`);
|
|
125
133
|
const config = await readJSON(client.configFile, {});
|
|
@@ -131,6 +139,7 @@ export async function main(asClaude, args = process.argv.slice(2), client = new
|
|
|
131
139
|
}
|
|
132
140
|
|
|
133
141
|
export async function entry(asClaude) {
|
|
142
|
+
installSignalHandlers();
|
|
134
143
|
try { process.exitCode = await main(asClaude); }
|
|
135
144
|
catch (error) {
|
|
136
145
|
console.error('claude-remote:' + error.message);
|
package/src/client.js
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
-
import { execute, exists, quote, readJSON, shellJoin, which, withLock, writeJSON } from './system.js';
|
|
5
|
+
import { execute, exists, quote, readJSON, restoreTerminal, shellJoin, which, withLock, writeJSON } from './system.js';
|
|
6
6
|
import { ensureMutagen } from './installer.js';
|
|
7
7
|
import { ensureRemoteClaude, remoteEnvironment as prefix } from './remote-claude.js';
|
|
8
8
|
import { sshArgs } from './ssh.js';
|
|
@@ -67,6 +67,7 @@ export class Client {
|
|
|
67
67
|
if (this.env.CLAUDE_REMOTE_DEBUG === '1') this.log(...messages);
|
|
68
68
|
};
|
|
69
69
|
this.tty = options.tty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
70
|
+
this.restoreTerminal = options.restoreTerminal || (() => restoreTerminal());
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
async configure(input) {
|
|
@@ -204,6 +205,8 @@ export class Client {
|
|
|
204
205
|
try {
|
|
205
206
|
code = (await this.remote(config, prefix + 'cd -- ' + remotePath(item.remote) +
|
|
206
207
|
' && exec ' + shellJoin([config.claude, ...extra]), true)).code;
|
|
208
|
+
// A dropped connection leaves the remote UI's terminal modes switched on.
|
|
209
|
+
if (code !== 0) this.restoreTerminal();
|
|
207
210
|
} finally {
|
|
208
211
|
try { await this.flush(item.name); }
|
|
209
212
|
catch (error) {
|
package/src/local/browser.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import os from 'node:os';
|
|
3
4
|
import { createRequire } from 'node:module';
|
|
@@ -6,33 +7,93 @@ import { chromium } from 'playwright';
|
|
|
6
7
|
import { execute, exists, withLock } from '../system.js';
|
|
7
8
|
|
|
8
9
|
const require = createRequire(import.meta.url);
|
|
10
|
+
const PLAYWRIGHT_VERSION = require('playwright/package.json').version;
|
|
9
11
|
let preparation;
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
const cacheDirectory = () => process.env.CLAUDE_REMOTE_CACHE_DIR || path.join(os.homedir(), '.cache', 'claude-remote');
|
|
14
|
+
export const browserMarker = () => path.join(cacheDirectory(), `browser-ready-${PLAYWRIGHT_VERSION}`);
|
|
15
|
+
|
|
16
|
+
// The marker records which executable a completed install produced, so a
|
|
17
|
+
// warm start skips spawning the Playwright installer entirely.
|
|
18
|
+
export async function browserReady() {
|
|
19
|
+
const executable = chromium.executablePath();
|
|
20
|
+
try { return (await fs.readFile(browserMarker(), 'utf8')).trim() === executable && await exists(executable); }
|
|
21
|
+
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function installBrowser(log = console.error, { debug = () => {}, force = false } = {}) {
|
|
25
|
+
if (!force && await browserReady()) { debug('浏览器缓存可用:' + chromium.executablePath()); return; }
|
|
26
|
+
if (force) preparation = null;
|
|
12
27
|
if (!preparation) preparation = (async () => {
|
|
13
28
|
const cli = path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js');
|
|
14
29
|
// Keep the lock outside Playwright's cache: its installer replaces browser directories.
|
|
15
|
-
|
|
16
|
-
|
|
30
|
+
await withLock(path.join(cacheDirectory(), 'browser-install'), async () => {
|
|
31
|
+
if (!force && await browserReady()) return;
|
|
17
32
|
if (!await exists(chromium.executablePath())) log('正在下载浏览器…');
|
|
18
33
|
else debug('检查浏览器缓存:' + chromium.executablePath());
|
|
19
34
|
// The CLI checks completion markers and prepares headed AND headless binaries.
|
|
20
35
|
const result = await execute(process.execPath, [cli, 'install', 'chromium'], { capture: true, timeout: 600000 });
|
|
21
36
|
if (!await exists(chromium.executablePath())) throw new Error('浏览器安装未完成:' + result.stderr);
|
|
37
|
+
await fs.mkdir(cacheDirectory(), { recursive: true });
|
|
38
|
+
await fs.writeFile(browserMarker(), chromium.executablePath() + '\n');
|
|
22
39
|
});
|
|
23
40
|
})().catch(error => { preparation = null; throw error; });
|
|
24
41
|
await preparation;
|
|
25
42
|
}
|
|
26
43
|
|
|
44
|
+
// Runs inside each frame. Refs persist on elements across snapshots; the
|
|
45
|
+
// per-document token keeps refs from a previous page from matching new ones.
|
|
46
|
+
function collectElements({ limit, textLimit }) {
|
|
47
|
+
const attribute = 'data-claude-remote-ref';
|
|
48
|
+
window.__claudeRemote ||= { token: Math.random().toString(36).slice(2, 7), next: 0 };
|
|
49
|
+
const state = window.__claudeRemote;
|
|
50
|
+
const query = 'a,button,input,textarea,select,summary,[role="button"],[role="link"],[role="checkbox"],' +
|
|
51
|
+
'[role="radio"],[role="tab"],[role="menuitem"],[role="option"],[role="switch"],[contenteditable="true"],[onclick]';
|
|
52
|
+
const found = [];
|
|
53
|
+
const visit = root => {
|
|
54
|
+
for (const element of root.querySelectorAll(query)) found.push(element);
|
|
55
|
+
for (const element of root.querySelectorAll('*')) if (element.shadowRoot) visit(element.shadowRoot);
|
|
56
|
+
};
|
|
57
|
+
visit(document);
|
|
58
|
+
const elements = [];
|
|
59
|
+
for (const element of found) {
|
|
60
|
+
if (elements.length >= limit) break;
|
|
61
|
+
if (!element.getClientRects().length) continue;
|
|
62
|
+
let ref = element.getAttribute(attribute);
|
|
63
|
+
if (!ref || !ref.startsWith(state.token + '-')) {
|
|
64
|
+
ref = `${state.token}-${++state.next}`;
|
|
65
|
+
element.setAttribute(attribute, ref);
|
|
66
|
+
}
|
|
67
|
+
const item = { selector: `[${attribute}="${ref}"]`, tag: element.tagName.toLowerCase(),
|
|
68
|
+
text: (element.innerText || element.getAttribute('aria-label') || element.getAttribute('placeholder') ||
|
|
69
|
+
element.getAttribute('title') || '').trim().slice(0, 200) };
|
|
70
|
+
const type = element.getAttribute('type'); if (type) item.type = type;
|
|
71
|
+
const name = element.getAttribute('name'); if (name) item.name = name;
|
|
72
|
+
if (element.tagName === 'A' && element.getAttribute('href')) item.href = element.getAttribute('href').slice(0, 200);
|
|
73
|
+
if ('value' in element && typeof element.value === 'string' && element.value && type !== 'password')
|
|
74
|
+
item.value = element.value.slice(0, 200);
|
|
75
|
+
if (element.disabled) item.disabled = true;
|
|
76
|
+
if (element.checked) item.checked = true;
|
|
77
|
+
elements.push(item);
|
|
78
|
+
}
|
|
79
|
+
return { text: (document.body?.innerText || '').slice(0, textLimit), elements, more: found.length > elements.length };
|
|
80
|
+
}
|
|
81
|
+
|
|
27
82
|
export class Browser {
|
|
28
83
|
constructor({ headless = false, log = console.error } = {}) {
|
|
29
|
-
this.headless = headless; this.log = log; this.pages = new Map(); this.logs = new Map();
|
|
84
|
+
this.headless = headless; this.log = log; this.pages = new Map(); this.logs = new Map();
|
|
30
85
|
}
|
|
31
86
|
|
|
32
87
|
async start() {
|
|
33
88
|
if (!this.starting) this.starting = (async () => {
|
|
34
89
|
await installBrowser(this.log);
|
|
35
|
-
this.browser = await chromium.launch({ headless: this.headless });
|
|
90
|
+
try { this.browser = await chromium.launch({ headless: this.headless }); }
|
|
91
|
+
catch (error) {
|
|
92
|
+
// The cache marker may be stale (e.g. browsers removed by hand): reinstall once.
|
|
93
|
+
if (!/Executable doesn't exist|executable/i.test(error.message)) throw error;
|
|
94
|
+
await installBrowser(this.log, { force: true });
|
|
95
|
+
this.browser = await chromium.launch({ headless: this.headless });
|
|
96
|
+
}
|
|
36
97
|
this.browser.on('disconnected', () => { this.starting = null; this.context = null; this.browser = null; this.pages.clear(); this.logs.clear(); });
|
|
37
98
|
this.context = await this.browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
38
99
|
})().catch(error => { this.starting = null; throw error; });
|
|
@@ -45,6 +106,15 @@ export class Browser {
|
|
|
45
106
|
return page;
|
|
46
107
|
}
|
|
47
108
|
|
|
109
|
+
// Selectors from iframes look like "frame=2 >> [data-claude-remote-ref=...]".
|
|
110
|
+
locator(page, selector) {
|
|
111
|
+
const match = selector.match(/^frame=(\d+) >> ([\s\S]+)$/);
|
|
112
|
+
if (!match) return page.locator(selector);
|
|
113
|
+
const frame = page.frames()[Number(match[1])];
|
|
114
|
+
if (!frame || frame.isDetached()) throw new Error('找不到对应 iframe,请重新调用 browser_snapshot。');
|
|
115
|
+
return frame.locator(match[2]);
|
|
116
|
+
}
|
|
117
|
+
|
|
48
118
|
async open(url = 'about:blank') {
|
|
49
119
|
await this.start();
|
|
50
120
|
if (this.pages.size >= 16) throw new Error('浏览器页面过多,请先调用 browser_close 关闭部分页面。');
|
|
@@ -52,7 +122,7 @@ export class Browser {
|
|
|
52
122
|
this.pages.set(id, page); this.logs.set(id, []);
|
|
53
123
|
const record = message => {
|
|
54
124
|
const entries = this.logs.get(id); if (!entries) return;
|
|
55
|
-
entries.push(message.slice(0, 1000)); if (entries.length >
|
|
125
|
+
entries.push(message.slice(0, 1000)); if (entries.length > 200) entries.shift();
|
|
56
126
|
};
|
|
57
127
|
page.on('console', message => record(`${message.type()}: ${message.text()}`));
|
|
58
128
|
page.on('pageerror', error => record('pageerror: ' + error.message));
|
|
@@ -63,38 +133,93 @@ export class Browser {
|
|
|
63
133
|
return { ...await this.snapshot(id), ...(navigationError ? { navigation_error: navigationError } : {}) };
|
|
64
134
|
}
|
|
65
135
|
|
|
136
|
+
async navigate(id, { url, action }) {
|
|
137
|
+
const page = this.page(id);
|
|
138
|
+
const options = { waitUntil: 'domcontentloaded', timeout: 30000 };
|
|
139
|
+
let navigationError;
|
|
140
|
+
try {
|
|
141
|
+
if (url) await page.goto(url, options);
|
|
142
|
+
else if (action === 'back') await page.goBack(options);
|
|
143
|
+
else if (action === 'forward') await page.goForward(options);
|
|
144
|
+
else if (action === 'reload') await page.reload(options);
|
|
145
|
+
else throw new Error('请提供 url,或 action 为 back、forward、reload 之一。');
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (!url && !action) throw error;
|
|
148
|
+
navigationError = error.message;
|
|
149
|
+
}
|
|
150
|
+
return { ...await this.snapshot(id), ...(navigationError ? { navigation_error: navigationError } : {}) };
|
|
151
|
+
}
|
|
152
|
+
|
|
66
153
|
async snapshot(id) {
|
|
67
|
-
const page = this.page(id)
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
154
|
+
const page = this.page(id);
|
|
155
|
+
const main = await page.mainFrame().evaluate(collectElements, { limit: 200, textLimit: 12000 });
|
|
156
|
+
const frames = [];
|
|
157
|
+
const all = page.frames();
|
|
158
|
+
for (let index = 0; index < all.length && frames.length < 10; index++) {
|
|
159
|
+
const frame = all[index];
|
|
160
|
+
if (frame === page.mainFrame() || frame.isDetached()) continue;
|
|
161
|
+
try {
|
|
162
|
+
const state = await frame.evaluate(collectElements, { limit: 50, textLimit: 2000 });
|
|
163
|
+
frames.push({ frame: index, url: frame.url(), text: state.text,
|
|
164
|
+
elements: state.elements.map(element => ({ ...element, selector: `frame=${index} >> ${element.selector}` })) });
|
|
165
|
+
} catch { /* detached or not yet loaded */ }
|
|
166
|
+
}
|
|
167
|
+
return { page_id: id, url: page.url(), title: await page.title(), text: main.text, elements: main.elements,
|
|
168
|
+
...(main.more ? { more_elements: true } : {}), ...(frames.length ? { frames } : {}) };
|
|
80
169
|
}
|
|
81
170
|
|
|
82
171
|
async action(id, action, selector, value) {
|
|
83
172
|
const page = this.page(id);
|
|
84
|
-
|
|
85
|
-
|
|
173
|
+
const options = { timeout: 15000 };
|
|
174
|
+
if (action === 'click') await this.locator(page, selector).click(options);
|
|
175
|
+
else if (action === 'fill') await this.locator(page, selector).fill(value, options);
|
|
176
|
+
else if (action === 'hover') await this.locator(page, selector).hover(options);
|
|
177
|
+
else if (action === 'select') await this.locator(page, selector).selectOption(value, options);
|
|
86
178
|
else if (action === 'press') {
|
|
87
|
-
if (selector) await
|
|
179
|
+
if (selector) await this.locator(page, selector).press(value, options);
|
|
88
180
|
else await page.keyboard.press(value);
|
|
89
181
|
}
|
|
90
182
|
return this.snapshot(id);
|
|
91
183
|
}
|
|
92
184
|
|
|
93
|
-
async
|
|
94
|
-
const
|
|
185
|
+
async scroll(id, { selector, x = 0, y = 0 }) {
|
|
186
|
+
const page = this.page(id);
|
|
187
|
+
if (selector) await this.locator(page, selector).scrollIntoViewIfNeeded({ timeout: 15000 });
|
|
188
|
+
else {
|
|
189
|
+
// mouse.wheel does not wait for the scroll to happen; give it a moment.
|
|
190
|
+
await page.mouse.wheel(x, y);
|
|
191
|
+
await page.waitForTimeout(200);
|
|
192
|
+
}
|
|
193
|
+
return this.snapshot(id);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async waitFor(id, { selector, text, state = 'visible', timeout = 10000 }) {
|
|
197
|
+
const page = this.page(id);
|
|
198
|
+
if (selector) await this.locator(page, selector).first().waitFor({ state, timeout });
|
|
199
|
+
else if (text) await page.getByText(text).first().waitFor({ state, timeout });
|
|
200
|
+
else await page.waitForLoadState('load', { timeout });
|
|
201
|
+
return this.snapshot(id);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async evaluate(id, expression) {
|
|
205
|
+
const result = await this.page(id).evaluate(expression);
|
|
206
|
+
return { page_id: id, result: result === undefined ? null : result };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async screenshot(id, { fullPage = false, selector } = {}) {
|
|
210
|
+
const page = this.page(id);
|
|
211
|
+
const options = { type: 'png', timeout: 15000 };
|
|
212
|
+
const buffer = selector ? await this.locator(page, selector).screenshot(options)
|
|
213
|
+
: await page.screenshot({ ...options, fullPage });
|
|
95
214
|
return { content: [{ type: 'image', data: buffer.toString('base64'), mimeType: 'image/png' }] };
|
|
96
215
|
}
|
|
97
216
|
|
|
217
|
+
readLogs(id, limit = 20) {
|
|
218
|
+
this.page(id);
|
|
219
|
+
const logs = this.logs.get(id);
|
|
220
|
+
return { logs: logs.slice(-limit), total: logs.length, truncated: logs.length > limit };
|
|
221
|
+
}
|
|
222
|
+
|
|
98
223
|
async closePage(id) {
|
|
99
224
|
await this.page(id).close(); this.pages.delete(id); this.logs.delete(id);
|
|
100
225
|
return { closed: id };
|
package/src/local/processes.js
CHANGED
|
@@ -2,18 +2,47 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { quote } from '../system.js';
|
|
4
4
|
|
|
5
|
+
// Characters retained per stream; older output is dropped but offsets stay absolute.
|
|
5
6
|
const LIMIT = 256 * 1024;
|
|
7
|
+
const PREVIEW = 8000;
|
|
6
8
|
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
7
9
|
|
|
8
10
|
export class Processes {
|
|
9
11
|
constructor(workspace, env = process.env) { this.workspace = workspace; this.env = env; this.jobs = new Map(); }
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
job(id) {
|
|
12
14
|
const job = this.jobs.get(id);
|
|
13
15
|
if (!job) throw new Error('找不到本会话的进程。');
|
|
16
|
+
return job;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
meta(id) {
|
|
20
|
+
const job = this.job(id);
|
|
14
21
|
return { job_id: id, pid: job.child.pid, running: !job.ended, exit_code: job.code ?? null,
|
|
15
|
-
signal: job.signal ?? null, timed_out: Boolean(job.timedOut),
|
|
16
|
-
|
|
22
|
+
signal: job.signal ?? null, timed_out: Boolean(job.timedOut), cwd: job.cwd };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Without a stream: the last PREVIEW characters of stdout and stderr.
|
|
26
|
+
// With a stream: a page starting at an absolute character offset.
|
|
27
|
+
status(id, { stream, offset, limit = PREVIEW } = {}) {
|
|
28
|
+
const job = this.job(id);
|
|
29
|
+
const totals = { stdout_total: job.stdout.dropped + job.stdout.text.length,
|
|
30
|
+
stderr_total: job.stderr.dropped + job.stderr.text.length };
|
|
31
|
+
if (!stream) {
|
|
32
|
+
const truncated = job.stdout.dropped > 0 || job.stderr.dropped > 0 ||
|
|
33
|
+
job.stdout.text.length > PREVIEW || job.stderr.text.length > PREVIEW;
|
|
34
|
+
return { ...this.meta(id), stdout: job.stdout.text.slice(-PREVIEW), stderr: job.stderr.text.slice(-PREVIEW),
|
|
35
|
+
output_truncated: truncated, ...totals,
|
|
36
|
+
...(truncated ? { hint: '仅返回末尾输出。用 process_status 的 stream、offset、limit 参数分页读取更早的内容。' } : {}) };
|
|
37
|
+
}
|
|
38
|
+
const buffer = job[stream];
|
|
39
|
+
const total = buffer.dropped + buffer.text.length;
|
|
40
|
+
const start = Math.max(offset ?? 0, buffer.dropped);
|
|
41
|
+
const text = buffer.text.slice(start - buffer.dropped, start - buffer.dropped + limit);
|
|
42
|
+
const next = start + text.length;
|
|
43
|
+
return { ...this.meta(id), stream, offset: start, text, total, available_from: buffer.dropped,
|
|
44
|
+
next_offset: next < total ? next : null,
|
|
45
|
+
...(offset !== undefined && offset < buffer.dropped ? { note: `前 ${buffer.dropped} 个字符已丢弃,仅保留最近 ${LIMIT} 个字符。` } : {}) };
|
|
17
46
|
}
|
|
18
47
|
|
|
19
48
|
async run(command, cwd = '.', timeoutMs = 30000, background = false, signal) {
|
|
@@ -25,15 +54,21 @@ export class Processes {
|
|
|
25
54
|
cwd: directory, env: this.env, detached: true, stdio: ['ignore', 'pipe', 'pipe']
|
|
26
55
|
});
|
|
27
56
|
const id = randomUUID();
|
|
28
|
-
const job = { child, cwd: directory,
|
|
57
|
+
const job = { child, cwd: directory, ended: false,
|
|
58
|
+
stdout: { text: '', dropped: 0 }, stderr: { text: '', dropped: 0 } };
|
|
29
59
|
this.jobs.set(id, job);
|
|
30
60
|
job.done = new Promise(resolve => {
|
|
31
61
|
child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8');
|
|
32
62
|
for (const stream of ['stdout', 'stderr']) child[stream].on('data', chunk => {
|
|
33
|
-
|
|
34
|
-
|
|
63
|
+
const buffer = job[stream];
|
|
64
|
+
buffer.text += chunk;
|
|
65
|
+
if (buffer.text.length > LIMIT) {
|
|
66
|
+
const excess = buffer.text.length - LIMIT;
|
|
67
|
+
buffer.text = buffer.text.slice(excess);
|
|
68
|
+
buffer.dropped += excess;
|
|
69
|
+
}
|
|
35
70
|
});
|
|
36
|
-
child.once('error', error => { job.stderr += error.message; job.code = 127; job.ended = true; resolve(); });
|
|
71
|
+
child.once('error', error => { job.stderr.text += error.message; job.code = 127; job.ended = true; resolve(); });
|
|
37
72
|
child.once('close', (code, sig) => { job.code = code; job.signal = sig; job.ended = true; resolve(); });
|
|
38
73
|
});
|
|
39
74
|
if (background) {
|
|
@@ -57,8 +92,7 @@ export class Processes {
|
|
|
57
92
|
}
|
|
58
93
|
|
|
59
94
|
async stop(id) {
|
|
60
|
-
const job = this.
|
|
61
|
-
if (!job) throw new Error('找不到本会话的进程。');
|
|
95
|
+
const job = this.job(id);
|
|
62
96
|
if (job.ended) return this.status(id);
|
|
63
97
|
// Kill the owned group even if its parent shell has already exited.
|
|
64
98
|
this.killGroup(job, 'SIGTERM');
|
|
@@ -68,9 +102,13 @@ export class Processes {
|
|
|
68
102
|
return this.status(id);
|
|
69
103
|
}
|
|
70
104
|
|
|
71
|
-
list() {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
105
|
+
list() {
|
|
106
|
+
return [...this.jobs.keys()].map(id => {
|
|
107
|
+
const job = this.jobs.get(id);
|
|
108
|
+
return { ...this.meta(id), stdout_characters: job.stdout.dropped + job.stdout.text.length,
|
|
109
|
+
stderr_characters: job.stderr.dropped + job.stderr.text.length };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
75
113
|
async close() { await Promise.allSettled([...this.jobs.keys()].map(id => this.stop(id))); }
|
|
76
114
|
}
|
package/src/local/server.js
CHANGED
|
@@ -2,6 +2,7 @@ import http from 'node:http';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
4
4
|
import * as fs from 'node:fs/promises';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
5
6
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
7
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
7
8
|
import { z } from 'zod';
|
|
@@ -9,13 +10,32 @@ import { Workspace } from './workspace.js';
|
|
|
9
10
|
import { Processes } from './processes.js';
|
|
10
11
|
import { Browser } from './browser.js';
|
|
11
12
|
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
const PACKAGE = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
14
|
+
const MAX_RESULT = 40000;
|
|
15
|
+
|
|
16
|
+
const shrink = (value, size) => {
|
|
17
|
+
if (typeof value === 'string')
|
|
18
|
+
return value.length > size ? value.slice(0, size) + `…[已截断 ${value.length - size} 字符]` : value;
|
|
19
|
+
if (Array.isArray(value)) return value.map(item => shrink(item, size));
|
|
20
|
+
if (value && typeof value === 'object')
|
|
21
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, shrink(item, size)]));
|
|
22
|
+
return value;
|
|
18
23
|
};
|
|
24
|
+
|
|
25
|
+
// Claude persists very large MCP results on the server, where local mode cannot
|
|
26
|
+
// read them back. Keep the result structured and shorten its longest strings.
|
|
27
|
+
export function boundedJSON(value) {
|
|
28
|
+
const output = JSON.stringify(value);
|
|
29
|
+
if (output.length <= MAX_RESULT) return output;
|
|
30
|
+
const message = '结果过大,已截断较长字段。请缩小查询范围或分页读取。';
|
|
31
|
+
for (let size = 16000; size >= 100; size = Math.floor(size / 2)) {
|
|
32
|
+
const shrunk = JSON.stringify({ ...shrink(value, size), truncated: true, message });
|
|
33
|
+
if (shrunk.length <= MAX_RESULT) return shrunk;
|
|
34
|
+
}
|
|
35
|
+
return JSON.stringify({ truncated: true, message, preview: output.slice(0, 16000) });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const text = value => ({ content: [{ type: 'text', text: boundedJSON(value) }] });
|
|
19
39
|
const file = z.string().min(1).max(4096);
|
|
20
40
|
const pageId = z.string().min(1);
|
|
21
41
|
const selector = z.string().min(1).max(4000);
|
|
@@ -29,7 +49,7 @@ export async function startLocalServer({ root, env = process.env, headless = fal
|
|
|
29
49
|
let closing = false;
|
|
30
50
|
|
|
31
51
|
function createServer() {
|
|
32
|
-
const server = new McpServer({ name: 'claude-local-tools', version:
|
|
52
|
+
const server = new McpServer({ name: 'claude-local-tools', version: PACKAGE.version });
|
|
33
53
|
const register = (name, description, inputSchema, handler, readOnly = false) => {
|
|
34
54
|
server.registerTool(name, { description, inputSchema,
|
|
35
55
|
annotations: { readOnlyHint: readOnly, idempotentHint: readOnly } }, async (args, extra) => {
|
|
@@ -42,7 +62,7 @@ export async function startLocalServer({ root, env = process.env, headless = fal
|
|
|
42
62
|
register('workspace_info', 'Describe the LOCAL computer and project. All tools execute on this computer, not the SSH server.', {},
|
|
43
63
|
() => ({ root: workspace.root, platform: os.platform(), shell: env.SHELL || '/bin/bash',
|
|
44
64
|
instructions: 'Use local absolute or project-relative paths. Use background=true for development servers, then process_status for logs. Browser selectors come from browser_snapshot. Terminal commands run as the local user.' }), true);
|
|
45
|
-
register('read_file', 'Read a LOCAL project text file, with line numbers and a SHA-256 for safe replacement.', {
|
|
65
|
+
register('read_file', 'Read a LOCAL project text file, with line numbers and a SHA-256 for safe replacement. Large reads stop early and return next_offset to continue from.', {
|
|
46
66
|
path: file, offset: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(2000).default(200)
|
|
47
67
|
}, args => workspace.read(args.path, args.offset, args.limit), true);
|
|
48
68
|
register('write_file', 'Create a LOCAL file. Replacing an existing file requires its current SHA-256 from read_file.', {
|
|
@@ -53,16 +73,19 @@ export async function startLocalServer({ root, env = process.env, headless = fal
|
|
|
53
73
|
}, args => workspace.edit(args.path, args.old_text, args.new_text, args.replace_all));
|
|
54
74
|
register('list_directory', 'List LOCAL project files and directories, including hidden entries.', { path: file.default('.') },
|
|
55
75
|
args => workspace.list(args.path), true);
|
|
56
|
-
register('search_text', 'Search
|
|
76
|
+
register('search_text', 'Search text in LOCAL project files. Literal by default; set regex=true for JavaScript regular expressions. glob filters files (e.g. "*.ts", "src/**/*.{js,jsx}"). Follows .gitignore inside Git repositories and skips .git, node_modules, .venv, binary and large files.', {
|
|
57
77
|
pattern: z.string().min(1).max(1000), path: file.default('.'), max_results: z.number().int().min(1).max(500).default(100),
|
|
58
|
-
case_sensitive: z.boolean().default(true)
|
|
59
|
-
}, args => workspace.search(args.pattern, args.path, args.max_results, args.case_sensitive
|
|
78
|
+
case_sensitive: z.boolean().default(true), regex: z.boolean().default(false), glob: z.string().min(1).max(500).optional()
|
|
79
|
+
}, args => workspace.search(args.pattern, args.path, args.max_results, args.case_sensitive,
|
|
80
|
+
{ regex: args.regex, glob: args.glob }), true);
|
|
60
81
|
register('run_command', 'Run a shell command on the LOCAL computer in the project. Git/npm/Docker and other installed CLI tools share this tool. For servers or long jobs set background=true; use process_status for output and stop_process to stop.', {
|
|
61
82
|
command: z.string().min(1).max(100000), cwd: file.default('.'), timeout_ms: z.number().int().min(100).max(120000).default(30000),
|
|
62
83
|
background: z.boolean().default(false)
|
|
63
84
|
}, (args, extra) => processes.run(args.command, args.cwd, args.timeout_ms, args.background, extra.signal));
|
|
64
|
-
register('process_status', 'Read status and
|
|
65
|
-
|
|
85
|
+
register('process_status', 'Read status and output of a process started by this session. By default returns the last 8000 characters of stdout and stderr; pass stream with offset/limit to page through earlier output.', {
|
|
86
|
+
job_id: z.string(), stream: z.enum(['stdout', 'stderr']).optional(),
|
|
87
|
+
offset: z.number().int().min(0).optional(), limit: z.number().int().min(1).max(30000).default(8000)
|
|
88
|
+
}, args => processes.status(args.job_id, { stream: args.stream, offset: args.offset, limit: args.limit }), true);
|
|
66
89
|
register('list_processes', 'List processes owned by this tool session, not all system processes.', {}, () => processes.list(), true);
|
|
67
90
|
register('stop_process', 'Stop a process group started by this tool session, including a development server.', { job_id: z.string() },
|
|
68
91
|
args => processes.stop(args.job_id));
|
|
@@ -78,10 +101,31 @@ export async function startLocalServer({ root, env = process.env, headless = fal
|
|
|
78
101
|
register('browser_press', 'Press a key such as Enter, Escape or ControlOrMeta+A in a LOCAL page.', {
|
|
79
102
|
page_id: pageId, selector: selector.optional(), key: z.string().min(1).max(100)
|
|
80
103
|
}, args => browser.action(args.page_id, 'press', args.selector, args.key));
|
|
81
|
-
register('
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
104
|
+
register('browser_select', 'Choose option(s) of a <select> on a LOCAL page by value or label.', {
|
|
105
|
+
page_id: pageId, selector, values: z.union([z.string(), z.array(z.string()).max(50)])
|
|
106
|
+
}, args => browser.action(args.page_id, 'select', args.selector, args.values));
|
|
107
|
+
register('browser_hover', 'Hover over an element on a LOCAL page, e.g. to open a menu.', { page_id: pageId, selector },
|
|
108
|
+
args => browser.action(args.page_id, 'hover', args.selector));
|
|
109
|
+
register('browser_navigate', 'Navigate an open LOCAL page to a URL, or go back, forward or reload.', {
|
|
110
|
+
page_id: pageId, url: z.string().url().optional(), action: z.enum(['back', 'forward', 'reload']).optional()
|
|
111
|
+
}, args => browser.navigate(args.page_id, args));
|
|
112
|
+
register('browser_scroll', 'Scroll a LOCAL page: bring a selector into view, or scroll by x/y pixels.', {
|
|
113
|
+
page_id: pageId, selector: selector.optional(), x: z.number().int().default(0), y: z.number().int().default(0)
|
|
114
|
+
}, args => browser.scroll(args.page_id, args));
|
|
115
|
+
register('browser_wait_for', 'Wait until a selector or text reaches a state (default visible) on a LOCAL page, or until the page has loaded.', {
|
|
116
|
+
page_id: pageId, selector: selector.optional(), text: z.string().min(1).max(1000).optional(),
|
|
117
|
+
state: z.enum(['visible', 'hidden', 'attached', 'detached']).default('visible'),
|
|
118
|
+
timeout_ms: z.number().int().min(100).max(60000).default(10000)
|
|
119
|
+
}, args => browser.waitFor(args.page_id, { selector: args.selector, text: args.text, state: args.state, timeout: args.timeout_ms }), true);
|
|
120
|
+
register('browser_evaluate', 'Evaluate a JavaScript expression in a LOCAL page and return its JSON-serializable result.', {
|
|
121
|
+
page_id: pageId, expression: z.string().min(1).max(100000)
|
|
122
|
+
}, args => browser.evaluate(args.page_id, args.expression));
|
|
123
|
+
register('browser_screenshot', 'Capture a LOCAL page as an image: the viewport by default, the full page, or one element.', {
|
|
124
|
+
page_id: pageId, full_page: z.boolean().default(false), selector: selector.optional()
|
|
125
|
+
}, args => browser.screenshot(args.page_id, { fullPage: args.full_page, selector: args.selector }), true);
|
|
126
|
+
register('browser_logs', 'Read recent browser console messages, page errors and failed network requests.', {
|
|
127
|
+
page_id: pageId, limit: z.number().int().min(1).max(200).default(20)
|
|
128
|
+
}, args => browser.readLogs(args.page_id, args.limit), true);
|
|
85
129
|
register('browser_close', 'Close a LOCAL browser tab opened by this session.', { page_id: pageId }, args => browser.closePage(args.page_id));
|
|
86
130
|
return server;
|
|
87
131
|
}
|
package/src/local/tunnel.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
-
import { execute, exists } from '../system.js';
|
|
6
|
+
import { execute, exists, trackChild } from '../system.js';
|
|
7
7
|
import { sshBase } from '../ssh.js';
|
|
8
8
|
|
|
9
9
|
export async function openTunnel(config, localPort, { env = process.env, run = execute } = {}) {
|
|
@@ -11,8 +11,8 @@ export async function openTunnel(config, localPort, { env = process.env, run = e
|
|
|
11
11
|
const base = os.tmpdir().length > 55 ? '/tmp' : os.tmpdir();
|
|
12
12
|
const directory = await fs.mkdtemp(path.join(base, 'ccr-'));
|
|
13
13
|
const controlPath = path.join(directory, 'ssh');
|
|
14
|
-
const master = spawn('ssh', [...sshBase(config), '-M', '-S', controlPath, '-o', 'ControlPersist=no',
|
|
15
|
-
'-o', 'ExitOnForwardFailure=yes', '-N', '-T', config.host], { env, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
14
|
+
const master = trackChild(spawn('ssh', [...sshBase(config), '-M', '-S', controlPath, '-o', 'ControlPersist=no',
|
|
15
|
+
'-o', 'ExitOnForwardFailure=yes', '-N', '-T', config.host], { env, stdio: ['ignore', 'ignore', 'pipe'] }));
|
|
16
16
|
let closed = false, errorText = '', spawnError;
|
|
17
17
|
const done = new Promise(resolve => {
|
|
18
18
|
master.once('error', error => { spawnError = error; closed = true; resolve(); });
|
package/src/local/workspace.js
CHANGED
|
@@ -1,10 +1,47 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
3
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
5
|
|
|
5
6
|
const hash = text => createHash('sha256').update(text).digest('hex');
|
|
6
7
|
const inside = (root, target) => target === root || target.startsWith(root + path.sep);
|
|
7
8
|
|
|
9
|
+
// Keep read_file results below the MCP response limit (JSON escaping adds overhead).
|
|
10
|
+
const READ_BUDGET = 28000;
|
|
11
|
+
const SKIPPED = new Set(['.git', 'node_modules', '.venv', '__pycache__']);
|
|
12
|
+
const MAX_FILES = 10000;
|
|
13
|
+
const escapeRegExp = text => text.replace(/[.+^${}()|[\]\\*?]/g, '\\$&');
|
|
14
|
+
|
|
15
|
+
// Minimal glob: *, **, ?, {a,b}. Without a slash it matches the file name only.
|
|
16
|
+
export function globMatcher(glob) {
|
|
17
|
+
let source = '';
|
|
18
|
+
for (let i = 0; i < glob.length; i++) {
|
|
19
|
+
const char = glob[i];
|
|
20
|
+
if (char === '*' && glob[i + 1] === '*') {
|
|
21
|
+
i++;
|
|
22
|
+
if (glob[i + 1] === '/') { i++; source += '(?:.*/)?'; } else source += '.*';
|
|
23
|
+
} else if (char === '*') source += '[^/]*';
|
|
24
|
+
else if (char === '?') source += '[^/]';
|
|
25
|
+
else if (char === '{' && glob.indexOf('}', i) > i) {
|
|
26
|
+
const end = glob.indexOf('}', i);
|
|
27
|
+
source += '(?:' + glob.slice(i + 1, end).split(',').map(escapeRegExp).join('|') + ')';
|
|
28
|
+
i = end;
|
|
29
|
+
} else source += escapeRegExp(char);
|
|
30
|
+
}
|
|
31
|
+
const pattern = new RegExp('^' + source + '$');
|
|
32
|
+
const byName = !glob.includes('/');
|
|
33
|
+
return relative => pattern.test(byName ? path.posix.basename(relative) : relative);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function gitFiles(directory) {
|
|
37
|
+
return new Promise(resolve => {
|
|
38
|
+
execFile('git', ['-C', directory, 'ls-files', '-z', '--cached', '--others', '--exclude-standard'],
|
|
39
|
+
{ maxBuffer: 64 * 1024 * 1024, timeout: 15000 }, (error, stdout) => {
|
|
40
|
+
resolve(error ? null : stdout.split('\0').filter(Boolean));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
8
45
|
export class Workspace {
|
|
9
46
|
constructor(root) { this.root = root; }
|
|
10
47
|
|
|
@@ -35,8 +72,25 @@ export class Workspace {
|
|
|
35
72
|
const content = await fs.readFile(target, 'utf8');
|
|
36
73
|
if (content.includes('\0')) throw new Error('这是二进制文件,请通过终端或专用工具处理。');
|
|
37
74
|
const lines = content.split('\n');
|
|
38
|
-
|
|
39
|
-
|
|
75
|
+
if (lines.length > 1 && lines.at(-1) === '') lines.pop(); // A trailing newline is not an extra line.
|
|
76
|
+
const total = content === '' ? 0 : lines.length;
|
|
77
|
+
const output = [];
|
|
78
|
+
let used = 0, sizeLimited = false;
|
|
79
|
+
for (let i = offset - 1; i < Math.min(total, offset - 1 + limit); i++) {
|
|
80
|
+
const line = `${i + 1}: ${lines[i]}`;
|
|
81
|
+
if (used + line.length > READ_BUDGET) {
|
|
82
|
+
sizeLimited = true;
|
|
83
|
+
if (!output.length) output.push(line.slice(0, READ_BUDGET) + ` …[本行过长已截断,共 ${lines[i].length} 字符,请用终端查看]`);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
output.push(line);
|
|
87
|
+
used += line.length + 1;
|
|
88
|
+
}
|
|
89
|
+
const next = offset + output.length;
|
|
90
|
+
return { path: path.relative(this.root, target), sha256: hash(content), total_lines: total,
|
|
91
|
+
content: output.join('\n'),
|
|
92
|
+
...(next <= total ? { next_offset: next } : {}),
|
|
93
|
+
...(sizeLimited ? { truncated_by_size: true } : {}) };
|
|
40
94
|
}
|
|
41
95
|
|
|
42
96
|
async atomicWrite(target, content, mode = 0o644) {
|
|
@@ -83,35 +137,78 @@ export class Workspace {
|
|
|
83
137
|
})) };
|
|
84
138
|
}
|
|
85
139
|
|
|
86
|
-
|
|
140
|
+
// Candidate files relative to `directory`. Inside a Git work tree this follows
|
|
141
|
+
// .gitignore; otherwise it walks the tree and skips common dependency folders.
|
|
142
|
+
async candidates(directory) {
|
|
143
|
+
const fromGit = await gitFiles(directory);
|
|
144
|
+
// An empty list usually means the directory itself is ignored; search it explicitly.
|
|
145
|
+
if (fromGit?.length) {
|
|
146
|
+
const files = fromGit.filter(file => !file.split('/').some(part => SKIPPED.has(part))).sort();
|
|
147
|
+
return { files: files.slice(0, MAX_FILES), limited: files.length > MAX_FILES, errors: 0, source: 'git' };
|
|
148
|
+
}
|
|
149
|
+
const files = [];
|
|
150
|
+
let errors = 0, limited = false;
|
|
151
|
+
const walk = async relative => {
|
|
152
|
+
let entries;
|
|
153
|
+
try { entries = await fs.readdir(path.join(directory, relative), { withFileTypes: true }); }
|
|
154
|
+
catch { errors++; return; }
|
|
155
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
156
|
+
for (const entry of entries) {
|
|
157
|
+
if (files.length >= MAX_FILES) { limited = true; return; }
|
|
158
|
+
const child = relative ? relative + '/' + entry.name : entry.name;
|
|
159
|
+
if (entry.isDirectory()) { if (!SKIPPED.has(entry.name)) await walk(child); }
|
|
160
|
+
else if (entry.isFile()) files.push(child);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
await walk('');
|
|
164
|
+
return { files, limited, errors, source: 'walk' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async search(pattern, directory = '.', maxResults = 100, caseSensitive = true, { regex = false, glob } = {}) {
|
|
87
168
|
const root = await this.resolve(directory);
|
|
169
|
+
let test;
|
|
170
|
+
if (regex) {
|
|
171
|
+
let expression;
|
|
172
|
+
try { expression = new RegExp(pattern, caseSensitive ? '' : 'i'); }
|
|
173
|
+
catch (error) { throw new Error('正则表达式无效:' + error.message); }
|
|
174
|
+
test = line => expression.test(line);
|
|
175
|
+
} else {
|
|
176
|
+
const needle = caseSensitive ? pattern : pattern.toLowerCase();
|
|
177
|
+
test = line => (caseSensitive ? line : line.toLowerCase()).includes(needle);
|
|
178
|
+
}
|
|
179
|
+
const matchesGlob = glob ? globMatcher(glob) : () => true;
|
|
180
|
+
const rootStat = await fs.stat(root);
|
|
181
|
+
const listing = rootStat.isFile() ? { files: [''], limited: false, errors: 0, source: 'file' } : await this.candidates(root);
|
|
182
|
+
const files = listing.files.filter(file => file === '' || matchesGlob(file));
|
|
88
183
|
const results = [];
|
|
89
|
-
let scanned = 0, truncated =
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (stat.isDirectory()) {
|
|
96
|
-
for (const entry of await fs.readdir(target, { withFileTypes: true })) {
|
|
97
|
-
if (['.git', 'node_modules', '.venv', '__pycache__'].includes(entry.name)) continue;
|
|
98
|
-
await walk(path.join(target, entry.name));
|
|
99
|
-
if (truncated) break;
|
|
100
|
-
}
|
|
101
|
-
} else if (stat.isFile()) {
|
|
102
|
-
scanned++;
|
|
103
|
-
if (stat.size > 1024 * 1024) return;
|
|
184
|
+
let scanned = 0, errors = listing.errors, truncated = listing.limited;
|
|
185
|
+
const scan = async relative => {
|
|
186
|
+
const target = relative ? path.join(root, relative) : root;
|
|
187
|
+
try {
|
|
188
|
+
const stat = await fs.lstat(target);
|
|
189
|
+
if (!stat.isFile() || stat.size > 1024 * 1024) return null;
|
|
104
190
|
const text = await fs.readFile(target, 'utf8');
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
191
|
+
scanned++;
|
|
192
|
+
if (text.includes('\0')) return null;
|
|
193
|
+
const found = [];
|
|
194
|
+
for (const [i, line] of text.split('\n').entries())
|
|
195
|
+
if (test(line)) found.push({ path: path.relative(this.root, target), line: i + 1, text: line.slice(0, 1000) });
|
|
196
|
+
return found;
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error.code !== 'ENOENT') errors++; // Deleted tracked files are not errors.
|
|
199
|
+
return null;
|
|
112
200
|
}
|
|
113
201
|
};
|
|
114
|
-
|
|
115
|
-
|
|
202
|
+
// Read in parallel batches but keep results in a stable path order.
|
|
203
|
+
for (let i = 0; i < files.length && !truncated; i += 32) {
|
|
204
|
+
for (const found of await Promise.all(files.slice(i, i + 32).map(scan))) {
|
|
205
|
+
for (const match of found || []) {
|
|
206
|
+
if (results.length >= maxResults) { truncated = true; break; }
|
|
207
|
+
results.push(match);
|
|
208
|
+
}
|
|
209
|
+
if (truncated) break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { results, scanned_files: scanned, truncated, source: listing.source, ...(errors ? { unreadable: errors } : {}) };
|
|
116
213
|
}
|
|
117
214
|
}
|
package/src/local-mode.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
-
import { quote, shellJoin, which } from './system.js';
|
|
4
|
+
import { onCleanup, quote, shellJoin, which } from './system.js';
|
|
5
5
|
import { sshArgs } from './ssh.js';
|
|
6
6
|
import { remotePath } from './client.js';
|
|
7
7
|
import { ensureRemoteClaude, remoteEnvironment } from './remote-claude.js';
|
|
@@ -12,9 +12,14 @@ import { openTunnel } from './local/tunnel.js';
|
|
|
12
12
|
const BOOLEAN_FLAGS = new Set(['--continue', '-c', '--print', '-p', '--verbose', '--no-session-persistence',
|
|
13
13
|
'--include-partial-messages', '--replay-user-messages']);
|
|
14
14
|
const VALUE_FLAGS = new Set(['--model', '--effort', '--max-turns', '--max-budget-usd', '--output-format',
|
|
15
|
-
'--input-format', '--session-id', '--append-system-prompt']);
|
|
15
|
+
'--input-format', '--session-id', '--append-system-prompt', '--permission-mode', '--name', '-n', '--fallback-model']);
|
|
16
|
+
// --restricted refuses bypassPermissions anyway; reject it before connecting.
|
|
17
|
+
const PERMISSION_MODES = new Set(['default', 'manual', 'acceptEdits', 'plan', 'auto', 'dontAsk']);
|
|
16
18
|
const SUBCOMMANDS = new Set(['auth', 'mcp', 'install', 'update', 'doctor', 'agents', 'attach', 'gateway',
|
|
17
19
|
'daemon', 'remote-control', 'rc', 'desktop', 'setup-token']);
|
|
20
|
+
// Project instruction files read from the LOCAL project root, in Claude Code's order.
|
|
21
|
+
const INSTRUCTION_FILES = ['CLAUDE.md', path.join('.claude', 'CLAUDE.md'), 'CLAUDE.local.md'];
|
|
22
|
+
const INSTRUCTION_LIMIT = 64 * 1024;
|
|
18
23
|
|
|
19
24
|
export function validateLocalArguments(args) {
|
|
20
25
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -27,30 +32,85 @@ export function validateLocalArguments(args) {
|
|
|
27
32
|
const name = argument.split('=')[0];
|
|
28
33
|
if (BOOLEAN_FLAGS.has(name) && argument === name) continue;
|
|
29
34
|
if (VALUE_FLAGS.has(name) || name === '--resume' || name === '-r') {
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
if (name === '--resume' || name === '-r') {
|
|
36
|
+
if (!argument.includes('=') && args[i + 1] && !args[i + 1].startsWith('-')) i++;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
let value;
|
|
40
|
+
if (argument.includes('=')) value = argument.slice(argument.indexOf('=') + 1);
|
|
41
|
+
else {
|
|
42
|
+
if (args[i + 1] === undefined || args[i + 1].startsWith('-')) throw new Error(`${name} 缺少参数;以短横线开头的值请使用 ${name}=值。`);
|
|
43
|
+
value = args[++i];
|
|
44
|
+
}
|
|
45
|
+
if (name === '--permission-mode' && !PERMISSION_MODES.has(value))
|
|
46
|
+
throw new Error(`本地模式的 --permission-mode 仅支持 ${[...PERMISSION_MODES].join('、')}。`);
|
|
47
|
+
continue;
|
|
34
48
|
}
|
|
35
49
|
throw new Error(`本地模式暂不支持参数 ${name};工具配置由本地模式统一管理。`);
|
|
36
50
|
}
|
|
37
51
|
}
|
|
38
52
|
|
|
39
|
-
|
|
53
|
+
// Pull --append-system-prompt values out of the forwarded arguments. They are
|
|
54
|
+
// merged into the single prompt file so they cannot replace the routing rules.
|
|
55
|
+
export function extractAppendedPrompts(args) {
|
|
56
|
+
const rest = [], prompts = [];
|
|
57
|
+
for (let i = 0; i < args.length; i++) {
|
|
58
|
+
const argument = args[i];
|
|
59
|
+
if (argument === '--') { rest.push(...args.slice(i)); break; }
|
|
60
|
+
if (argument === '--append-system-prompt') { prompts.push(args[++i]); continue; }
|
|
61
|
+
if (argument.startsWith('--append-system-prompt=')) { prompts.push(argument.slice(argument.indexOf('=') + 1)); continue; }
|
|
62
|
+
rest.push(argument);
|
|
63
|
+
if (VALUE_FLAGS.has(argument) && i + 1 < args.length) rest.push(args[++i]);
|
|
64
|
+
}
|
|
65
|
+
return { rest, prompts };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function projectInstructions(root) {
|
|
69
|
+
const sections = [];
|
|
70
|
+
let remaining = INSTRUCTION_LIMIT;
|
|
71
|
+
for (const name of INSTRUCTION_FILES) {
|
|
72
|
+
if (remaining <= 0) break;
|
|
73
|
+
let content;
|
|
74
|
+
try {
|
|
75
|
+
const file = await fs.realpath(path.join(root, name));
|
|
76
|
+
if (file !== root && !file.startsWith(root + path.sep)) continue; // Never follow links out of the project.
|
|
77
|
+
content = await fs.readFile(file, 'utf8');
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (['ENOENT', 'ENOTDIR', 'EISDIR'].includes(error.code)) continue;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
if (!content.trim()) continue;
|
|
83
|
+
const clipped = content.length > remaining ? content.slice(0, remaining) + `\n…[已截断,完整内容请用 read_file 读取 ${name}]` : content;
|
|
84
|
+
remaining -= clipped.length;
|
|
85
|
+
sections.push(`Contents of LOCAL ${name}:\n\n${clipped}`);
|
|
86
|
+
}
|
|
87
|
+
return sections.join('\n\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function localPrompt(root, { instructions = '', appended = [] } = {}) {
|
|
91
|
+
const parts = [`All project operations run on the developer's LOCAL computer through the local MCP server. ` +
|
|
92
|
+
`The LOCAL project root is ${JSON.stringify(root)}. The server working directory is only for session metadata, not project files. ` +
|
|
93
|
+
'Call workspace_info before working. Use only local tools for file access, shell commands and browser operations. ' +
|
|
94
|
+
'For public information research, use the server-side WebSearch and WebFetch tools first; do not open the local browser for ordinary searches or reading public documentation. ' +
|
|
95
|
+
'Use the LOCAL browser for local development pages, localhost/loopback or developer LAN addresses, interactive UI testing, screenshots, or pages requiring interactive sign-in. ' +
|
|
96
|
+
'Never send local development URLs to server-side WebFetch. If WebSearch or WebFetch is unavailable or fails for ordinary public research, report it and ask before using the local browser as a fallback. ' +
|
|
97
|
+
'Project instructions from the LOCAL CLAUDE.md files are included below when present; also read CLAUDE.md files in LOCAL subdirectories with read_file when working there. ' +
|
|
98
|
+
'Use background commands for development servers, inspect their logs, and stop owned processes when finished. ' +
|
|
99
|
+
'If the local connection fails, report it and stop local project operations; never fall back to server filesystem, shell or browser tools.'];
|
|
100
|
+
if (instructions) parts.push('# Project instructions (LOCAL)\n\n' + instructions);
|
|
101
|
+
if (appended.length) parts.push('# Additional instructions from the user\n\n' + appended.join('\n\n'));
|
|
102
|
+
return parts.join('\n\n');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function localClaudeArguments(configFile, promptFile, extra) {
|
|
40
106
|
validateLocalArguments(extra);
|
|
107
|
+
const { rest } = extractAppendedPrompts(extra);
|
|
41
108
|
return ['--restricted', '--tools', 'WebSearch,WebFetch', '--strict-mcp-config', '--mcp-config', configFile,
|
|
42
109
|
// Keep built-in UI commands (/config, /model, /help) available. Disable
|
|
43
110
|
// skill shell expansion separately instead of disabling the entire menu.
|
|
44
111
|
'--setting-sources', '', '--settings', JSON.stringify({ disableAllHooks: true,
|
|
45
112
|
disableSkillShellExecution: true, disableBundledSkills: true, enabledPlugins: {} }),
|
|
46
|
-
'--append-system-prompt',
|
|
47
|
-
`The LOCAL project root is ${JSON.stringify(root)}. The server working directory is only for session metadata, not project files. ` +
|
|
48
|
-
'Call workspace_info before working. Use only local tools for file access, shell commands and browser operations. ' +
|
|
49
|
-
'For public information research, use the server-side WebSearch and WebFetch tools first; do not open the local browser for ordinary searches or reading public documentation. ' +
|
|
50
|
-
'Use the LOCAL browser for local development pages, localhost/loopback or developer LAN addresses, interactive UI testing, screenshots, or pages requiring interactive sign-in. ' +
|
|
51
|
-
'Never send local development URLs to server-side WebFetch. If WebSearch or WebFetch is unavailable or fails for ordinary public research, report it and ask before using the local browser as a fallback. ' +
|
|
52
|
-
'Read LOCAL CLAUDE.md with read_file if present. Use background commands for development servers, inspect their logs, and stop owned processes when finished. ' +
|
|
53
|
-
'If the local connection fails, report it and stop local project operations; never fall back to server filesystem, shell or browser tools.', ...extra];
|
|
113
|
+
'--append-system-prompt-file', promptFile, ...rest];
|
|
54
114
|
}
|
|
55
115
|
|
|
56
116
|
export function supportsLocalMode(output) {
|
|
@@ -65,36 +125,56 @@ export async function launchLocal(client, config, extra, dependencies = {}) {
|
|
|
65
125
|
if (!await which('ssh', client.env.PATH)) throw new Error('找不到系统 SSH 客户端。');
|
|
66
126
|
const root = await fs.realpath(client.cwd), home = await fs.realpath(client.home);
|
|
67
127
|
if (root === home || root === path.parse(root).root) throw new Error('请进入具体项目目录,不允许以用户目录或磁盘根目录作为项目。');
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
let tunnel, configFile;
|
|
128
|
+
const prompt = localPrompt(root, { instructions: await projectInstructions(root), appended: extractAppendedPrompts(extra).prompts });
|
|
129
|
+
// Prepare the browser while SSH connects; it is only needed before Claude starts.
|
|
130
|
+
const browserReady = (dependencies.installBrowser || installBrowser)(client.log, { debug: client.debug });
|
|
131
|
+
browserReady.catch(() => {}); // Reported when awaited below.
|
|
132
|
+
let gateway, tunnel, configFile, promptFile, cleaning;
|
|
73
133
|
const hash = createHash('sha256').update(config.client_id + '\0' + root).digest('hex').slice(0, 24);
|
|
74
134
|
const remoteDirectory = config.root + '/local-' + hash;
|
|
75
135
|
const remote = (script, { interactive = false, input, capture = !interactive, timeout = 30000, check = true } = {}) =>
|
|
76
136
|
client.execute('ssh', sshArgs(config, script, { interactive, tty: interactive && client.tty,
|
|
77
137
|
input: input !== undefined, controlPath: tunnel.controlPath }), { env: client.env, capture, input,
|
|
78
138
|
timeout: interactive ? 0 : timeout, check });
|
|
139
|
+
const cleanup = () => cleaning ||= (async () => {
|
|
140
|
+
if (tunnel && configFile)
|
|
141
|
+
await remote('rm -f -- ' + remotePath(configFile) + ' ' + remotePath(promptFile), { timeout: 5000, check: false }).catch(() => {});
|
|
142
|
+
await gateway?.close();
|
|
143
|
+
await tunnel?.close();
|
|
144
|
+
})();
|
|
145
|
+
const removeCleanup = onCleanup(cleanup);
|
|
79
146
|
try {
|
|
147
|
+
gateway = await (dependencies.startLocalServer || startLocalServer)({ root, env: client.env,
|
|
148
|
+
headless: client.env.CLAUDE_REMOTE_HEADLESS === undefined ? Boolean(config.browserHeadless) : client.env.CLAUDE_REMOTE_HEADLESS === '1', log: client.log });
|
|
80
149
|
tunnel = await (dependencies.openTunnel || openTunnel)(config, gateway.port, { env: client.env, run: client.execute });
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
150
|
+
const session = randomUUID();
|
|
151
|
+
configFile = remoteDirectory + '/.local-mcp-' + session + '.json';
|
|
152
|
+
promptFile = remoteDirectory + '/.local-prompt-' + session + '.md';
|
|
84
153
|
const mcp = { mcpServers: { local: { type: 'http', url: `http://127.0.0.1:${tunnel.port}/mcp`,
|
|
85
154
|
headers: { Authorization: 'Bearer ' + gateway.token }, timeout: 180000 } } };
|
|
86
|
-
|
|
87
|
-
|
|
155
|
+
// One round trip: install Claude if missing, store the token-bearing MCP
|
|
156
|
+
// config (first stdin line) and the prompt (the rest), then report the version.
|
|
157
|
+
// `read` consumes the pipe byte by byte, so nothing is lost between the two files.
|
|
158
|
+
const version = await remote(ensureRemoteClaude(config.claude) +
|
|
159
|
+
'umask 077; mkdir -p -- ' + remotePath(remoteDirectory) + '\n' +
|
|
160
|
+
'IFS= read -r line; printf \'%s\\n\' "$line" > ' + remotePath(configFile) + '\n' +
|
|
161
|
+
'cat > ' + remotePath(promptFile) + '\n' +
|
|
162
|
+
shellJoin([config.claude, '--version']) + ' </dev/null',
|
|
163
|
+
{ input: JSON.stringify(mcp) + '\n' + prompt, timeout: 600000 });
|
|
164
|
+
if (!supportsLocalMode(version.stdout)) throw new Error('本地工具模式需要服务器 Claude Code 2.1.248 或更高版本。请先在服务器运行 claude update。');
|
|
165
|
+
await browserReady;
|
|
88
166
|
client.debug(`本地执行模式:${root}\n文件、命令和浏览器在本机运行;项目文件不通过本模式同步。`);
|
|
89
|
-
const args = localClaudeArguments(path.posix.basename(configFile),
|
|
90
|
-
// Keep the parent shell so EXIT can remove the token-bearing temporary
|
|
167
|
+
const args = localClaudeArguments(path.posix.basename(configFile), path.posix.basename(promptFile), extra);
|
|
168
|
+
// Keep the parent shell so EXIT can remove the token-bearing temporary files.
|
|
91
169
|
const script = remoteEnvironment + 'cd -- ' + remotePath(remoteDirectory) + ' || exit 1; ' +
|
|
92
|
-
'trap ' + quote('rm -f -- ' + remotePath(configFile)) + ' EXIT; ' +
|
|
170
|
+
'trap ' + quote('rm -f -- ' + remotePath(configFile) + ' ' + remotePath(promptFile)) + ' EXIT; ' +
|
|
93
171
|
shellJoin([config.claude, ...args]);
|
|
94
|
-
|
|
172
|
+
const code = (await remote(script, { interactive: true, check: false })).code;
|
|
173
|
+
// A dropped connection leaves the remote UI's terminal modes switched on.
|
|
174
|
+
if (code !== 0) client.restoreTerminal();
|
|
175
|
+
return code;
|
|
95
176
|
} finally {
|
|
96
|
-
|
|
97
|
-
await
|
|
98
|
-
await tunnel?.close();
|
|
177
|
+
removeCleanup();
|
|
178
|
+
await cleanup();
|
|
99
179
|
}
|
|
100
180
|
}
|
package/src/remote-claude.js
CHANGED
|
@@ -10,15 +10,15 @@ export function ensureRemoteClaude(command) {
|
|
|
10
10
|
installer=$(mktemp "\${TMPDIR:-/tmp}/claude-remote-install.XXXXXXXX")
|
|
11
11
|
trap 'rm -f -- "$installer"' EXIT
|
|
12
12
|
if command -v curl >/dev/null 2>&1; then
|
|
13
|
-
curl -fSL --connect-timeout 20 --max-time 120 --retry 2 \\
|
|
13
|
+
curl -fSL </dev/null --connect-timeout 20 --max-time 120 --retry 2 \\
|
|
14
14
|
--output "$installer" https://claude.ai/install.sh
|
|
15
15
|
elif command -v wget >/dev/null 2>&1; then
|
|
16
|
-
wget --timeout=120 --tries=3 --output-document="$installer" https://claude.ai/install.sh
|
|
16
|
+
wget </dev/null --timeout=120 --tries=3 --output-document="$installer" https://claude.ai/install.sh
|
|
17
17
|
else
|
|
18
18
|
echo '自动安装需要服务器提供 curl 或 wget,请先安装其中一个工具。' >&2
|
|
19
19
|
exit 127
|
|
20
20
|
fi
|
|
21
|
-
if ! bash "$installer" >&2; then
|
|
21
|
+
if ! bash "$installer" >&2 </dev/null; then
|
|
22
22
|
echo 'Claude Code 官方安装失败,请检查上述输出、网络连接和目录权限后重试。' >&2
|
|
23
23
|
exit 1
|
|
24
24
|
fi
|
package/src/system.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import * as fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { randomUUID } from 'node:crypto';
|
|
6
7
|
import lockfile from 'proper-lockfile';
|
|
@@ -38,10 +39,73 @@ export async function writeJSON(file, value) {
|
|
|
38
39
|
} finally { await fs.rm(temp, { force: true }); }
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
// Children and cleanup callbacks that must not outlive this CLI when the
|
|
43
|
+
// terminal closes (SIGHUP) or the process is asked to stop (SIGTERM).
|
|
44
|
+
const trackedChildren = new Set();
|
|
45
|
+
const cleanups = new Set();
|
|
46
|
+
let terminating = null;
|
|
47
|
+
|
|
48
|
+
export function trackChild(child) {
|
|
49
|
+
trackedChildren.add(child);
|
|
50
|
+
child.once('close', () => trackedChildren.delete(child));
|
|
51
|
+
child.once('error', () => trackedChildren.delete(child));
|
|
52
|
+
return child;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function onCleanup(callback) {
|
|
56
|
+
cleanups.add(callback);
|
|
57
|
+
return () => cleanups.delete(callback);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function terminate(signal, { exit = code => process.exit(code), graceMs = 10000, terminal = process.stdout } = {}) {
|
|
61
|
+
if (terminating) return terminating;
|
|
62
|
+
const code = 128 + (os.constants.signals[signal] || 15);
|
|
63
|
+
terminating = (async () => {
|
|
64
|
+
// Cleanups first: they may still need live SSH connections (e.g. to delete
|
|
65
|
+
// remote temporary files). Whatever is left afterwards is stopped.
|
|
66
|
+
const pending = Promise.allSettled([...cleanups].map(callback => callback()));
|
|
67
|
+
let timer;
|
|
68
|
+
const limit = new Promise(resolve => { timer = setTimeout(resolve, graceMs); timer.unref?.(); });
|
|
69
|
+
await Promise.race([pending, limit]);
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
for (const child of trackedChildren) {
|
|
72
|
+
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
|
73
|
+
}
|
|
74
|
+
restoreTerminal(terminal);
|
|
75
|
+
exit(code);
|
|
76
|
+
})();
|
|
77
|
+
return terminating;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A remote full-screen program that loses its connection cannot switch off the
|
|
81
|
+
// terminal modes it enabled, so mouse moves, focus changes and keys show up as
|
|
82
|
+
// stray escape codes. Every sequence here only turns a mode off and is a no-op
|
|
83
|
+
// when the mode is already off. The alternate screen (?1049l) is deliberately
|
|
84
|
+
// left alone: leaving it when not active can move the cursor on some terminals.
|
|
85
|
+
export const TERMINAL_RESET = [
|
|
86
|
+
'\x1b[?1000l', '\x1b[?1002l', '\x1b[?1003l', '\x1b[?1005l', '\x1b[?1006l', '\x1b[?1015l', // mouse reporting
|
|
87
|
+
'\x1b[?1004l', // focus in/out events
|
|
88
|
+
'\x1b[?2004l', // bracketed paste
|
|
89
|
+
'\x1b[=0;1u', '\x1b[<99u', // kitty keyboard protocol: clear flags, pop pushed levels
|
|
90
|
+
'\x1b[>4m', // xterm modifyOtherKeys
|
|
91
|
+
'\x1b[?1l', '\x1b>', // normal cursor keys and keypad
|
|
92
|
+
'\x1b[0m', '\x1b[?25h' // plain text attributes, visible cursor
|
|
93
|
+
].join('');
|
|
94
|
+
|
|
95
|
+
export function restoreTerminal(stream = process.stdout) {
|
|
96
|
+
if (!stream?.isTTY) return false;
|
|
97
|
+
try { stream.write(TERMINAL_RESET); return true; }
|
|
98
|
+
catch { return false; } // The terminal may already be gone (SIGHUP).
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function installSignalHandlers() {
|
|
102
|
+
for (const signal of ['SIGHUP', 'SIGTERM']) process.on(signal, () => { terminate(signal); });
|
|
103
|
+
}
|
|
104
|
+
|
|
41
105
|
export function execute(command, args = [], { capture = false, timeout = 0, check = true, env = process.env, input } = {}) {
|
|
42
106
|
return new Promise((resolve, reject) => {
|
|
43
|
-
const child = spawn(command, args, { stdio: [input !== undefined ? 'pipe' : capture ? 'ignore' : 'inherit',
|
|
44
|
-
capture ? 'pipe' : 'inherit', capture ? 'pipe' : 'inherit'], env });
|
|
107
|
+
const child = trackChild(spawn(command, args, { stdio: [input !== undefined ? 'pipe' : capture ? 'ignore' : 'inherit',
|
|
108
|
+
capture ? 'pipe' : 'inherit', capture ? 'pipe' : 'inherit'], env }));
|
|
45
109
|
if (input !== undefined) {
|
|
46
110
|
child.stdin.on('error', () => {}); // An early remote failure is reported by the exit status.
|
|
47
111
|
child.stdin.end(input);
|