@znmin/claude-remote-cli 0.2.4 → 0.2.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # claude-remote-cli
2
2
 
3
- 在项目目录运行 `claude`,让 Claude Code 主进程运行在 SSH 服务器,文件、终端和浏览器操作在本机执行;也保留服务器执行和目录双向同步模式。
3
+ 在项目目录运行 `claude`,让 Claude Code 主进程运行在 SSH 服务器,文件、终端和交互式浏览器操作在本机执行,普通公开资料搜索与网页读取使用服务器原生工具;也保留服务器执行和目录双向同步模式。
4
4
 
5
5
  本项目是独立的 SSH/同步封装,不是 Anthropic 官方客户端。它不提供服务器、Claude 账号或 API 服务。
6
6
 
@@ -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.4.tgz
28
+ npm install -g ./znmin-claude-remote-cli-0.2.6.tgz
29
29
  ```
30
30
 
31
- 若系统 npm 全局目录不可写,可使用 `npm install -g --prefix "$HOME/.local" ./znmin-claude-remote-cli-0.2.4.tgz`,并确保 `~/.local/bin` 在 PATH 中;卸载时也使用相同的 `--prefix`。
31
+ 若系统 npm 全局目录不可写,可使用 `npm install -g --prefix "$HOME/.local" ./znmin-claude-remote-cli-0.2.6.tgz`,并确保 `~/.local/bin` 在 PATH 中;卸载时也使用相同的 `--prefix`。
32
32
 
33
33
  npm 中不带作用域的 `claude-remote-cli` 属于其他项目。本项目使用独立的 `@znmin/claude-remote-cli` 作用域包名;请安装完整包名,或使用上述 GitHub 地址及本项目分发的 `.tgz` 文件。
34
34
 
@@ -117,11 +117,21 @@ claude-remote configure my-server --mode local
117
117
 
118
118
  切换模式不会自动停止已有后台同步。若从旧模式切换且不希望旧项目继续同步,先运行 `claude-remote stop --all`。本地模式本身不创建同步会话,不上传整个项目。
119
119
 
120
- 无需用户手工配置 MCP,默认提供 18 个工具:
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
+ - 启动时浏览器检查与 SSH 连接并行,浏览器已就绪时不再调用 Playwright 安装程序;服务器上的版本检查和配置写入合并为一次往返。
125
135
 
126
136
  Git、npm、pnpm、Python、Go、Docker、数据库 CLI、`gh` 等共用本地终端,不需要逐项适配,仍需在本机安装并具备必要的登录、连接配置及权限。终端继承启动用户环境和登录 shell,默认工作目录为当前项目。长任务使用后台进程,再查询日志和停止;退出会话会停止仍受本工具管理的进程组,自行 daemonize 或逃离原进程组的程序不在自动清理保证内。
127
137
 
@@ -129,6 +139,8 @@ Git、npm、pnpm、Python、Go、Docker、数据库 CLI、`gh` 等共用本地
129
139
 
130
140
  文件工具限定项目内,拒绝路径穿越和指向项目外的符号链接;修改已有文件需要精确旧文本或读取时的 SHA-256。终端具有启动用户的正常系统权限,项目工作目录不是操作系统沙箱。本地模式可使用本机 Git worktree。
131
141
 
142
+ 0.2.5 起,普通公开资料查询优先使用服务器上的 Claude 原生 `WebSearch` / `WebFetch`,不打开本地浏览器;服务器的文件、终端和浏览器执行工具仍不开放。原生搜索/网页读取是否可用取决于服务器 Claude 的账号、服务商和策略;如果不可用或查询失败,会先说明并征求用户意见,再回退本地浏览器。本地开发页面、localhost/局域网地址、交互测试、截图和交互登录仍使用本地浏览器,不交给服务器 WebFetch。该选择通过工具白名单及提示词约定实现,不是对模型每次路由选择的硬性拦截。
143
+
132
144
  浏览器使用独立窗口和临时上下文,不读取日常 Chrome 的登录态。启动本地模式时准备相应浏览器版本,后续使用缓存;收到浏览器工具调用时才打开窗口。可以打开本机 localhost,填写表单、点击、截图和读取控制台错误。
133
145
 
134
146
  ```bash
