@znmin/claude-remote-cli 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -6
- package/THIRD_PARTY_NOTICES.md +16 -16
- package/bin/claude-remote.js +3 -3
- package/bin/claude.js +3 -3
- package/package.json +67 -67
- package/scripts/postinstall.js +11 -11
- package/src/cli.js +187 -157
- package/src/client.js +232 -232
- package/src/installer.js +96 -96
- package/src/local/browser.js +290 -234
- package/src/local/mcp-config.js +320 -0
- package/src/local/mcp-proxy.js +199 -0
- package/src/local/processes.js +144 -144
- package/src/local/server.js +201 -193
- package/src/local/shell.js +72 -72
- package/src/local/tunnel.js +98 -98
- package/src/local/workspace.js +225 -225
- package/src/local-mode.js +218 -182
- package/src/powershell-conflicts.js +67 -67
- package/src/remote-claude.js +39 -39
- package/src/ssh-auth.js +113 -113
- package/src/ssh-policy.js +92 -92
- package/src/ssh.js +16 -16
- package/src/system.js +179 -179
package/src/local/server.js
CHANGED
|
@@ -1,193 +1,201 @@
|
|
|
1
|
-
import http from 'node:http';
|
|
2
|
-
import os from 'node:os';
|
|
3
|
-
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
4
|
-
import * as fs from 'node:fs/promises';
|
|
5
|
-
import { readFileSync } from 'node:fs';
|
|
6
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
8
|
-
import { z } from 'zod';
|
|
9
|
-
import { Workspace } from './workspace.js';
|
|
10
|
-
import { Processes } from './processes.js';
|
|
11
|
-
import { describeShell } from './shell.js';
|
|
12
|
-
import { Browser } from './browser.js';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
const shrink = (value, size) => {
|
|
18
|
-
if (typeof value === 'string')
|
|
19
|
-
return value.length > size ? value.slice(0, size) + `…[已截断 ${value.length - size} 字符]` : value;
|
|
20
|
-
if (Array.isArray(value)) return value.map(item => shrink(item, size));
|
|
21
|
-
if (value && typeof value === 'object')
|
|
22
|
-
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, shrink(item, size)]));
|
|
23
|
-
return value;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
// Claude persists very large MCP results on the server, where local mode cannot
|
|
27
|
-
// read them back. Keep the result structured and shorten its longest strings.
|
|
28
|
-
export function boundedJSON(value) {
|
|
29
|
-
const output = JSON.stringify(value);
|
|
30
|
-
if (output.length <= MAX_RESULT) return output;
|
|
31
|
-
const message = '结果过大,已截断较长字段。请缩小查询范围或分页读取。';
|
|
32
|
-
for (let size = 16000; size >= 100; size = Math.floor(size / 2)) {
|
|
33
|
-
const shrunk = JSON.stringify({ ...shrink(value, size), truncated: true, message });
|
|
34
|
-
if (shrunk.length <= MAX_RESULT) return shrunk;
|
|
35
|
-
}
|
|
36
|
-
return JSON.stringify({ truncated: true, message, preview: output.slice(0, 16000) });
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const text = value => ({ content: [{ type: 'text', text: boundedJSON(value) }] });
|
|
40
|
-
const file = z.string().min(1).max(4096);
|
|
41
|
-
const pageId = z.string().min(1);
|
|
42
|
-
const selector = z.string().min(1).max(4000);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
register('
|
|
77
|
-
path: file,
|
|
78
|
-
}, args => workspace.
|
|
79
|
-
register('
|
|
80
|
-
path: file,
|
|
81
|
-
}, args => workspace.
|
|
82
|
-
register('
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
register('
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
register('
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
register('
|
|
113
|
-
page_id: pageId, selector
|
|
114
|
-
}, args => browser.action(args.page_id, '
|
|
115
|
-
register('
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
register('
|
|
121
|
-
page_id: pageId,
|
|
122
|
-
}, args => browser.
|
|
123
|
-
register('
|
|
124
|
-
page_id: pageId, selector: selector.optional(),
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}, args => browser.
|
|
131
|
-
register('
|
|
132
|
-
page_id: pageId,
|
|
133
|
-
}, args => browser.
|
|
134
|
-
register('
|
|
135
|
-
page_id: pageId,
|
|
136
|
-
}, args => browser.
|
|
137
|
-
register('
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import * as fs from 'node:fs/promises';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { Workspace } from './workspace.js';
|
|
10
|
+
import { Processes } from './processes.js';
|
|
11
|
+
import { describeShell } from './shell.js';
|
|
12
|
+
import { Browser } from './browser.js';
|
|
13
|
+
import { MAX_RESULT } from './mcp-proxy.js';
|
|
14
|
+
|
|
15
|
+
const PACKAGE = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
16
|
+
|
|
17
|
+
const shrink = (value, size) => {
|
|
18
|
+
if (typeof value === 'string')
|
|
19
|
+
return value.length > size ? value.slice(0, size) + `…[已截断 ${value.length - size} 字符]` : value;
|
|
20
|
+
if (Array.isArray(value)) return value.map(item => shrink(item, size));
|
|
21
|
+
if (value && typeof value === 'object')
|
|
22
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, shrink(item, size)]));
|
|
23
|
+
return value;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Claude persists very large MCP results on the server, where local mode cannot
|
|
27
|
+
// read them back. Keep the result structured and shorten its longest strings.
|
|
28
|
+
export function boundedJSON(value) {
|
|
29
|
+
const output = JSON.stringify(value);
|
|
30
|
+
if (output.length <= MAX_RESULT) return output;
|
|
31
|
+
const message = '结果过大,已截断较长字段。请缩小查询范围或分页读取。';
|
|
32
|
+
for (let size = 16000; size >= 100; size = Math.floor(size / 2)) {
|
|
33
|
+
const shrunk = JSON.stringify({ ...shrink(value, size), truncated: true, message });
|
|
34
|
+
if (shrunk.length <= MAX_RESULT) return shrunk;
|
|
35
|
+
}
|
|
36
|
+
return JSON.stringify({ truncated: true, message, preview: output.slice(0, 16000) });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const text = value => ({ content: [{ type: 'text', text: boundedJSON(value) }] });
|
|
40
|
+
const file = z.string().min(1).max(4096);
|
|
41
|
+
const pageId = z.string().min(1);
|
|
42
|
+
const selector = z.string().min(1).max(4000);
|
|
43
|
+
|
|
44
|
+
// `upstreams` maps names to developer MCP servers (see mcp-proxy.js), served at
|
|
45
|
+
// /mcp/<name>. The map may be filled after the gateway starts.
|
|
46
|
+
export async function startLocalServer({ root, env = process.env, headless = false, browser, log = console.error,
|
|
47
|
+
upstreams = new Map() } = {}) {
|
|
48
|
+
const workspace = new Workspace(await fs.realpath(root));
|
|
49
|
+
const processes = new Processes(workspace, env);
|
|
50
|
+
browser ||= new Browser({ headless, log, env });
|
|
51
|
+
const token = randomBytes(32).toString('hex');
|
|
52
|
+
const transports = new Set();
|
|
53
|
+
let closing = false;
|
|
54
|
+
|
|
55
|
+
function createServer() {
|
|
56
|
+
const server = new McpServer({ name: 'claude-local-tools', version: PACKAGE.version });
|
|
57
|
+
const register = (name, description, inputSchema, handler, readOnly = false) => {
|
|
58
|
+
server.registerTool(name, { description, inputSchema,
|
|
59
|
+
annotations: { readOnlyHint: readOnly, idempotentHint: readOnly } }, async (args, extra) => {
|
|
60
|
+
try {
|
|
61
|
+
const value = await handler(args, extra);
|
|
62
|
+
return Array.isArray(value?.content) ? value : text(value);
|
|
63
|
+
} catch (error) { return { isError: true, content: [{ type: 'text', text: error.message }] }; }
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
register('workspace_info', 'Describe the LOCAL computer and project. All tools execute on this computer, not the SSH server.', {},
|
|
67
|
+
async () => {
|
|
68
|
+
const shell = await processes.shell();
|
|
69
|
+
const windows = os.platform() === 'win32';
|
|
70
|
+
const shellNote = !windows ? '' : shell.kind === 'bash'
|
|
71
|
+
? ' This is Windows: run_command uses Git Bash, so write bash; Windows programs (git, npm, node, python) are on PATH, and C:/path style paths work.'
|
|
72
|
+
: ' This is Windows without Git Bash: run_command uses PowerShell, so write PowerShell commands (not bash or cmd syntax).';
|
|
73
|
+
return { root: workspace.root, platform: os.platform(), shell: shell.file, ...(windows ? { shell_kind: describeShell(shell) } : {}),
|
|
74
|
+
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.' + shellNote };
|
|
75
|
+
}, true);
|
|
76
|
+
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.', {
|
|
77
|
+
path: file, offset: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(2000).default(200)
|
|
78
|
+
}, args => workspace.read(args.path, args.offset, args.limit), true);
|
|
79
|
+
register('write_file', 'Create a LOCAL file. Replacing an existing file requires its current SHA-256 from read_file.', {
|
|
80
|
+
path: file, content: z.string().max(1024 * 1024), expected_sha256: z.string().regex(/^[a-f0-9]{64}$/).optional()
|
|
81
|
+
}, args => workspace.write(args.path, args.content, args.expected_sha256));
|
|
82
|
+
register('edit_file', 'Edit a LOCAL file by exact text replacement. Include enough context for a unique match.', {
|
|
83
|
+
path: file, old_text: z.string().min(1).max(1024 * 1024), new_text: z.string().max(1024 * 1024), replace_all: z.boolean().default(false)
|
|
84
|
+
}, args => workspace.edit(args.path, args.old_text, args.new_text, args.replace_all));
|
|
85
|
+
register('list_directory', 'List LOCAL project files and directories, including hidden entries.', { path: file.default('.') },
|
|
86
|
+
args => workspace.list(args.path), true);
|
|
87
|
+
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.', {
|
|
88
|
+
pattern: z.string().min(1).max(1000), path: file.default('.'), max_results: z.number().int().min(1).max(500).default(100),
|
|
89
|
+
case_sensitive: z.boolean().default(true), regex: z.boolean().default(false), glob: z.string().min(1).max(500).optional()
|
|
90
|
+
}, args => workspace.search(args.pattern, args.path, args.max_results, args.case_sensitive,
|
|
91
|
+
{ regex: args.regex, glob: args.glob }), true);
|
|
92
|
+
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.', {
|
|
93
|
+
command: z.string().min(1).max(100000), cwd: file.default('.'), timeout_ms: z.number().int().min(100).max(120000).default(30000),
|
|
94
|
+
background: z.boolean().default(false)
|
|
95
|
+
}, (args, extra) => processes.run(args.command, args.cwd, args.timeout_ms, args.background, extra.signal));
|
|
96
|
+
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.', {
|
|
97
|
+
job_id: z.string(), stream: z.enum(['stdout', 'stderr']).optional(),
|
|
98
|
+
offset: z.number().int().min(0).optional(), limit: z.number().int().min(1).max(30000).default(8000)
|
|
99
|
+
}, args => processes.status(args.job_id, { stream: args.stream, offset: args.offset, limit: args.limit }), true);
|
|
100
|
+
register('list_processes', 'List processes owned by this tool session, not all system processes.', {}, () => processes.list(), true);
|
|
101
|
+
register('stop_process', 'Stop a process group started by this tool session, including a development server.', { job_id: z.string() },
|
|
102
|
+
args => processes.stop(args.job_id));
|
|
103
|
+
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.', {
|
|
104
|
+
url: z.string().url().or(z.literal('about:blank')).default('about:blank')
|
|
105
|
+
}, args => browser.open(args.url));
|
|
106
|
+
register('browser_snapshot', 'Inspect a LOCAL browser page and get visible text and selectors for interactive elements.', { page_id: pageId },
|
|
107
|
+
args => browser.snapshot(args.page_id), true);
|
|
108
|
+
register('browser_click', 'Click a selector on a LOCAL browser page. Use selectors from browser_snapshot.', { page_id: pageId, selector },
|
|
109
|
+
args => browser.action(args.page_id, 'click', args.selector));
|
|
110
|
+
register('browser_fill', 'Fill an input on a LOCAL browser page.', { page_id: pageId, selector, value: z.string().max(100000) },
|
|
111
|
+
args => browser.action(args.page_id, 'fill', args.selector, args.value));
|
|
112
|
+
register('browser_press', 'Press a key such as Enter, Escape or ControlOrMeta+A in a LOCAL page.', {
|
|
113
|
+
page_id: pageId, selector: selector.optional(), key: z.string().min(1).max(100)
|
|
114
|
+
}, args => browser.action(args.page_id, 'press', args.selector, args.key));
|
|
115
|
+
register('browser_select', 'Choose option(s) of a <select> on a LOCAL page by value or label.', {
|
|
116
|
+
page_id: pageId, selector, values: z.union([z.string(), z.array(z.string()).max(50)])
|
|
117
|
+
}, args => browser.action(args.page_id, 'select', args.selector, args.values));
|
|
118
|
+
register('browser_hover', 'Hover over an element on a LOCAL page, e.g. to open a menu.', { page_id: pageId, selector },
|
|
119
|
+
args => browser.action(args.page_id, 'hover', args.selector));
|
|
120
|
+
register('browser_navigate', 'Navigate an open LOCAL page to a URL, or go back, forward or reload.', {
|
|
121
|
+
page_id: pageId, url: z.string().url().optional(), action: z.enum(['back', 'forward', 'reload']).optional()
|
|
122
|
+
}, args => browser.navigate(args.page_id, args));
|
|
123
|
+
register('browser_scroll', 'Scroll a LOCAL page: bring a selector into view, or scroll by x/y pixels.', {
|
|
124
|
+
page_id: pageId, selector: selector.optional(), x: z.number().int().default(0), y: z.number().int().default(0)
|
|
125
|
+
}, args => browser.scroll(args.page_id, args));
|
|
126
|
+
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.', {
|
|
127
|
+
page_id: pageId, selector: selector.optional(), text: z.string().min(1).max(1000).optional(),
|
|
128
|
+
state: z.enum(['visible', 'hidden', 'attached', 'detached']).default('visible'),
|
|
129
|
+
timeout_ms: z.number().int().min(100).max(60000).default(10000)
|
|
130
|
+
}, args => browser.waitFor(args.page_id, { selector: args.selector, text: args.text, state: args.state, timeout: args.timeout_ms }), true);
|
|
131
|
+
register('browser_evaluate', 'Evaluate a JavaScript expression in a LOCAL page and return its JSON-serializable result.', {
|
|
132
|
+
page_id: pageId, expression: z.string().min(1).max(100000)
|
|
133
|
+
}, args => browser.evaluate(args.page_id, args.expression));
|
|
134
|
+
register('browser_screenshot', 'Capture a LOCAL page as an image: the viewport by default, the full page, or one element.', {
|
|
135
|
+
page_id: pageId, full_page: z.boolean().default(false), selector: selector.optional()
|
|
136
|
+
}, args => browser.screenshot(args.page_id, { fullPage: args.full_page, selector: args.selector }), true);
|
|
137
|
+
register('browser_logs', 'Read recent browser console messages, page errors and failed network requests.', {
|
|
138
|
+
page_id: pageId, limit: z.number().int().min(1).max(200).default(20)
|
|
139
|
+
}, args => browser.readLogs(args.page_id, args.limit), true);
|
|
140
|
+
register('browser_close', 'Close a LOCAL browser tab opened by this session.', { page_id: pageId }, args => browser.closePage(args.page_id));
|
|
141
|
+
return server;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const httpServer = http.createServer(async (request, response) => {
|
|
145
|
+
const expected = Buffer.from('Bearer ' + token), actual = Buffer.from(request.headers.authorization || '');
|
|
146
|
+
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
|
|
147
|
+
response.writeHead(401).end('Unauthorized'); return;
|
|
148
|
+
}
|
|
149
|
+
if (closing) { response.writeHead(503).end(); return; }
|
|
150
|
+
// Browser pages cannot invoke this privileged endpoint; native MCP clients do not send Origin.
|
|
151
|
+
if (request.headers.origin) { response.writeHead(403).end('Origin not allowed'); return; }
|
|
152
|
+
let upstream;
|
|
153
|
+
if (request.url !== '/mcp') {
|
|
154
|
+
const match = request.url.match(/^\/mcp\/([A-Za-z0-9_-]{1,64})$/);
|
|
155
|
+
upstream = match && upstreams.get(match[1]);
|
|
156
|
+
if (!upstream) { response.writeHead(404).end(); return; }
|
|
157
|
+
}
|
|
158
|
+
if (request.method !== 'POST') { response.writeHead(405, { Allow: 'POST' }).end(); return; }
|
|
159
|
+
let server, transport;
|
|
160
|
+
try {
|
|
161
|
+
const chunks = []; let bytes = 0;
|
|
162
|
+
for await (const chunk of request) {
|
|
163
|
+
bytes += chunk.length;
|
|
164
|
+
if (bytes > 2 * 1024 * 1024) { response.writeHead(413).end('Request too large'); return; }
|
|
165
|
+
chunks.push(chunk);
|
|
166
|
+
}
|
|
167
|
+
let body;
|
|
168
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
|
169
|
+
catch { response.writeHead(400).end('Invalid JSON'); return; }
|
|
170
|
+
server = upstream ? upstream.createServer() : createServer();
|
|
171
|
+
transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
172
|
+
transports.add(transport);
|
|
173
|
+
response.on('close', () => {
|
|
174
|
+
transports.delete(transport);
|
|
175
|
+
transport.close().catch(() => {}); server.close().catch(() => {});
|
|
176
|
+
});
|
|
177
|
+
await server.connect(transport);
|
|
178
|
+
await transport.handleRequest(request, response, body);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
await transport?.close().catch(() => {}); await server?.close().catch(() => {});
|
|
181
|
+
if (!response.headersSent) response.writeHead(500).end('MCP request failed');
|
|
182
|
+
else response.end();
|
|
183
|
+
log('本地工具请求失败:' + error.message);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
httpServer.requestTimeout = 30000;
|
|
187
|
+
httpServer.headersTimeout = 10000;
|
|
188
|
+
await new Promise((resolve, reject) => {
|
|
189
|
+
httpServer.once('error', reject); httpServer.listen(0, '127.0.0.1', resolve);
|
|
190
|
+
});
|
|
191
|
+
const port = httpServer.address().port;
|
|
192
|
+
return { port, token, workspace, processes, browser,
|
|
193
|
+
async close() {
|
|
194
|
+
closing = true;
|
|
195
|
+
await Promise.allSettled([...transports].map(transport => transport.close()));
|
|
196
|
+
await Promise.allSettled([processes.close(), browser.close()]);
|
|
197
|
+
httpServer.closeAllConnections();
|
|
198
|
+
await new Promise(resolve => httpServer.close(resolve));
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
}
|
package/src/local/shell.js
CHANGED
|
@@ -1,72 +1,72 @@
|
|
|
1
|
-
import * as fs from 'node:fs/promises';
|
|
2
|
-
import os from 'node:os';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { exists, quote, which } from '../system.js';
|
|
6
|
-
|
|
7
|
-
const win = path.win32;
|
|
8
|
-
export const psQuote = value => "'" + String(value).replaceAll("'", "''") + "'";
|
|
9
|
-
|
|
10
|
-
// Git Bash locations, in the order Claude Code itself looks for them.
|
|
11
|
-
export async function gitBashCandidates(env = process.env) {
|
|
12
|
-
const candidates = [];
|
|
13
|
-
if (env.CLAUDE_CODE_GIT_BASH_PATH) candidates.push(env.CLAUDE_CODE_GIT_BASH_PATH);
|
|
14
|
-
const git = await which('git', env.PATH || env.Path || '', { platform: 'win32' });
|
|
15
|
-
if (git) {
|
|
16
|
-
// <Git>\cmd\git.exe, <Git>\bin\git.exe or <Git>\mingw64\bin\git.exe
|
|
17
|
-
let root = win.dirname(win.dirname(git));
|
|
18
|
-
if (/^mingw(32|64)$/i.test(win.basename(root))) root = win.dirname(root);
|
|
19
|
-
candidates.push(win.join(root, 'bin', 'bash.exe'));
|
|
20
|
-
}
|
|
21
|
-
for (const base of [env.ProgramFiles, env.ProgramW6432, env['ProgramFiles(x86)']]) if (base) candidates.push(win.join(base, 'Git', 'bin', 'bash.exe'));
|
|
22
|
-
if (env.LOCALAPPDATA) candidates.push(win.join(env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe'));
|
|
23
|
-
return [...new Set(candidates)];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// The shell behind run_command. POSIX keeps the user's login shell. Windows
|
|
27
|
-
// prefers Git Bash (the model writes bash most reliably), then PowerShell.
|
|
28
|
-
// C:\Windows\System32\bash.exe is WSL, which would run in a different system,
|
|
29
|
-
// so bash is never taken from PATH there.
|
|
30
|
-
export async function findShell(env = process.env, platform = process.platform) {
|
|
31
|
-
if (platform !== 'win32') return { kind: 'posix', file: env.SHELL || '/bin/bash' };
|
|
32
|
-
const chosen = env.CLAUDE_REMOTE_SHELL;
|
|
33
|
-
if (chosen) return { kind: /(^|[\\/])(pwsh|powershell)(\.exe)?$/i.test(chosen) ? 'powershell' : 'bash', file: chosen };
|
|
34
|
-
for (const file of await gitBashCandidates(env)) if (await exists(file)) return { kind: 'bash', file };
|
|
35
|
-
const pwsh = await which('pwsh', env.PATH || env.Path || '', { platform });
|
|
36
|
-
if (pwsh) return { kind: 'powershell', file: pwsh };
|
|
37
|
-
return { kind: 'powershell',
|
|
38
|
-
file: win.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function describeShell(shell) {
|
|
42
|
-
if (shell.kind === 'bash') return 'Git Bash';
|
|
43
|
-
if (shell.kind === 'powershell') return 'PowerShell';
|
|
44
|
-
return path.basename(shell.file);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Windows limits a command line to 32767 characters, so commands there are
|
|
48
|
-
// written to a temporary script instead of being passed as an argument.
|
|
49
|
-
export async function prepareCommand(shell, command, directory, env = process.env) {
|
|
50
|
-
if (shell.kind === 'posix')
|
|
51
|
-
return { file: shell.file, args: ['-lc', `cd -- ${quote(directory)} && ${command}`], env, cleanup: async () => {} };
|
|
52
|
-
const script = path.join(os.tmpdir(), `claude-remote-${randomUUID()}.${shell.kind === 'bash' ? 'sh' : 'ps1'}`);
|
|
53
|
-
const cleanup = () => fs.rm(script, { force: true }).catch(() => {});
|
|
54
|
-
if (shell.kind === 'bash') {
|
|
55
|
-
// MSYS accepts C:/x paths. CHERE_INVOKING stops the login profile from changing to $HOME.
|
|
56
|
-
await fs.writeFile(script, `cd -- ${quote(directory.replaceAll('\\', '/'))} || exit 1\n${command}\n`, { mode: 0o600 });
|
|
57
|
-
return { file: shell.file, args: ['-l', script.replaceAll('\\', '/')], env: { ...env, CHERE_INVOKING: '1' }, cleanup };
|
|
58
|
-
}
|
|
59
|
-
// A BOM makes Windows PowerShell 5.1 read the script as UTF-8. Mirror bash:
|
|
60
|
-
// the exit code follows the last statement, including native programs.
|
|
61
|
-
await fs.writeFile(script, '\uFEFF' + [
|
|
62
|
-
"$ProgressPreference = 'SilentlyContinue'",
|
|
63
|
-
'try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}',
|
|
64
|
-
'$OutputEncoding = [System.Text.Encoding]::UTF8',
|
|
65
|
-
'Set-Location -LiteralPath ' + psQuote(directory),
|
|
66
|
-
command,
|
|
67
|
-
'if (-not $?) { if ($LASTEXITCODE) { exit $LASTEXITCODE }; exit 1 }',
|
|
68
|
-
'exit 0', ''
|
|
69
|
-
].join('\r\n'), { mode: 0o600 });
|
|
70
|
-
return { file: shell.file, args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script],
|
|
71
|
-
env, cleanup };
|
|
72
|
-
}
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { exists, quote, which } from '../system.js';
|
|
6
|
+
|
|
7
|
+
const win = path.win32;
|
|
8
|
+
export const psQuote = value => "'" + String(value).replaceAll("'", "''") + "'";
|
|
9
|
+
|
|
10
|
+
// Git Bash locations, in the order Claude Code itself looks for them.
|
|
11
|
+
export async function gitBashCandidates(env = process.env) {
|
|
12
|
+
const candidates = [];
|
|
13
|
+
if (env.CLAUDE_CODE_GIT_BASH_PATH) candidates.push(env.CLAUDE_CODE_GIT_BASH_PATH);
|
|
14
|
+
const git = await which('git', env.PATH || env.Path || '', { platform: 'win32' });
|
|
15
|
+
if (git) {
|
|
16
|
+
// <Git>\cmd\git.exe, <Git>\bin\git.exe or <Git>\mingw64\bin\git.exe
|
|
17
|
+
let root = win.dirname(win.dirname(git));
|
|
18
|
+
if (/^mingw(32|64)$/i.test(win.basename(root))) root = win.dirname(root);
|
|
19
|
+
candidates.push(win.join(root, 'bin', 'bash.exe'));
|
|
20
|
+
}
|
|
21
|
+
for (const base of [env.ProgramFiles, env.ProgramW6432, env['ProgramFiles(x86)']]) if (base) candidates.push(win.join(base, 'Git', 'bin', 'bash.exe'));
|
|
22
|
+
if (env.LOCALAPPDATA) candidates.push(win.join(env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe'));
|
|
23
|
+
return [...new Set(candidates)];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The shell behind run_command. POSIX keeps the user's login shell. Windows
|
|
27
|
+
// prefers Git Bash (the model writes bash most reliably), then PowerShell.
|
|
28
|
+
// C:\Windows\System32\bash.exe is WSL, which would run in a different system,
|
|
29
|
+
// so bash is never taken from PATH there.
|
|
30
|
+
export async function findShell(env = process.env, platform = process.platform) {
|
|
31
|
+
if (platform !== 'win32') return { kind: 'posix', file: env.SHELL || '/bin/bash' };
|
|
32
|
+
const chosen = env.CLAUDE_REMOTE_SHELL;
|
|
33
|
+
if (chosen) return { kind: /(^|[\\/])(pwsh|powershell)(\.exe)?$/i.test(chosen) ? 'powershell' : 'bash', file: chosen };
|
|
34
|
+
for (const file of await gitBashCandidates(env)) if (await exists(file)) return { kind: 'bash', file };
|
|
35
|
+
const pwsh = await which('pwsh', env.PATH || env.Path || '', { platform });
|
|
36
|
+
if (pwsh) return { kind: 'powershell', file: pwsh };
|
|
37
|
+
return { kind: 'powershell',
|
|
38
|
+
file: win.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function describeShell(shell) {
|
|
42
|
+
if (shell.kind === 'bash') return 'Git Bash';
|
|
43
|
+
if (shell.kind === 'powershell') return 'PowerShell';
|
|
44
|
+
return path.basename(shell.file);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Windows limits a command line to 32767 characters, so commands there are
|
|
48
|
+
// written to a temporary script instead of being passed as an argument.
|
|
49
|
+
export async function prepareCommand(shell, command, directory, env = process.env) {
|
|
50
|
+
if (shell.kind === 'posix')
|
|
51
|
+
return { file: shell.file, args: ['-lc', `cd -- ${quote(directory)} && ${command}`], env, cleanup: async () => {} };
|
|
52
|
+
const script = path.join(os.tmpdir(), `claude-remote-${randomUUID()}.${shell.kind === 'bash' ? 'sh' : 'ps1'}`);
|
|
53
|
+
const cleanup = () => fs.rm(script, { force: true }).catch(() => {});
|
|
54
|
+
if (shell.kind === 'bash') {
|
|
55
|
+
// MSYS accepts C:/x paths. CHERE_INVOKING stops the login profile from changing to $HOME.
|
|
56
|
+
await fs.writeFile(script, `cd -- ${quote(directory.replaceAll('\\', '/'))} || exit 1\n${command}\n`, { mode: 0o600 });
|
|
57
|
+
return { file: shell.file, args: ['-l', script.replaceAll('\\', '/')], env: { ...env, CHERE_INVOKING: '1' }, cleanup };
|
|
58
|
+
}
|
|
59
|
+
// A BOM makes Windows PowerShell 5.1 read the script as UTF-8. Mirror bash:
|
|
60
|
+
// the exit code follows the last statement, including native programs.
|
|
61
|
+
await fs.writeFile(script, '\uFEFF' + [
|
|
62
|
+
"$ProgressPreference = 'SilentlyContinue'",
|
|
63
|
+
'try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}',
|
|
64
|
+
'$OutputEncoding = [System.Text.Encoding]::UTF8',
|
|
65
|
+
'Set-Location -LiteralPath ' + psQuote(directory),
|
|
66
|
+
command,
|
|
67
|
+
'if (-not $?) { if ($LASTEXITCODE) { exit $LASTEXITCODE }; exit 1 }',
|
|
68
|
+
'exit 0', ''
|
|
69
|
+
].join('\r\n'), { mode: 0o600 });
|
|
70
|
+
return { file: shell.file, args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script],
|
|
71
|
+
env, cleanup };
|
|
72
|
+
}
|