@yanzc/claude-pro 0.0.0-stage → 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/.env.example +41 -0
- package/LICENSE +21 -0
- package/README.md +314 -3
- package/bin/claude-pro.js +32 -0
- package/package.json +40 -6
- package/src/agent.js +752 -0
- package/src/agents.js +186 -0
- package/src/api.js +529 -0
- package/src/cache.js +150 -0
- package/src/checkpoints.js +184 -0
- package/src/claude-code.js +60 -0
- package/src/codex/client.js +307 -0
- package/src/codex/translate-request.js +239 -0
- package/src/codex/translate-stream.js +328 -0
- package/src/commands.js +839 -0
- package/src/config.js +453 -0
- package/src/context.js +109 -0
- package/src/filestate.js +57 -0
- package/src/gateway.js +143 -0
- package/src/hooks.js +81 -0
- package/src/main.js +193 -0
- package/src/mcp.js +290 -0
- package/src/modes.js +154 -0
- package/src/oauth.js +287 -0
- package/src/peers.js +157 -0
- package/src/print.js +106 -0
- package/src/prompts.js +179 -0
- package/src/ratelimit.js +244 -0
- package/src/restart.js +115 -0
- package/src/runtime.js +162 -0
- package/src/selfcheck.js +71 -0
- package/src/session.js +139 -0
- package/src/supervisor.js +110 -0
- package/src/tasks.js +76 -0
- package/src/tools/bash.js +227 -0
- package/src/tools/effort.js +41 -0
- package/src/tools/files.js +352 -0
- package/src/tools/index.js +52 -0
- package/src/tools/interaction.js +157 -0
- package/src/tools/notebook.js +91 -0
- package/src/tools/peers.js +45 -0
- package/src/tools/restart.js +50 -0
- package/src/tools/search.js +458 -0
- package/src/tools/skill.js +32 -0
- package/src/tools/task.js +139 -0
- package/src/tools/web.js +283 -0
- package/src/ui/ansi.js +228 -0
- package/src/ui/dialogs.js +122 -0
- package/src/ui/editor.js +389 -0
- package/src/ui/markdown.js +249 -0
- package/src/ui/render.js +155 -0
- package/src/ui/repl.js +1460 -0
- package/src/ui/screen.js +259 -0
- package/src/updater.js +93 -0
- package/src/utils/clipboard.js +197 -0
- package/src/utils/diff.js +95 -0
- package/src/utils/paths.js +37 -0
- package/src/utils/shell.js +251 -0
package/src/mcp.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Minimal Model Context Protocol client. Supports stdio servers and streamable HTTP
|
|
2
|
+
// servers configured like Claude Code (.mcp.json or "mcpServers" in settings.json):
|
|
3
|
+
// { "mcpServers": { "name": { "command": "npx", "args": [...], "env": {...} },
|
|
4
|
+
// "remote": { "type": "http", "url": "https://...", "headers": {...} } } }
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { VERSION } from './config.js';
|
|
8
|
+
import { killTree } from './utils/shell.js';
|
|
9
|
+
|
|
10
|
+
const PROTOCOL_VERSION = '2025-06-18';
|
|
11
|
+
const isWin = process.platform === 'win32';
|
|
12
|
+
|
|
13
|
+
function quoteWinArg(a) {
|
|
14
|
+
if (a === '') return '""';
|
|
15
|
+
if (!/[\s"&|<>^()%!]/.test(a)) return a;
|
|
16
|
+
return '"' + a.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1') + '"';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function expandEnv(value) {
|
|
20
|
+
return String(value).replace(/\$\{(\w+)(?::-([^}]*))?\}/g, (m, name, def) => process.env[name] ?? def ?? '');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class StdioTransport {
|
|
24
|
+
constructor(name, cfg, cwd) {
|
|
25
|
+
this.name = name;
|
|
26
|
+
this.cfg = cfg;
|
|
27
|
+
this.cwd = cwd;
|
|
28
|
+
this.pending = new Map();
|
|
29
|
+
this.nextId = 1;
|
|
30
|
+
this.stderr = '';
|
|
31
|
+
this.closed = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async connect() {
|
|
35
|
+
const env = { ...process.env };
|
|
36
|
+
for (const [k, v] of Object.entries(this.cfg.env || {})) env[k] = expandEnv(v);
|
|
37
|
+
const command = expandEnv(this.cfg.command);
|
|
38
|
+
const args = (this.cfg.args || []).map(expandEnv);
|
|
39
|
+
if (isWin) {
|
|
40
|
+
const line = [command, ...args].map(quoteWinArg).join(' ');
|
|
41
|
+
this.child = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `"${line}"`], { env, cwd: this.cwd, windowsHide: true, windowsVerbatimArguments: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
42
|
+
} else {
|
|
43
|
+
this.child = spawn(command, args, { env, cwd: this.cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
44
|
+
}
|
|
45
|
+
let buf = '';
|
|
46
|
+
// Decode as a stream: a multi-byte character (CJK text) can be split between two chunks.
|
|
47
|
+
this.child.stdout.setEncoding('utf8');
|
|
48
|
+
this.child.stderr.setEncoding('utf8');
|
|
49
|
+
this.child.stdout.on('data', (d) => {
|
|
50
|
+
buf += d;
|
|
51
|
+
let i;
|
|
52
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
53
|
+
const line = buf.slice(0, i).trim();
|
|
54
|
+
buf = buf.slice(i + 1);
|
|
55
|
+
if (!line) continue;
|
|
56
|
+
let msg;
|
|
57
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
58
|
+
this.onMessage(msg);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
this.child.stderr.on('data', (d) => { this.stderr = (this.stderr + d).slice(-4000); });
|
|
62
|
+
const failAll = (err) => {
|
|
63
|
+
this.closed = true;
|
|
64
|
+
for (const p of this.pending.values()) p.reject(err);
|
|
65
|
+
this.pending.clear();
|
|
66
|
+
};
|
|
67
|
+
this.child.on('error', (e) => failAll(e));
|
|
68
|
+
this.child.on('close', (code) => failAll(new Error(`MCP server "${this.name}" exited (code ${code}). ${this.stderr.trim().split('\n').slice(-3).join(' ')}`)));
|
|
69
|
+
this.child.stdin.on('error', () => {});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
onMessage(msg) {
|
|
73
|
+
if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
|
|
74
|
+
const p = this.pending.get(msg.id);
|
|
75
|
+
if (!p) return;
|
|
76
|
+
this.pending.delete(msg.id);
|
|
77
|
+
if (msg.error) p.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
|
|
78
|
+
else p.resolve(msg.result);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Server -> client requests we don't support get a polite error; pings get an answer.
|
|
82
|
+
if (msg.id !== undefined && msg.method) {
|
|
83
|
+
const reply = msg.method === 'ping'
|
|
84
|
+
? { jsonrpc: '2.0', id: msg.id, result: {} }
|
|
85
|
+
: { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'Method not supported by client' } };
|
|
86
|
+
this.send(reply);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
send(obj) {
|
|
91
|
+
if (!this.closed) this.child.stdin.write(JSON.stringify(obj) + '\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
request(method, params, { timeout = 60_000, signal } = {}) {
|
|
95
|
+
if (this.closed) return Promise.reject(new Error(`MCP server "${this.name}" is not running`));
|
|
96
|
+
const aborted = () => Object.assign(new Error('aborted'), { name: 'AbortError' });
|
|
97
|
+
if (signal?.aborted) return Promise.reject(aborted());
|
|
98
|
+
const id = this.nextId++;
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
// The abort listener must go as soon as the request is over: left in place it would send a
|
|
101
|
+
// cancellation for a request that already finished when the signal is aborted later.
|
|
102
|
+
const onAbort = () => {
|
|
103
|
+
this.pending.delete(id);
|
|
104
|
+
cleanup();
|
|
105
|
+
this.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: id, reason: 'User interrupted' } });
|
|
106
|
+
reject(aborted());
|
|
107
|
+
};
|
|
108
|
+
const timer = setTimeout(() => { this.pending.delete(id); cleanup(); reject(new Error(`MCP request ${method} timed out after ${timeout}ms`)); }, timeout);
|
|
109
|
+
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); };
|
|
110
|
+
this.pending.set(id, { resolve: (v) => { cleanup(); resolve(v); }, reject: (e) => { cleanup(); reject(e); } });
|
|
111
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
112
|
+
this.send({ jsonrpc: '2.0', id, method, params });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
notify(method, params) { this.send({ jsonrpc: '2.0', method, params }); }
|
|
117
|
+
close() { this.closed = true; killTree(this.child); }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
class HttpTransport {
|
|
121
|
+
constructor(name, cfg) {
|
|
122
|
+
this.name = name;
|
|
123
|
+
this.url = expandEnv(cfg.url);
|
|
124
|
+
this.headers = Object.fromEntries(Object.entries(cfg.headers || {}).map(([k, v]) => [k, expandEnv(v)]));
|
|
125
|
+
this.nextId = 1;
|
|
126
|
+
this.sessionId = null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async connect() {}
|
|
130
|
+
|
|
131
|
+
async post(body, signal, timeout) {
|
|
132
|
+
const ctrl = new AbortController();
|
|
133
|
+
// A timeout must not look like the user's interrupt: both abort the fetch, and an AbortError
|
|
134
|
+
// would end the whole agent turn as "interrupted by user".
|
|
135
|
+
let timedOut = false;
|
|
136
|
+
const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, timeout);
|
|
137
|
+
const onAbort = () => ctrl.abort();
|
|
138
|
+
if (signal?.aborted) ctrl.abort();
|
|
139
|
+
else signal?.addEventListener('abort', onAbort, { once: true });
|
|
140
|
+
try {
|
|
141
|
+
const res = await fetch(this.url, {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
headers: {
|
|
144
|
+
'Content-Type': 'application/json',
|
|
145
|
+
Accept: 'application/json, text/event-stream',
|
|
146
|
+
'MCP-Protocol-Version': PROTOCOL_VERSION,
|
|
147
|
+
...(this.sessionId ? { 'Mcp-Session-Id': this.sessionId } : {}),
|
|
148
|
+
...this.headers,
|
|
149
|
+
},
|
|
150
|
+
body: JSON.stringify(body),
|
|
151
|
+
signal: ctrl.signal,
|
|
152
|
+
});
|
|
153
|
+
this.sessionId = res.headers.get('mcp-session-id') || this.sessionId;
|
|
154
|
+
if (!res.ok && res.status !== 202) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
155
|
+
if (body.id === undefined) return null;
|
|
156
|
+
const type = res.headers.get('content-type') || '';
|
|
157
|
+
const text = await res.text();
|
|
158
|
+
if (type.includes('text/event-stream')) {
|
|
159
|
+
for (const event of text.split(/\r?\n\r?\n/)) {
|
|
160
|
+
const data = event.split(/\r?\n/).filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim()).join('\n');
|
|
161
|
+
if (!data) continue;
|
|
162
|
+
try {
|
|
163
|
+
const msg = JSON.parse(data);
|
|
164
|
+
if (msg.id === body.id) return msg;
|
|
165
|
+
} catch { /* skip */ }
|
|
166
|
+
}
|
|
167
|
+
throw new Error('No response in event stream');
|
|
168
|
+
}
|
|
169
|
+
return JSON.parse(text);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
if (timedOut && !signal?.aborted) throw new Error(`MCP request ${body.method} timed out after ${timeout}ms`);
|
|
172
|
+
throw e;
|
|
173
|
+
} finally {
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
signal?.removeEventListener('abort', onAbort);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async request(method, params, { timeout = 60_000, signal } = {}) {
|
|
180
|
+
const msg = await this.post({ jsonrpc: '2.0', id: this.nextId++, method, params }, signal, timeout);
|
|
181
|
+
if (msg.error) throw new Error(msg.error.message || JSON.stringify(msg.error));
|
|
182
|
+
return msg.result;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Returns a promise that never rejects, so callers can wait until the server has taken the notification.
|
|
186
|
+
notify(method, params) { return this.post({ jsonrpc: '2.0', method, params }, null, 10_000).catch(() => {}); }
|
|
187
|
+
close() {}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const sanitize = (s) => String(s).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
191
|
+
|
|
192
|
+
function convertContent(result) {
|
|
193
|
+
const blocks = [];
|
|
194
|
+
for (const c of result.content || []) {
|
|
195
|
+
if (c.type === 'text') blocks.push({ type: 'text', text: c.text });
|
|
196
|
+
else if (c.type === 'image') blocks.push({ type: 'image', source: { type: 'base64', media_type: c.mimeType, data: c.data } });
|
|
197
|
+
else if (c.type === 'resource') blocks.push({ type: 'text', text: c.resource?.text ?? `[resource ${c.resource?.uri}]` });
|
|
198
|
+
else if (c.type === 'resource_link') blocks.push({ type: 'text', text: `[resource ${c.name || ''} ${c.uri}]` });
|
|
199
|
+
else blocks.push({ type: 'text', text: JSON.stringify(c) });
|
|
200
|
+
}
|
|
201
|
+
if (!blocks.length && result.structuredContent) blocks.push({ type: 'text', text: JSON.stringify(result.structuredContent, null, 2) });
|
|
202
|
+
if (!blocks.length) blocks.push({ type: 'text', text: '(no content)' });
|
|
203
|
+
return blocks.every((b) => b.type === 'text') ? blocks.map((b) => b.text).join('\n') : blocks;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export class McpManager {
|
|
207
|
+
constructor(servers = {}, { cwd }) {
|
|
208
|
+
this.servers = servers;
|
|
209
|
+
this.cwd = cwd;
|
|
210
|
+
this.clients = new Map(); // name -> {transport, tools, status, error}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async connectOne(name, cfg) {
|
|
214
|
+
const entry = { status: 'connecting', tools: [], error: null, config: cfg };
|
|
215
|
+
this.clients.set(name, entry);
|
|
216
|
+
try {
|
|
217
|
+
const type = cfg.type || (cfg.url ? 'http' : 'stdio');
|
|
218
|
+
if (type === 'sse') throw new Error('legacy SSE transport is not supported; use "type": "http"');
|
|
219
|
+
const transport = type === 'http' ? new HttpTransport(name, cfg) : new StdioTransport(name, cfg, this.cwd);
|
|
220
|
+
entry.transport = transport;
|
|
221
|
+
await transport.connect();
|
|
222
|
+
const timeout = parseInt(process.env.MCP_TIMEOUT, 10) || 30_000;
|
|
223
|
+
const init = await transport.request('initialize', {
|
|
224
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
225
|
+
capabilities: {},
|
|
226
|
+
clientInfo: { name: 'claude-pro', version: VERSION },
|
|
227
|
+
}, { timeout });
|
|
228
|
+
entry.serverInfo = init?.serverInfo;
|
|
229
|
+
entry.instructions = init?.instructions;
|
|
230
|
+
// Over HTTP the notification is a request of its own: tools/list must not overtake it, or a
|
|
231
|
+
// server that insists on a finished handshake answers "not initialized".
|
|
232
|
+
await transport.notify('notifications/initialized', {});
|
|
233
|
+
const tools = [];
|
|
234
|
+
let cursor;
|
|
235
|
+
do {
|
|
236
|
+
const page = await transport.request('tools/list', cursor ? { cursor } : {}, { timeout });
|
|
237
|
+
tools.push(...(page?.tools || []));
|
|
238
|
+
cursor = page?.nextCursor;
|
|
239
|
+
} while (cursor);
|
|
240
|
+
entry.tools = tools;
|
|
241
|
+
entry.status = 'connected';
|
|
242
|
+
} catch (e) {
|
|
243
|
+
entry.status = 'failed';
|
|
244
|
+
entry.error = e.message;
|
|
245
|
+
entry.transport?.close();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async start(onWarn) {
|
|
250
|
+
const names = Object.keys(this.servers || {});
|
|
251
|
+
if (!names.length) return;
|
|
252
|
+
await Promise.all(names.map((n) => this.connectOne(n, this.servers[n])));
|
|
253
|
+
for (const [name, e] of this.clients) if (e.status === 'failed') onWarn?.(`MCP server "${name}" failed to connect: ${e.error}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
tools() {
|
|
257
|
+
const out = [];
|
|
258
|
+
const toolTimeout = parseInt(process.env.MCP_TOOL_TIMEOUT, 10) || 600_000;
|
|
259
|
+
for (const [server, entry] of this.clients) {
|
|
260
|
+
if (entry.status !== 'connected') continue;
|
|
261
|
+
for (const t of entry.tools) {
|
|
262
|
+
const name = `mcp__${sanitize(server)}__${sanitize(t.name)}`.slice(0, 64);
|
|
263
|
+
out.push({
|
|
264
|
+
name,
|
|
265
|
+
mcp: { server, tool: t.name },
|
|
266
|
+
description: (t.description || `${t.name} (MCP tool from ${server})`).slice(0, 4000),
|
|
267
|
+
input_schema: t.inputSchema && t.inputSchema.type === 'object' ? t.inputSchema : { type: 'object', properties: {} },
|
|
268
|
+
summarize(input) {
|
|
269
|
+
const s = JSON.stringify(input);
|
|
270
|
+
return s.length > 80 ? s.slice(0, 80) + '…' : s;
|
|
271
|
+
},
|
|
272
|
+
label() { return `${server} - ${t.title || t.name} (MCP)`; },
|
|
273
|
+
async call(input, ctx) {
|
|
274
|
+
const result = await entry.transport.request('tools/call', { name: t.name, arguments: input }, { timeout: toolTimeout, signal: ctx.signal });
|
|
275
|
+
return { content: convertContent(result || {}), isError: !!result?.isError };
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
status() {
|
|
284
|
+
return [...this.clients.entries()].map(([name, e]) => ({ name, status: e.status, tools: e.tools.length, error: e.error, serverInfo: e.serverInfo }));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
close() {
|
|
288
|
+
for (const e of this.clients.values()) e.transport?.close();
|
|
289
|
+
}
|
|
290
|
+
}
|
package/src/modes.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Session mode. There are no permission prompts or rules: every tool call runs right away.
|
|
2
|
+
// The only switch is plan mode, an opt-in read-only phase for investigating and proposing a
|
|
3
|
+
// plan before changing anything (shift+tab or --plan turns it on; ExitPlanMode turns it off).
|
|
4
|
+
|
|
5
|
+
export const MODES = ['full', 'plan'];
|
|
6
|
+
|
|
7
|
+
// Split a shell command into simple commands on && || ; | and newlines (quote aware).
|
|
8
|
+
export function splitCommand(cmd) {
|
|
9
|
+
const parts = [];
|
|
10
|
+
let cur = '', quote = null;
|
|
11
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
12
|
+
const ch = cmd[i];
|
|
13
|
+
if (quote) {
|
|
14
|
+
cur += ch;
|
|
15
|
+
if (ch === '\\' && quote === '"' && i + 1 < cmd.length) { cur += cmd[++i]; continue; }
|
|
16
|
+
if (ch === quote) quote = null;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue; }
|
|
20
|
+
if (ch === '\\' && i + 1 < cmd.length) { cur += ch + cmd[++i]; continue; }
|
|
21
|
+
const two = cmd.slice(i, i + 2);
|
|
22
|
+
if (two === '&&' || two === '||') { parts.push(cur); cur = ''; i++; continue; }
|
|
23
|
+
if (ch === ';' || ch === '|' || ch === '\n') { parts.push(cur); cur = ''; continue; }
|
|
24
|
+
if (ch === '&' && cmd[i + 1] !== '>' && cmd[i - 1] !== '>') { parts.push(cur); cur = ''; continue; }
|
|
25
|
+
cur += ch;
|
|
26
|
+
}
|
|
27
|
+
parts.push(cur);
|
|
28
|
+
return parts.map((p) => p.trim()).filter(Boolean);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stripQuotedStrings(s) {
|
|
32
|
+
return s.replace(/'[^']*'/g, "''").replace(/"(?:\\.|[^"\\])*"/g, '""');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Syntax we can't classify: command substitution, redirection to files, etc.
|
|
36
|
+
function hasUnclassifiableSyntax(cmd) {
|
|
37
|
+
const s = stripQuotedStrings(cmd);
|
|
38
|
+
if (/\$\(|`|<\(|>\(/.test(s)) return true;
|
|
39
|
+
// Redirections other than to /dev/null or fd duplication.
|
|
40
|
+
const redir = s.replace(/\d?>&\d/g, '').replace(/&?>>?\s*\/dev\/null/g, '').replace(/\d?>>?\s*\/dev\/null/g, '');
|
|
41
|
+
return /[<>]/.test(redir);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Commands that read only, whatever their arguments (the ones whose arguments can make them write or run
|
|
45
|
+
// something have their own check in COMMAND_CHECKS below). No pagers: `less +!cmd` runs a command.
|
|
46
|
+
const READ_ONLY_SIMPLE = new Set([
|
|
47
|
+
'ls', 'pwd', 'echo', 'cat', 'head', 'tail', 'wc', 'which', 'whoami', 'stat', 'du', 'df',
|
|
48
|
+
'uname', 'printenv', 'type', 'realpath', 'basename', 'dirname', 'cut', 'tr', 'diff',
|
|
49
|
+
'grep', 'egrep', 'fgrep', 'true', 'false', 'test', 'nproc', 'id', 'cd', 'column', 'jq', 'md5sum', 'sha256sum',
|
|
50
|
+
]);
|
|
51
|
+
const READ_ONLY_PREFIXES = [
|
|
52
|
+
'git status', 'git rev-parse', 'git ls-files', 'git blame', 'git describe', 'git stash list',
|
|
53
|
+
'git config --get', 'git config -l', 'git config --list',
|
|
54
|
+
'node --version', 'node -v', 'npm --version', 'npm -v', 'npm ls', 'npm list', 'npm view', 'npm outdated', 'pnpm --version',
|
|
55
|
+
'yarn --version', 'python --version', 'python3 --version', 'pip list', 'pip show', 'pip --version', 'go version',
|
|
56
|
+
'cargo --version', 'rustc --version', 'java -version', 'dotnet --version', 'docker ps', 'docker images', 'where',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// `git branch` only lists with these flags; a name, -d/-D, -m/-M, -c/-C, -u ... would create, delete or rename.
|
|
60
|
+
const GIT_BRANCH_FLAGS = new Set(['-a', '--all', '-r', '--remotes', '-v', '-vv', '--verbose', '-l', '--list', '--show-current', '--no-color', '--color', '--no-column', '--column']);
|
|
61
|
+
const GIT_BRANCH_VALUE_FLAGS = /^--(?:contains|no-contains|merged|no-merged|points-at|sort|format|abbrev)(?:=.*)?$/;
|
|
62
|
+
function gitBranchReads(args) {
|
|
63
|
+
let listing = false;
|
|
64
|
+
for (let i = 0; i < args.length; i++) {
|
|
65
|
+
const a = args[i];
|
|
66
|
+
if (GIT_BRANCH_FLAGS.has(a)) { if (a === '-l' || a === '--list') listing = true; continue; }
|
|
67
|
+
if (GIT_BRANCH_VALUE_FLAGS.test(a)) { if (!a.includes('=') && !/^--abbrev$/.test(a)) i++; continue; }
|
|
68
|
+
if (listing && !a.startsWith('-')) continue; // a pattern to list
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// `git tag` likewise: listing is fine, anything that makes or removes a tag is not.
|
|
75
|
+
function gitTagReads(args) {
|
|
76
|
+
if (!args.some((a) => a === '-l' || a === '--list')) return false;
|
|
77
|
+
return args.every((a) => !a.startsWith('-') || /^(?:-l|--list|-n\d*|--sort=.*|--column|--no-column|--contains|--no-contains|--merged|--no-merged|--points-at|--format=.*|--color|--no-color)$/.test(a));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const positional = (args) => args.filter((a) => !a.startsWith('-') && !a.startsWith('+'));
|
|
81
|
+
|
|
82
|
+
// Commands that are read-only only with certain arguments: args => true when this call reads only.
|
|
83
|
+
const COMMAND_CHECKS = {
|
|
84
|
+
// -exec and -delete run or remove things, -fprint* and -fls write files
|
|
85
|
+
find: (args) => !args.some((a) => /^-(?:exec|execdir|ok|okdir|delete|fprint0?|fprintf|fls)$/.test(a)),
|
|
86
|
+
// -o FILE and --output FILE (GNU getopt accepts any unambiguous prefix of it)
|
|
87
|
+
sort: (args) => !args.some((a) => /^-[a-zA-Z]*o/.test(a) || /^--o/.test(a)),
|
|
88
|
+
// `uniq INPUT OUTPUT` writes OUTPUT
|
|
89
|
+
uniq: (args) => positional(args).length <= 1,
|
|
90
|
+
tree: (args) => !args.some((a) => /^-[a-zA-Z]*o/.test(a) || /^--output/.test(a)),
|
|
91
|
+
// setting the clock or the host name changes the machine
|
|
92
|
+
date: (args) => !args.some((a) => /^-[a-zA-Z]*s/.test(a) || /^--set/.test(a)) && positional(args).length === 0,
|
|
93
|
+
hostname: (args) => args.length === 0 || args.every((a) => /^(?:-[fsiIdAy]|--(?:fqdn|short|ip-address|domain|all-ip-addresses))$/.test(a)),
|
|
94
|
+
// --pre runs a command on every file
|
|
95
|
+
rg: (args) => !args.some((a) => /^--(?:pre|hostname-bin)(?:=|$)/.test(a)),
|
|
96
|
+
file: (args) => !args.some((a) => /^-[a-zA-Z]*C/.test(a) || a === '--compile'),
|
|
97
|
+
// `git log --output=FILE` and friends write the result to a file
|
|
98
|
+
git: (args, part) => {
|
|
99
|
+
const sub = args.find((a) => !a.startsWith('-'));
|
|
100
|
+
const rest = args.slice(args.indexOf(sub) + 1);
|
|
101
|
+
if (args[0] !== sub) return null; // options before the subcommand (-C dir, -c key=value): not classified
|
|
102
|
+
if (sub === 'diff' || sub === 'log' || sub === 'show') return !rest.some((a) => /^--output(?:=|$)/.test(a));
|
|
103
|
+
if (sub === 'branch') return gitBranchReads(rest);
|
|
104
|
+
if (sub === 'tag') return gitTagReads(rest);
|
|
105
|
+
if (sub === 'remote') return part === 'git remote' || part === 'git remote -v';
|
|
106
|
+
return null;
|
|
107
|
+
},
|
|
108
|
+
// `go env -w` and `-u` change the Go settings
|
|
109
|
+
go: (args) => (args[0] === 'env' ? !args.some((a) => a === '-w' || a === '-u') : args[0] === 'version'),
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// True when every part of the command only reads (used to let research commands run in plan mode).
|
|
113
|
+
export function isReadOnlyCommand(cmd) {
|
|
114
|
+
if (hasUnclassifiableSyntax(cmd)) return false;
|
|
115
|
+
return splitCommand(cmd).every((part) => {
|
|
116
|
+
const words = part.split(/\s+/);
|
|
117
|
+
const first = words[0];
|
|
118
|
+
if (/^\w+=/.test(first)) return false; // env assignment prefix
|
|
119
|
+
// hasOwn: a command called `constructor` or `toString` must not find a function on the prototype.
|
|
120
|
+
if (Object.hasOwn(COMMAND_CHECKS, first)) {
|
|
121
|
+
const verdict = COMMAND_CHECKS[first](words.slice(1), part);
|
|
122
|
+
if (verdict !== null) return verdict;
|
|
123
|
+
}
|
|
124
|
+
if (READ_ONLY_SIMPLE.has(first)) return true;
|
|
125
|
+
// A command that has an argument check is only ever read-only through that check, except where it
|
|
126
|
+
// is also in the simple list (above); the rest is matched by prefix.
|
|
127
|
+
return READ_ONLY_PREFIXES.some((p) => part === p || part.startsWith(`${p} `));
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class ModeManager {
|
|
132
|
+
constructor(mode = 'full') {
|
|
133
|
+
this.mode = MODES.includes(mode) ? mode : 'full';
|
|
134
|
+
this.listeners = new Set();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
setMode(mode) {
|
|
138
|
+
if (!MODES.includes(mode)) return;
|
|
139
|
+
this.mode = mode;
|
|
140
|
+
for (const l of this.listeners) l(mode);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
toggle() {
|
|
144
|
+
this.setMode(this.mode === 'plan' ? 'full' : 'plan');
|
|
145
|
+
return this.mode;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// {behavior: 'allow'} or, in plan mode for anything that changes state, {behavior: 'deny', message}.
|
|
149
|
+
check(tool, input = {}) {
|
|
150
|
+
if (this.mode !== 'plan' || tool.readOnly) return { behavior: 'allow' };
|
|
151
|
+
if (tool.name === 'Bash' && isReadOnlyCommand(String(input.command || ''))) return { behavior: 'allow' };
|
|
152
|
+
return { behavior: 'deny', message: 'Plan mode is active: do not make changes or run commands that modify anything yet. Keep researching with read-only tools, then present your plan with ExitPlanMode.' };
|
|
153
|
+
}
|
|
154
|
+
}
|