@@ -142,7 +154,9 @@ Linux 上仍需安装 Playwright 所需的系统共享库,有窗口模式还
142
154
 
143
155
  本地模式要求服务器允许 SSH TCP 转发,Claude Code 版本至少 **2.1.248**;已有版本过旧时会提示在服务器运行 `claude update`。每个会话创建仅监听本机回环地址的工具服务、随机令牌和 SSH 反向隧道;令牌通过 SSH 标准输入写入服务器 0600 临时配置,不出现在命令行参数或同步项目中。退出时清理配置、连接和浏览器。
144
156
 
145
- 启动时使用 `--restricted`、`--tools ""`、`--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` 和输入/输出格式;暂不接受替换工具配置、加载插件、切换云会话等参数。
157
+ 启动时使用 `--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`)。暂不接受替换工具配置、加载插件、切换云会话等参数。
158
+
159
+ 本地模式的路由规则、本地 CLAUDE.md 和用户传入的 `--append-system-prompt` 内容合并为一个文件,通过 SSH 标准输入写入服务器临时目录(权限 0600),再用 `--append-system-prompt-file` 加载;用户的追加内容排在路由规则之后,不会替换它。退出时与 MCP 配置一起删除。
146
160
 
147
161
  这是工具路由约束,不是对 Claude Code 主进程的系统隔离。认证、会话和日志仍在服务器,管理员管理策略继续生效。文件内容、命令结果和截图会经过服务器交给模型,不整体同步不代表数据不离开本机。
148
162
 
@@ -154,7 +168,7 @@ claude-remote flush # 等待当前项目完成一次同步
154
168
  claude-remote stop # 停止当前目录在当前配置服务器上的同步
155
169
  claude-remote stop --all # 停止本工具的所有同步会话
156
170
  claude-remote stop --name 名称 # 按 status 中的名称停止旧服务器上的映射
157
- claude-remote doctor # 检查本地安装和服务器配置,不连接服务器
171
+ claude-remote doctor # 检查本地安装(含 Node 版本、SSH 工具、本地浏览器缓存)和服务器配置,不连接服务器
158
172
  claude-remote install-sync # 手动重试同步工具准备
159
173
  claude-remote --help
160
174
  ```
@@ -214,13 +228,14 @@ CLAUDE_REMOTE_DEBUG=1 claude
214
228
 
215
229
  ```bash
216
230
  npm ci --ignore-scripts
231
+ npm run lint
217
232
  npm test
218
233
 
219
234
  # 可选:真实本地同步与受控浏览器测试
220
235
  CLAUDE_REMOTE_INTEGRATION=1 CLAUDE_REMOTE_BROWSER_TEST=1 npm test
221
236
 
222
237
  npm pack
223
- npm install -g ./znmin-claude-remote-cli-0.2.4.tgz
238
+ npm install -g ./znmin-claude-remote-cli-0.2.6.tgz
224
239
 
225
240
  # 确定包名、版本和使用条款后,使用有发布权限的 npm 账号发布
226
241
  npm login
@@ -229,6 +244,6 @@ npm publish --access public
229
244
 
230
245
  GitHub 仓库名为 `claude-remote-cli`,npm 包名为 `@znmin/claude-remote-cli`,命令名为 `claude` 和 `claude-remote`。发布账号必须拥有 npm 用户作用域 `@znmin`,或拥有同名 npm 组织的发布权限;GitHub 身份不会自动授予 npm 权限。当前 `license` 为 `UNLICENSED`,尚未指定开源许可证。
231
246
 
232
- 测试覆盖真实 MCP HTTP 调用、本机文件与进程、受控浏览器交互、本地真实同步、模拟 SSH/Claude 的完整启动清理链路,以及官方安装流程的隔离模拟。尚未提供服务器,真实 SSH 认证、Claude 模型端到端调用、Linux 和其他 CPU 架构尚未实测。
247
+ 测试覆盖真实 MCP HTTP 调用、本机文件与进程、受控浏览器交互、本地真实同步、模拟 SSH/Claude 的完整启动清理链路,以及官方安装流程的隔离模拟。真实服务器完整启动、Claude 模型端到端调用、Linux 和其他 CPU 架构尚未完成实测。
233
248
 
