@znmin/claude-remote-cli 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/local-mode.js CHANGED
@@ -1,182 +1,230 @@
1
- import * as fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { createHash, randomUUID } from 'node:crypto';
4
- import { onCleanup, quote, shellJoin, which } from './system.js';
5
- import { sshArgs } from './ssh.js';
6
- import { remotePath } from './client.js';
7
- import { ensureRemoteClaude, remoteEnvironment } from './remote-claude.js';
8
- import { startLocalServer } from './local/server.js';
9
- import { installBrowser } from './local/browser.js';
10
- import { openTunnel } from './local/tunnel.js';
11
-
12
- const BOOLEAN_FLAGS = new Set(['--continue', '-c', '--print', '-p', '--verbose', '--no-session-persistence',
13
- '--include-partial-messages', '--replay-user-messages']);
14
- const VALUE_FLAGS = new Set(['--model', '--effort', '--max-turns', '--max-budget-usd', '--output-format',
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']);
18
- const SUBCOMMANDS = new Set(['auth', 'mcp', 'install', 'update', 'doctor', 'agents', 'attach', 'gateway',
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;
23
-
24
- export function validateLocalArguments(args) {
25
- for (let i = 0; i < args.length; i++) {
26
- const argument = args[i];
27
- if (argument === '--') break;
28
- if (!argument.startsWith('-')) {
29
- if (SUBCOMMANDS.has(argument)) throw new Error('本地模式不执行服务器管理子命令;请用 SSH 管理服务器上的 Claude。');
30
- continue;
31
- }
32
- const name = argument.split('=')[0];
33
- if (BOOLEAN_FLAGS.has(name) && argument === name) continue;
34
- if (VALUE_FLAGS.has(name) || name === '--resume' || name === '-r') {
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;
48
- }
49
- throw new Error(`本地模式暂不支持参数 ${name};工具配置由本地模式统一管理。`);
50
- }
51
- }
52
-
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 = [], platform = 'posix' } = {}) {
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
- (platform === 'win32' ? ' The LOCAL computer runs Windows while the server runs Linux: use Windows paths for local tools and check workspace_info for the shell used by run_command.' : '')];
101
- if (instructions) parts.push('# Project instructions (LOCAL)\n\n' + instructions);
102
- if (appended.length) parts.push('# Additional instructions from the user\n\n' + appended.join('\n\n'));
103
- return parts.join('\n\n');
104
- }
105
-
106
- export function localClaudeArguments(configFile, promptFile, extra) {
107
- validateLocalArguments(extra);
108
- const { rest } = extractAppendedPrompts(extra);
109
- return ['--restricted', '--tools', 'WebSearch,WebFetch', '--strict-mcp-config', '--mcp-config', configFile,
110
- // Keep built-in UI commands (/config, /model, /help) available. Disable
111
- // skill shell expansion separately instead of disabling the entire menu.
112
- '--setting-sources', '', '--settings', JSON.stringify({ disableAllHooks: true,
113
- disableSkillShellExecution: true, disableBundledSkills: true, enabledPlugins: {} }),
114
- '--append-system-prompt-file', promptFile, ...rest];
115
- }
116
-
117
- export function supportsLocalMode(output) {
118
- const match = output.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
119
- if (!match) return false;
120
- const [major, minor, patch] = match.slice(1).map(Number);
121
- return major > 2 || major === 2 && (minor > 1 || minor === 1 && patch >= 248);
122
- }
123
-
124
- export async function launchLocal(client, config, extra, dependencies = {}) {
125
- validateLocalArguments(extra);
126
- if (!await which('ssh', client.env.PATH)) throw new Error('找不到系统 SSH 客户端。');
127
- const root = await fs.realpath(client.cwd), home = await fs.realpath(client.home);
128
- if (root === home || root === path.parse(root).root) throw new Error('请进入具体项目目录,不允许以用户目录或磁盘根目录作为项目。');
129
- const prompt = localPrompt(root, { instructions: await projectInstructions(root), appended: extractAppendedPrompts(extra).prompts,
130
- platform: client.platform });
131
- // Prepare the browser while SSH connects; it is only needed before Claude starts.
132
- const browserReady = (dependencies.installBrowser || installBrowser)(client.log, { debug: client.debug, env: client.env });
133
- browserReady.catch(() => {}); // Reported when awaited below.
134
- let gateway, tunnel, configFile, promptFile, cleaning;
135
- const hash = createHash('sha256').update(config.client_id + '\0' + root).digest('hex').slice(0, 24);
136
- const remoteDirectory = config.root + '/local-' + hash;
137
- const remote = (script, { interactive = false, input, capture = !interactive, timeout = 30000, check = true } = {}) =>
138
- client.execute('ssh', sshArgs(config, script, { interactive, tty: interactive && client.tty,
139
- input: input !== undefined, controlPath: tunnel.controlPath }), { env: client.env, capture, input,
140
- timeout: interactive ? 0 : timeout, check });
141
- const cleanup = () => cleaning ||= (async () => {
142
- if (tunnel && configFile)
143
- await remote('rm -f -- ' + remotePath(configFile) + ' ' + remotePath(promptFile), { timeout: 5000, check: false }).catch(() => {});
144
- await gateway?.close();
145
- await tunnel?.close();
146
- })();
147
- const removeCleanup = onCleanup(cleanup);
148
- try {
149
- gateway = await (dependencies.startLocalServer || startLocalServer)({ root, env: client.env,
150
- headless: client.env.CLAUDE_REMOTE_HEADLESS === undefined ? Boolean(config.browserHeadless) : client.env.CLAUDE_REMOTE_HEADLESS === '1', log: client.log });
151
- tunnel = await (dependencies.openTunnel || openTunnel)(config, gateway.port, { env: client.env, run: client.execute });
152
- const session = randomUUID();
153
- configFile = remoteDirectory + '/.local-mcp-' + session + '.json';
154
- promptFile = remoteDirectory + '/.local-prompt-' + session + '.md';
155
- const mcp = { mcpServers: { local: { type: 'http', url: `http://127.0.0.1:${tunnel.port}/mcp`,
156
- headers: { Authorization: 'Bearer ' + gateway.token }, timeout: 180000 } } };
157
- // One round trip: install Claude if missing, store the token-bearing MCP
158
- // config (first stdin line) and the prompt (the rest), then report the version.
159
- // `read` consumes the pipe byte by byte, so nothing is lost between the two files.
160
- const version = await remote(ensureRemoteClaude(config.claude) +
161
- 'umask 077; mkdir -p -- ' + remotePath(remoteDirectory) + '\n' +
162
- 'IFS= read -r line; printf \'%s\\n\' "$line" > ' + remotePath(configFile) + '\n' +
163
- 'cat > ' + remotePath(promptFile) + '\n' +
164
- shellJoin([config.claude, '--version']) + ' </dev/null',
165
- { input: JSON.stringify(mcp) + '\n' + prompt, timeout: 600000 });
1
+ import * as fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { onCleanup, quote, shellJoin, which } from './system.js';
5
+ import { sshArgs } from './ssh.js';
6
+ import { remotePath } from './client.js';
7
+ import { ensureRemoteClaude, remoteEnvironment } from './remote-claude.js';
8
+ import { ALT_V_BINDING, ensureAltVBinding, installClipboardShim, KEYBINDING_MISSING } from './remote-clipboard.js';
9
+ import { startLocalServer } from './local/server.js';
10
+ import { installBrowser } from './local/browser.js';
11
+ import { openTunnel } from './local/tunnel.js';
12
+ import { approveServers, describeServer, loadServers, McpStore } from './local/mcp-config.js';
13
+ import { CALL_TIMEOUT, closeUpstreams, connectUpstreams } from './local/mcp-proxy.js';
14
+ import { createInterface } from 'node:readline/promises';
15
+
16
+ const BOOLEAN_FLAGS = new Set(['--continue', '-c', '--print', '-p', '--verbose', '--no-session-persistence',
17
+ '--include-partial-messages', '--replay-user-messages']);
18
+ const VALUE_FLAGS = new Set(['--model', '--effort', '--max-turns', '--max-budget-usd', '--output-format',
19
+ '--input-format', '--session-id', '--append-system-prompt', '--permission-mode', '--name', '-n', '--fallback-model']);
20
+ // --restricted refuses bypassPermissions anyway; reject it before connecting.
21
+ const PERMISSION_MODES = new Set(['default', 'manual', 'acceptEdits', 'plan', 'auto', 'dontAsk']);
22
+ const SUBCOMMANDS = new Set(['auth', 'mcp', 'install', 'update', 'doctor', 'agents', 'attach', 'gateway',
23
+ 'daemon', 'remote-control', 'rc', 'desktop', 'setup-token']);
24
+ // Project instruction files read from the LOCAL project root, in Claude Code's order.
25
+ const INSTRUCTION_FILES = ['CLAUDE.md', path.join('.claude', 'CLAUDE.md'), 'CLAUDE.local.md'];
26
+ const INSTRUCTION_LIMIT = 64 * 1024;
27
+
28
+ export function validateLocalArguments(args) {
29
+ for (let i = 0; i < args.length; i++) {
30
+ const argument = args[i];
31
+ if (argument === '--') break;
32
+ if (!argument.startsWith('-')) {
33
+ if (argument === 'mcp') throw new Error('本地模式的 MCP 由本机管理,请使用 claude-remote mcp。');
34
+ if (SUBCOMMANDS.has(argument)) throw new Error('本地模式不执行服务器管理子命令;请用 SSH 管理服务器上的 Claude。');
35
+ continue;
36
+ }
37
+ const name = argument.split('=')[0];
38
+ if (BOOLEAN_FLAGS.has(name) && argument === name) continue;
39
+ if (VALUE_FLAGS.has(name) || name === '--resume' || name === '-r') {
40
+ if (name === '--resume' || name === '-r') {
41
+ if (!argument.includes('=') && args[i + 1] && !args[i + 1].startsWith('-')) i++;
42
+ continue;
43
+ }
44
+ let value;
45
+ if (argument.includes('=')) value = argument.slice(argument.indexOf('=') + 1);
46
+ else {
47
+ if (args[i + 1] === undefined || args[i + 1].startsWith('-')) throw new Error(`${name} 缺少参数;以短横线开头的值请使用 ${name}=值。`);
48
+ value = args[++i];
49
+ }
50
+ if (name === '--permission-mode' && !PERMISSION_MODES.has(value))
51
+ throw new Error(`本地模式的 --permission-mode 仅支持 ${[...PERMISSION_MODES].join('、')}。`);
52
+ continue;
53
+ }
54
+ throw new Error(`本地模式暂不支持参数 ${name};工具配置由本地模式统一管理。`);
55
+ }
56
+ }
57
+
58
+ // Pull --append-system-prompt values out of the forwarded arguments. They are
59
+ // merged into the single prompt file so they cannot replace the routing rules.
60
+ export function extractAppendedPrompts(args) {
61
+ const rest = [], prompts = [];
62
+ for (let i = 0; i < args.length; i++) {
63
+ const argument = args[i];
64
+ if (argument === '--') { rest.push(...args.slice(i)); break; }
65
+ if (argument === '--append-system-prompt') { prompts.push(args[++i]); continue; }
66
+ if (argument.startsWith('--append-system-prompt=')) { prompts.push(argument.slice(argument.indexOf('=') + 1)); continue; }
67
+ rest.push(argument);
68
+ if (VALUE_FLAGS.has(argument) && i + 1 < args.length) rest.push(args[++i]);
69
+ }
70
+ return { rest, prompts };
71
+ }
72
+
73
+ export async function projectInstructions(root) {
74
+ const sections = [];
75
+ let remaining = INSTRUCTION_LIMIT;
76
+ for (const name of INSTRUCTION_FILES) {
77
+ if (remaining <= 0) break;
78
+ let content;
79
+ try {
80
+ const file = await fs.realpath(path.join(root, name));
81
+ if (file !== root && !file.startsWith(root + path.sep)) continue; // Never follow links out of the project.
82
+ content = await fs.readFile(file, 'utf8');
83
+ } catch (error) {
84
+ if (['ENOENT', 'ENOTDIR', 'EISDIR'].includes(error.code)) continue;
85
+ throw error;
86
+ }
87
+ if (!content.trim()) continue;
88
+ const clipped = content.length > remaining ? content.slice(0, remaining) + `\n…[已截断,完整内容请用 read_file 读取 ${name}]` : content;
89
+ remaining -= clipped.length;
90
+ sections.push(`Contents of LOCAL ${name}:\n\n${clipped}`);
91
+ }
92
+ return sections.join('\n\n');
93
+ }
94
+
95
+ export function localPrompt(root, { instructions = '', appended = [], platform = 'posix', mcpServers = [] } = {}) {
96
+ const parts = [`All project operations run on the developer's LOCAL computer through the local MCP server. ` +
97
+ `The LOCAL project root is ${JSON.stringify(root)}. The server working directory is only for session metadata, not project files. ` +
98
+ 'Call workspace_info before working. Use only local tools for file access, shell commands and browser operations. ' +
99
+ '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. ' +
100
+ 'Use the LOCAL browser for local development pages, localhost/loopback or developer LAN addresses, interactive UI testing, screenshots, or pages requiring interactive sign-in. ' +
101
+ '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. ' +
102
+ '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. ' +
103
+ 'Use background commands for development servers, inspect their logs, and stop owned processes when finished. ' +
104
+ 'If the local connection fails, report it and stop local project operations; never fall back to server filesystem, shell or browser tools.' +
105
+ (platform === 'win32' ? ' The LOCAL computer runs Windows while the server runs Linux: use Windows paths for local tools and check workspace_info for the shell used by run_command.' : '')];
106
+ if (mcpServers.length) parts.push('# Developer MCP servers (LOCAL)\n\n' +
107
+ 'These MCP servers come from the developer\'s configuration. They run on, or connect from, the LOCAL computer, ' +
108
+ 'so they can reach the developer\'s intranet and local services. Use their tools directly (named mcp__<server>__<tool>); ' +
109
+ 'do not try to reach those services with server-side WebFetch.\n\n' + mcpServers.map(name => '- ' + name).join('\n'));
110
+ if (instructions) parts.push('# Project instructions (LOCAL)\n\n' + instructions);
111
+ if (appended.length) parts.push('# Additional instructions from the user\n\n' + appended.join('\n\n'));
112
+ return parts.join('\n\n');
113
+ }
114
+
115
+ export function localClaudeArguments(configFile, promptFile, extra) {
116
+ validateLocalArguments(extra);
117
+ const { rest } = extractAppendedPrompts(extra);
118
+ return ['--restricted', '--tools', 'WebSearch,WebFetch', '--strict-mcp-config', '--mcp-config', configFile,
119
+ // Keep built-in UI commands (/config, /model, /help) available. Disable
120
+ // skill shell expansion separately instead of disabling the entire menu.
121
+ '--setting-sources', '', '--settings', JSON.stringify({ disableAllHooks: true,
122
+ disableSkillShellExecution: true, disableBundledSkills: true, enabledPlugins: {} }),
123
+ '--append-system-prompt-file', promptFile, ...rest];
124
+ }
125
+
126
+ export function supportsLocalMode(output) {
127
+ const match = output.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
128
+ if (!match) return false;
129
+ const [major, minor, patch] = match.slice(1).map(Number);
130
+ return major > 2 || major === 2 && (minor > 1 || minor === 1 && patch >= 248);
131
+ }
132
+
133
+ // Project .mcp.json entries start local programs; ask before the first use.
134
+ async function confirmServer(server) {
135
+ const prompt = createInterface({ input: process.stdin, output: process.stderr });
136
+ try {
137
+ const answer = await prompt.question(`项目 .mcp.json 中的 MCP「${server.name}」将在本机运行或从本机连接:\n ${describeServer(server.config)}\n是否启用?[y/N] `);
138
+ return /^y(es)?$/i.test(answer.trim());
139
+ } finally { prompt.close(); }
140
+ }
141
+
142
+ export async function launchLocal(client, config, extra, dependencies = {}) {
143
+ validateLocalArguments(extra);
144
+ if (!await which('ssh', client.env.PATH)) throw new Error('找不到系统 SSH 客户端。');
145
+ const root = await fs.realpath(client.cwd), home = await fs.realpath(client.home);
146
+ if (root === home || root === path.parse(root).root) throw new Error('请进入具体项目目录,不允许以用户目录或磁盘根目录作为项目。');
147
+ const store = new McpStore({ directory: client.directory, lockFile: client.lockFile, root });
148
+ const loaded = await (dependencies.loadServers || loadServers)(store);
149
+ for (const warning of loaded.warnings) client.warn(warning);
150
+ const servers = await approveServers(store, loaded.servers,
151
+ { confirm: client.tty ? dependencies.confirmServer || confirmServer : null, warn: client.warn });
152
+ // Prepare the browser while SSH connects; it is only needed before Claude starts.
153
+ const browserReady = (dependencies.installBrowser || installBrowser)(client.log, { debug: client.debug, env: client.env });
154
+ browserReady.catch(() => {}); // Reported when awaited below.
155
+ let gateway, tunnel, configFile, promptFile, clipboardDirectory, cleaning;
156
+ const upstreams = new Map(), abort = new AbortController();
157
+ let connecting = Promise.resolve();
158
+ const hash = createHash('sha256').update(config.client_id + '\0' + root).digest('hex').slice(0, 24);
159
+ const remoteDirectory = config.root + '/local-' + hash;
160
+ const remote = (script, { interactive = false, input, capture = !interactive, timeout = 30000, check = true } = {}) =>
161
+ client.execute('ssh', sshArgs(config, script, { interactive, tty: interactive && client.tty,
162
+ input: input !== undefined, controlPath: tunnel.controlPath }), { env: client.env, capture, input,
163
+ timeout: interactive ? 0 : timeout, check });
164
+ const cleanup = () => cleaning ||= (async () => {
165
+ if (tunnel && configFile)
166
+ await remote('rm -rf -- ' + remotePath(configFile) + ' ' + remotePath(promptFile) + ' ' + remotePath(clipboardDirectory),
167
+ { timeout: 5000, check: false }).catch(() => {});
168
+ abort.abort();
169
+ await gateway?.close();
170
+ await connecting;
171
+ await closeUpstreams(upstreams);
172
+ await tunnel?.close();
173
+ })();
174
+ const removeCleanup = onCleanup(cleanup);
175
+ try {
176
+ // Developer MCP servers connect while the gateway and SSH tunnel start.
177
+ if (servers.length) connecting = (dependencies.connectUpstreams || connectUpstreams)(servers,
178
+ { root, env: client.env, debug: client.debug, warn: client.warn, signal: abort.signal })
179
+ .then(connected => { for (const [name, upstream] of connected) upstreams.set(name, upstream); })
180
+ .catch(error => client.warn('MCP 连接失败:' + error.message));
181
+ gateway = await (dependencies.startLocalServer || startLocalServer)({ root, env: client.env, upstreams,
182
+ readClipboard: dependencies.readClipboard,
183
+ headless: client.env.CLAUDE_REMOTE_HEADLESS === undefined ? Boolean(config.browserHeadless) : client.env.CLAUDE_REMOTE_HEADLESS === '1', log: client.log });
184
+ tunnel = await (dependencies.openTunnel || openTunnel)(config, gateway.port, { env: client.env, run: client.execute });
185
+ const session = randomUUID();
186
+ configFile = remoteDirectory + '/.local-mcp-' + session + '.json';
187
+ promptFile = remoteDirectory + '/.local-prompt-' + session + '.md';
188
+ clipboardDirectory = remoteDirectory + '/.local-clipboard-' + session;
189
+ await connecting;
190
+ const endpoint = suffix => ({ type: 'http', url: `http://127.0.0.1:${tunnel.port}/mcp${suffix}`,
191
+ headers: { Authorization: 'Bearer ' + gateway.token }, timeout: CALL_TIMEOUT });
192
+ const mcp = { mcpServers: { local: endpoint('') } };
193
+ for (const name of upstreams.keys()) mcp.mcpServers[name] = endpoint('/' + name);
194
+ if (upstreams.size) client.debug('本机 MCP:' + [...upstreams.values()].map(item => `${item.name}(${item.describe()})`).join(','));
195
+ const prompt = localPrompt(root, { instructions: await projectInstructions(root), appended: extractAppendedPrompts(extra).prompts,
196
+ platform: client.platform, mcpServers: [...upstreams.keys()] });
197
+ // One round trip: install Claude if missing, store the token-bearing MCP
198
+ // config (first stdin line), the clipboard stand-in with its Authorization
199
+ // header (second line) and the prompt (the rest), then report the version.
200
+ // `read` consumes the pipe byte by byte, so nothing is lost between the files.
201
+ const version = await remote(ensureRemoteClaude(config.claude) +
202
+ 'umask 077; mkdir -p -- ' + remotePath(remoteDirectory) + '\n' +
203
+ 'IFS= read -r line; printf \'%s\\n\' "$line" > ' + remotePath(configFile) + '\n' +
204
+ installClipboardShim(remotePath(clipboardDirectory), quote(`http://127.0.0.1:${tunnel.port}/clipboard/image`)) +
205
+ (client.platform === 'win32' ? ensureAltVBinding() : '') +
206
+ 'cat > ' + remotePath(promptFile) + '\n' +
207
+ shellJoin([config.claude, '--version']) + ' </dev/null',
208
+ { input: JSON.stringify(mcp) + '\nAuthorization: Bearer ' + gateway.token + '\n' + prompt, timeout: 600000 });
166
209
  if (!supportsLocalMode(version.stdout)) throw new Error('本地工具模式需要服务器 Claude Code 2.1.248 或更高版本。请先在服务器运行 claude update。');
167
- await browserReady;
168
- client.debug(`本地执行模式:${root}\n文件、命令和浏览器在本机运行;项目文件不通过本模式同步。`);
169
- const args = localClaudeArguments(path.posix.basename(configFile), path.posix.basename(promptFile), extra);
170
- // Keep the parent shell so EXIT can remove the token-bearing temporary files.
171
- const script = remoteEnvironment + 'cd -- ' + remotePath(remoteDirectory) + ' || exit 1; ' +
172
- 'trap ' + quote('rm -f -- ' + remotePath(configFile) + ' ' + remotePath(promptFile)) + ' EXIT; ' +
173
- shellJoin([config.claude, ...args]);
174
- const code = (await remote(script, { interactive: true, check: false })).code;
175
- // A dropped connection leaves the remote UI's terminal modes switched on.
176
- if (code !== 0) client.restoreTerminal();
177
- return code;
178
- } finally {
179
- removeCleanup();
180
- await cleanup();
181
- }
182
- }
210
+ if (version.stdout.includes(KEYBINDING_MISSING))
211
+ client.warn('服务器的 ~/.claude/keybindings.json 已存在但未绑定 Alt+V,按 Alt+V 无法粘贴本机截图。' +
212
+ '请在其 "bindings" 数组中加入:' + JSON.stringify(ALT_V_BINDING));
213
+ await browserReady;
214
+ client.debug(`本地执行模式:${root}\n文件、命令和浏览器在本机运行;项目文件不通过本模式同步。`);
215
+ const args = localClaudeArguments(path.posix.basename(configFile), path.posix.basename(promptFile), extra);
216
+ // Keep the parent shell so EXIT can remove the token-bearing temporary files.
217
+ // The clipboard stand-in goes first on PATH for Claude only, so pasting an
218
+ // image reads this computer's clipboard.
219
+ const script = remoteEnvironment + 'cd -- ' + remotePath(remoteDirectory) + ' || exit 1; ' +
220
+ 'trap ' + quote('rm -rf -- ' + remotePath(configFile) + ' ' + remotePath(promptFile) + ' ' + remotePath(clipboardDirectory)) + ' EXIT; ' +
221
+ 'PATH=' + remotePath(clipboardDirectory) + ':"$PATH" ' + shellJoin([config.claude, ...args]);
222
+ const code = (await remote(script, { interactive: true, check: false })).code;
223
+ // A dropped connection leaves the remote UI's terminal modes switched on.
224
+ if (code !== 0) client.restoreTerminal();
225
+ return code;
226
+ } finally {
227
+ removeCleanup();
228
+ await cleanup();
229
+ }
230
+ }
@@ -1,67 +1,67 @@
1
- import path from 'node:path';
2
- import { execute, which } from './system.js';
3
-
4
- const MARKER = 'CLAUDE_REMOTE_JSON:';
5
-
6
- // Runs with the user's profile loaded, as an interactive window would. Only
7
- // aliases and functions are reported: they win over programs on PATH, so a
8
- // leftover `function claude` hides this tool while where.exe shows nothing wrong.
9
- const SCRIPT = [
10
- "$ErrorActionPreference = 'SilentlyContinue'",
11
- 'try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}',
12
- '$items = @(Get-Command claude -All -CommandType Alias,Function,Filter | ForEach-Object {',
13
- " $alias = $_.CommandType -eq 'Alias'",
14
- ' [pscustomobject]@{ type = [string]$_.CommandType; target = $(if ($alias) { [string]$_.Definition } else { $null });',
15
- ' file = $(if ($alias) { $null } else { [string]$_.ScriptBlock.File }); module = [string]$_.ModuleName }',
16
- '})',
17
- `'${MARKER}' + (ConvertTo-Json -Compress -Depth 3 -InputObject ([pscustomobject]@{ profile = [string]$PROFILE; items = $items }))`
18
- ].join('\n');
19
-
20
- const encoded = Buffer.from(SCRIPT, 'utf16le').toString('base64');
21
-
22
- export async function powershellHosts(env = process.env) {
23
- const hosts = [{ name: 'Windows PowerShell',
24
- file: path.win32.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') }];
25
- const pwsh = await which('pwsh', env.PATH || env.Path || '', { platform: 'win32' });
26
- if (pwsh) hosts.push({ name: 'PowerShell 7', file: pwsh });
27
- return hosts;
28
- }
29
-
30
- export function parseConflicts(stdout) {
31
- const line = stdout.split(/\r?\n/).reverse().find(text => text.startsWith(MARKER));
32
- if (!line) throw new Error('没有得到检查结果');
33
- const result = JSON.parse(line.slice(MARKER.length));
34
- const items = result.items == null ? [] : [].concat(result.items);
35
- return { profile: result.profile || '', items };
36
- }
37
-
38
- export async function findClaudeConflicts(host, { env = process.env, run = execute, timeout = 20000 } = {}) {
39
- const { stdout } = await run(host.file, ['-NoLogo', '-NonInteractive', '-EncodedCommand', encoded],
40
- { capture: true, check: false, timeout, env });
41
- return parseConflicts(stdout);
42
- }
43
-
44
- export function describeConflict(item) {
45
- if (item.type === 'Alias') return `别名 → ${item.target}`;
46
- const kind = item.type === 'Filter' ? '筛选器' : '函数';
47
- if (item.file) return `${kind}(定义于 ${item.file})`;
48
- if (item.module) return `${kind}(来自模块 ${item.module})`;
49
- return kind;
50
- }
51
-
52
- // Lines for `claude-remote doctor`.
53
- export async function conflictReport(env = process.env, options = {}) {
54
- const lines = [];
55
- for (const host of await powershellHosts(env)) {
56
- try {
57
- const { profile, items } = await findClaudeConflicts(host, { env, ...options });
58
- if (!items.length) { lines.push(`${host.name} 中的 claude:无冲突`); continue; }
59
- lines.push(`${host.name} 中的 claude:被占用,在该终端输入 claude 不会运行本工具`);
60
- for (const item of items) lines.push(' - ' + describeConflict(item));
61
- lines.push(` 处理:删除上述定义(配置文件 ${profile},可用 notepad $PROFILE 打开)后重新打开终端;临时可运行 claude.cmd`);
62
- } catch (error) {
63
- lines.push(`${host.name} 中的 claude:无法检查(${error.message})`);
64
- }
65
- }
66
- return lines;
67
- }
1
+ import path from 'node:path';
2
+ import { execute, which } from './system.js';
3
+
4
+ const MARKER = 'CLAUDE_REMOTE_JSON:';
5
+
6
+ // Runs with the user's profile loaded, as an interactive window would. Only
7
+ // aliases and functions are reported: they win over programs on PATH, so a
8
+ // leftover `function claude` hides this tool while where.exe shows nothing wrong.
9
+ const SCRIPT = [
10
+ "$ErrorActionPreference = 'SilentlyContinue'",
11
+ 'try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}',
12
+ '$items = @(Get-Command claude -All -CommandType Alias,Function,Filter | ForEach-Object {',
13
+ " $alias = $_.CommandType -eq 'Alias'",
14
+ ' [pscustomobject]@{ type = [string]$_.CommandType; target = $(if ($alias) { [string]$_.Definition } else { $null });',
15
+ ' file = $(if ($alias) { $null } else { [string]$_.ScriptBlock.File }); module = [string]$_.ModuleName }',
16
+ '})',
17
+ `'${MARKER}' + (ConvertTo-Json -Compress -Depth 3 -InputObject ([pscustomobject]@{ profile = [string]$PROFILE; items = $items }))`
18
+ ].join('\n');
19
+
20
+ const encoded = Buffer.from(SCRIPT, 'utf16le').toString('base64');
21
+
22
+ export async function powershellHosts(env = process.env) {
23
+ const hosts = [{ name: 'Windows PowerShell',
24
+ file: path.win32.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') }];
25
+ const pwsh = await which('pwsh', env.PATH || env.Path || '', { platform: 'win32' });
26
+ if (pwsh) hosts.push({ name: 'PowerShell 7', file: pwsh });
27
+ return hosts;
28
+ }
29
+
30
+ export function parseConflicts(stdout) {
31
+ const line = stdout.split(/\r?\n/).reverse().find(text => text.startsWith(MARKER));
32
+ if (!line) throw new Error('没有得到检查结果');
33
+ const result = JSON.parse(line.slice(MARKER.length));
34
+ const items = result.items == null ? [] : [].concat(result.items);
35
+ return { profile: result.profile || '', items };
36
+ }
37
+
38
+ export async function findClaudeConflicts(host, { env = process.env, run = execute, timeout = 20000 } = {}) {
39
+ const { stdout } = await run(host.file, ['-NoLogo', '-NonInteractive', '-EncodedCommand', encoded],
40
+ { capture: true, check: false, timeout, env });
41
+ return parseConflicts(stdout);
42
+ }
43
+
44
+ export function describeConflict(item) {
45
+ if (item.type === 'Alias') return `别名 → ${item.target}`;
46
+ const kind = item.type === 'Filter' ? '筛选器' : '函数';
47
+ if (item.file) return `${kind}(定义于 ${item.file})`;
48
+ if (item.module) return `${kind}(来自模块 ${item.module})`;
49
+ return kind;
50
+ }
51
+
52
+ // Lines for `claude-remote doctor`.
53
+ export async function conflictReport(env = process.env, options = {}) {
54
+ const lines = [];
55
+ for (const host of await powershellHosts(env)) {
56
+ try {
57
+ const { profile, items } = await findClaudeConflicts(host, { env, ...options });
58
+ if (!items.length) { lines.push(`${host.name} 中的 claude:无冲突`); continue; }
59
+ lines.push(`${host.name} 中的 claude:被占用,在该终端输入 claude 不会运行本工具`);
60
+ for (const item of items) lines.push(' - ' + describeConflict(item));
61
+ lines.push(` 处理:删除上述定义(配置文件 ${profile},可用 notepad $PROFILE 打开)后重新打开终端;临时可运行 claude.cmd`);
62
+ } catch (error) {
63
+ lines.push(`${host.name} 中的 claude:无法检查(${error.message})`);
64
+ }
65
+ }
66
+ return lines;
67
+ }