234
249
  参考:[npm 命令入口配置](https://docs.npmjs.com/cli/configuring-npm/package-json#bin)、[Mutagen 同步机制](https://mutagen.io/documentation/synchronization/)、[Mutagen SSH 支持](https://mutagen.io/documentation/transports/ssh/)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@znmin/claude-remote-cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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
- console.log(`Node.js:${process.version}\n平台:${process.platform}/${process.arch}`);
122
- for (const tool of ['ssh', 'scp', 'tar']) console.log(`${tool}:${await which(tool, client.env.PATH) || '未安装'}`);
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);
@@ -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
- export async function installBrowser(log = console.error, { debug = () => {} } = {}) {
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
- const lock = path.join(process.env.CLAUDE_REMOTE_CACHE_DIR || path.join(os.homedir(), '.cache', 'claude-remote'), 'browser-install');
16
- await withLock(lock, async () => {
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(); this.reference = 0;
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 > 100) entries.shift();
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), generation = ++this.reference;
68
- const state = await page.evaluate(generation => {
69
- const elements = [...document.querySelectorAll('a,button,input,textarea,select,[role="button"],[contenteditable="true"]')]
70
- .filter(element => element.getClientRects().length).slice(0, 200).map((element, i) => {
71
- const ref = `r${generation}-${i}`;
72
- element.setAttribute('data-claude-remote-ref', ref);
73
- return { selector: `[data-claude-remote-ref="${ref}"]`, tag: element.tagName.toLowerCase(),
74
- text: (element.innerText || element.getAttribute('aria-label') || element.getAttribute('placeholder') || '').slice(0, 200),
75
- type: element.getAttribute('type') || undefined };
76
- });
77
- return { text: (document.body?.innerText || '').slice(0, 12000), elements };
78
- }, generation);
79
- return { page_id: id, url: page.url(), title: await page.title(), ...state };
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
- if (action === 'click') await page.locator(selector).click({ timeout: 15000 });
85
- else if (action === 'fill') await page.locator(selector).fill(value, { timeout: 15000 });
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 page.locator(selector).press(value, { timeout: 15000 });
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 screenshot(id) {
94
- const buffer = await this.page(id).screenshot({ type: 'png', timeout: 15000 });
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 };
@@ -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
- status(id) {
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), stdout: job.stdout.slice(-8000), stderr: job.stderr.slice(-8000),
16
- output_truncated: job.truncated || job.stdout.length > 8000 || job.stderr.length > 8000, cwd: job.cwd };
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, stdout: '', stderr: '', truncated: false, ended: false };
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
- if (job[stream].length + chunk.length > LIMIT) job.truncated = true;
34
- job[stream] = (job[stream] + chunk).slice(-LIMIT);
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.jobs.get(id);
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() { return [...this.jobs.keys()].map(id => {
72
- const { stdout, stderr, ...status } = this.status(id);
73
- return { ...status, stdout_characters: this.jobs.get(id).stdout.length, stderr_characters: this.jobs.get(id).stderr.length };
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
  }
@@ -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 text = value => {
13
- const output = JSON.stringify(value);
14
- // Claude persists very large MCP results on the server; local mode cannot use its Read tool.
15
- const bounded = output.length <= 40000 ? output : JSON.stringify({ truncated: true,
16
- message: '结果过大,请缩小查询范围或分页读取。本次只返回预览。', preview: output.slice(0, 16000) });
17
- return { content: [{ type: 'text', text: bounded }] };
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: '0.2.0' });
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,20 +73,23 @@ 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 literal text in LOCAL project files. Skips .git, node_modules, .venv, binary and large files.', {
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), true);
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 bounded stdout/stderr from a process started by this session.', { job_id: z.string() },
65
- args => processes.status(args.job_id), true);
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));
69
- register('browser_open', 'Open a page in a dedicated LOCAL browser. localhost is the developer computer. Does not attach to existing browser profiles.', {
92
+ register('browser_open', 'Open a page in a dedicated LOCAL browser for local development, interactive UI testing, screenshots or interactive sign-in. For ordinary public research use server WebSearch/WebFetch; ask the user before falling back to this browser. localhost is the developer computer. Does not attach to existing browser profiles.', {
70
93
  url: z.string().url().or(z.literal('about:blank')).default('about:blank')
71
94
  }, args => browser.open(args.url));
72
95
  register('browser_snapshot', 'Inspect a LOCAL browser page and get visible text and selectors for interactive elements.', { page_id: pageId },
@@ -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('browser_screenshot', 'Capture the visible viewport of a LOCAL page as an image.', { page_id: pageId },
82
- args => browser.screenshot(args.page_id), true);
83
- register('browser_logs', 'Read recent browser console messages, page errors and failed network requests.', { page_id: pageId },
84
- args => { browser.page(args.page_id); const logs = browser.logs.get(args.page_id); return { logs: logs.slice(-20), truncated: logs.length > 20 }; }, true);
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
  }
@@ -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(); });
@@ -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
- return { path: path.relative(this.root, target), sha256: hash(content), total_lines: lines.length,
39
- content: lines.slice(offset - 1, offset - 1 + limit).map((line, i) => `${offset + i}: ${line}`).join('\n') };
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
- async search(pattern, directory = '.', maxResults = 100, caseSensitive = true) {
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 = false;
90
- const needle = caseSensitive ? pattern : pattern.toLowerCase();
91
- const walk = async target => {
92
- if (results.length >= maxResults || scanned >= 10000) { truncated = true; return; }
93
- const stat = await fs.lstat(target);
94
- if (stat.isSymbolicLink()) return;
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
- if (text.includes('\0')) return;
106
- for (const [i, line] of text.split('\n').entries()) {
107
- if ((caseSensitive ? line : line.toLowerCase()).includes(needle)) {
108
- results.push({ path: path.relative(this.root, target), line: i + 1, text: line.slice(0, 1000) });
109
- if (results.length >= maxResults) { truncated = true; break; }
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
- await walk(root);
115
- return { results, scanned_files: scanned, truncated };
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,27 +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 (argument.includes('=')) continue;
31
- if (name === '--resume' || name === '-r') { if (args[i + 1] && !args[i + 1].startsWith('-')) i++; continue; }
32
- if (args[i + 1] === undefined || args[i + 1].startsWith('-')) throw new Error(`${name} 缺少参数;以短横线开头的值请使用 ${name}=值。`);
33
- i++; continue;
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
- export function localClaudeArguments(configFile, root, extra) {
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);
41
- return ['--restricted', '--tools', '', '--strict-mcp-config', '--mcp-config', configFile,
107
+ const { rest } = extractAppendedPrompts(extra);
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', `All project operations run on the developer's LOCAL computer through the local MCP server. ` +
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
- 'Read LOCAL CLAUDE.md with read_file if present. Use background commands for development servers, inspect their logs, and stop owned processes when finished. ' +
50
- 'If the local connection fails, report it and stop tool work; never fall back to server tools.', ...extra];
113
+ '--append-system-prompt-file', promptFile, ...rest];
51
114
  }
52
115
 
53
116
  export function supportsLocalMode(output) {
@@ -62,36 +125,53 @@ export async function launchLocal(client, config, extra, dependencies = {}) {
62
125
  if (!await which('ssh', client.env.PATH)) throw new Error('找不到系统 SSH 客户端。');
63
126
  const root = await fs.realpath(client.cwd), home = await fs.realpath(client.home);
64
127
  if (root === home || root === path.parse(root).root) throw new Error('请进入具体项目目录,不允许以用户目录或磁盘根目录作为项目。');
65
- const prepareBrowser = dependencies.installBrowser || installBrowser;
66
- await prepareBrowser(client.log, { debug: client.debug });
67
- const gateway = await (dependencies.startLocalServer || startLocalServer)({ root, env: client.env,
68
- headless: client.env.CLAUDE_REMOTE_HEADLESS === undefined ? Boolean(config.browserHeadless) : client.env.CLAUDE_REMOTE_HEADLESS === '1', log: client.log });
69
- 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;
70
133
  const hash = createHash('sha256').update(config.client_id + '\0' + root).digest('hex').slice(0, 24);
71
134
  const remoteDirectory = config.root + '/local-' + hash;
72
135
  const remote = (script, { interactive = false, input, capture = !interactive, timeout = 30000, check = true } = {}) =>
73
136
  client.execute('ssh', sshArgs(config, script, { interactive, tty: interactive && client.tty,
74
137
  input: input !== undefined, controlPath: tunnel.controlPath }), { env: client.env, capture, input,
75
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);
76
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 });
77
149
  tunnel = await (dependencies.openTunnel || openTunnel)(config, gateway.port, { env: client.env, run: client.execute });
78
- const version = await remote(ensureRemoteClaude(config.claude) + shellJoin([config.claude, '--version']), { timeout: 600000 });
79
- if (!supportsLocalMode(version.stdout)) throw new Error('本地工具模式需要服务器 Claude Code 2.1.248 或更高版本。请先在服务器运行 claude update。');
80
- configFile = remoteDirectory + '/.local-mcp-' + randomUUID() + '.json';
150
+ const session = randomUUID();
151
+ configFile = remoteDirectory + '/.local-mcp-' + session + '.json';
152
+ promptFile = remoteDirectory + '/.local-prompt-' + session + '.md';
81
153
  const mcp = { mcpServers: { local: { type: 'http', url: `http://127.0.0.1:${tunnel.port}/mcp`,
82
154
  headers: { Authorization: 'Bearer ' + gateway.token }, timeout: 180000 } } };
83
- await remote('set -e; umask 077; mkdir -p -- ' + remotePath(remoteDirectory) + '; cat > ' + remotePath(configFile),
84
- { input: JSON.stringify(mcp) });
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;
85
166
  client.debug(`本地执行模式:${root}\n文件、命令和浏览器在本机运行;项目文件不通过本模式同步。`);
86
- const args = localClaudeArguments(path.posix.basename(configFile), root, extra);
87
- // Keep the parent shell so EXIT can remove the token-bearing temporary config.
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.
88
169
  const script = remoteEnvironment + 'cd -- ' + remotePath(remoteDirectory) + ' || exit 1; ' +
89
- 'trap ' + quote('rm -f -- ' + remotePath(configFile)) + ' EXIT; ' +
170
+ 'trap ' + quote('rm -f -- ' + remotePath(configFile) + ' ' + remotePath(promptFile)) + ' EXIT; ' +
90
171
  shellJoin([config.claude, ...args]);
91
172
  return (await remote(script, { interactive: true, check: false })).code;
92
173
  } finally {
93
- if (tunnel && configFile) await remote('rm -f -- ' + remotePath(configFile), { timeout: 5000, check: false }).catch(() => {});
94
- await gateway.close();
95
- await tunnel?.close();
174
+ removeCleanup();
175
+ await cleanup();
96
176
  }
97
177
  }
@@ -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,51 @@ 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 } = {}) {
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
+ exit(code);
75
+ })();
76
+ return terminating;
77
+ }
78
+
79
+ export function installSignalHandlers() {
80
+ for (const signal of ['SIGHUP', 'SIGTERM']) process.on(signal, () => { terminate(signal); });
81
+ }
82
+
41
83
  export function execute(command, args = [], { capture = false, timeout = 0, check = true, env = process.env, input } = {}) {
42
84
  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 });
85
+ const child = trackChild(spawn(command, args, { stdio: [input !== undefined ? 'pipe' : capture ? 'ignore' : 'inherit',
86
+ capture ? 'pipe' : 'inherit', capture ? 'pipe' : 'inherit'], env }));
45
87
  if (input !== undefined) {
46
88
  child.stdin.on('error', () => {}); // An early remote failure is reported by the exit status.
47
89
  child.stdin.end(input);