termux-dev 1.2.2 → 1.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 +48 -0
- package/assets/banner.svg +1 -1
- package/assets/preview.png +0 -0
- package/dist/cli/doctor.js +8 -2
- package/dist/cli/export.js +62 -0
- package/dist/cli/headless.js +110 -0
- package/dist/cli/index.js +411 -224
- package/dist/cli/prompt.js +16 -2
- package/dist/cli/server.js +1 -1
- package/dist/cli/updater.js +0 -6
- package/dist/core/commands.js +100 -0
- package/dist/core/history.js +10 -0
- package/dist/core/loop.js +5 -1
- package/dist/core/models.js +1 -1
- package/dist/core/pricing.js +1 -1
- package/dist/core/repomap.js +135 -0
- package/dist/core/usage.js +119 -0
- package/dist/mcp/client.js +218 -0
- package/dist/mcp/manager.js +196 -0
- package/dist/mcp/types.js +1 -0
- package/dist/permissions/guard.js +69 -15
- package/dist/prompts/builder.js +9 -0
- package/dist/providers/openai.js +37 -8
- package/dist/tools/bash.js +7 -0
- package/dist/tools/fs.js +1 -1
- package/dist/tools/index.js +4 -2
- package/dist/tools/web.js +3 -3
- package/package.json +1 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
export class MCPClient {
|
|
3
|
+
name;
|
|
4
|
+
config;
|
|
5
|
+
process = null;
|
|
6
|
+
nextRequestId = 1;
|
|
7
|
+
pendingRequests = new Map();
|
|
8
|
+
buffer = '';
|
|
9
|
+
tools = [];
|
|
10
|
+
isConnected = false;
|
|
11
|
+
constructor(name, config) {
|
|
12
|
+
this.name = name;
|
|
13
|
+
this.config = config;
|
|
14
|
+
}
|
|
15
|
+
async start() {
|
|
16
|
+
if (this.config.disabled) {
|
|
17
|
+
throw new Error(`MCP server "${this.name}" is disabled in configuration.`);
|
|
18
|
+
}
|
|
19
|
+
return new Promise(async (resolve, reject) => {
|
|
20
|
+
let isSettled = false;
|
|
21
|
+
const initialTimer = setTimeout(() => {
|
|
22
|
+
if (!isSettled) {
|
|
23
|
+
isSettled = true;
|
|
24
|
+
this.close();
|
|
25
|
+
reject(new Error(`MCP server "${this.name}" initialization timed out after 15s.`));
|
|
26
|
+
}
|
|
27
|
+
}, 15000);
|
|
28
|
+
try {
|
|
29
|
+
const env = {
|
|
30
|
+
...process.env,
|
|
31
|
+
...(this.config.env || {})
|
|
32
|
+
};
|
|
33
|
+
const isWindows = process.platform === 'win32';
|
|
34
|
+
const useShell = isWindows && (this.config.command.endsWith('.cmd') ||
|
|
35
|
+
this.config.command.endsWith('.bat') ||
|
|
36
|
+
this.config.command === 'npx' ||
|
|
37
|
+
this.config.command === 'npm');
|
|
38
|
+
this.process = spawn(this.config.command, this.config.args || [], {
|
|
39
|
+
env,
|
|
40
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
41
|
+
shell: useShell
|
|
42
|
+
});
|
|
43
|
+
this.process.stdout?.on('data', (data) => {
|
|
44
|
+
this.handleStdout(data.toString());
|
|
45
|
+
});
|
|
46
|
+
this.process.stderr?.on('data', (_data) => {
|
|
47
|
+
// Stderr from MCP servers is used for logging/debugging
|
|
48
|
+
});
|
|
49
|
+
this.process.on('error', (err) => {
|
|
50
|
+
if (!isSettled) {
|
|
51
|
+
isSettled = true;
|
|
52
|
+
clearTimeout(initialTimer);
|
|
53
|
+
reject(new Error(`Failed to start MCP server "${this.name}": ${err.message}`));
|
|
54
|
+
}
|
|
55
|
+
this.cleanup();
|
|
56
|
+
});
|
|
57
|
+
this.process.on('close', (_code) => {
|
|
58
|
+
this.cleanup();
|
|
59
|
+
});
|
|
60
|
+
// 1. Initialize Handshake
|
|
61
|
+
const initResult = await this.sendRequest('initialize', {
|
|
62
|
+
protocolVersion: '2024-11-05',
|
|
63
|
+
capabilities: {},
|
|
64
|
+
clientInfo: {
|
|
65
|
+
name: 'devx',
|
|
66
|
+
version: '1.4.0'
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
if (!initResult) {
|
|
70
|
+
throw new Error(`Invalid initialize response from MCP server "${this.name}".`);
|
|
71
|
+
}
|
|
72
|
+
// 2. Send initialized notification
|
|
73
|
+
this.sendNotification('notifications/initialized', {});
|
|
74
|
+
// 3. Fetch Tools List
|
|
75
|
+
const toolsResult = await this.sendRequest('tools/list', {});
|
|
76
|
+
this.tools = toolsResult?.tools || [];
|
|
77
|
+
this.isConnected = true;
|
|
78
|
+
if (!isSettled) {
|
|
79
|
+
isSettled = true;
|
|
80
|
+
clearTimeout(initialTimer);
|
|
81
|
+
resolve(this.tools);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
if (!isSettled) {
|
|
86
|
+
isSettled = true;
|
|
87
|
+
clearTimeout(initialTimer);
|
|
88
|
+
this.close();
|
|
89
|
+
reject(err);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
getTools() {
|
|
95
|
+
return this.tools;
|
|
96
|
+
}
|
|
97
|
+
hasConnected() {
|
|
98
|
+
return this.isConnected;
|
|
99
|
+
}
|
|
100
|
+
async callTool(toolName, args) {
|
|
101
|
+
if (!this.process || !this.isConnected) {
|
|
102
|
+
throw new Error(`MCP server "${this.name}" is not connected.`);
|
|
103
|
+
}
|
|
104
|
+
const res = await this.sendRequest('tools/call', {
|
|
105
|
+
name: toolName,
|
|
106
|
+
arguments: args || {}
|
|
107
|
+
}, 60000); // 60s timeout for tool calls
|
|
108
|
+
if (!res || !res.content) {
|
|
109
|
+
return JSON.stringify(res || {});
|
|
110
|
+
}
|
|
111
|
+
const outputParts = [];
|
|
112
|
+
for (const c of res.content) {
|
|
113
|
+
if (c.type === 'text' && c.text) {
|
|
114
|
+
outputParts.push(c.text);
|
|
115
|
+
}
|
|
116
|
+
else if (c.type === 'image' && c.data) {
|
|
117
|
+
outputParts.push(`[Image content (${c.mimeType || 'image/png'})]`);
|
|
118
|
+
}
|
|
119
|
+
else if (c.type === 'resource') {
|
|
120
|
+
outputParts.push(`[Resource: ${JSON.stringify(c)}]`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const finalResult = outputParts.join('\n\n') || JSON.stringify(res);
|
|
124
|
+
if (res.isError) {
|
|
125
|
+
throw new Error(finalResult);
|
|
126
|
+
}
|
|
127
|
+
return finalResult;
|
|
128
|
+
}
|
|
129
|
+
handleStdout(chunk) {
|
|
130
|
+
this.buffer += chunk;
|
|
131
|
+
const lines = this.buffer.split('\n');
|
|
132
|
+
this.buffer = lines.pop() || '';
|
|
133
|
+
for (const line of lines) {
|
|
134
|
+
const trimmed = line.trim();
|
|
135
|
+
if (!trimmed)
|
|
136
|
+
continue;
|
|
137
|
+
try {
|
|
138
|
+
const msg = JSON.parse(trimmed);
|
|
139
|
+
if ('id' in msg && msg.id !== undefined) {
|
|
140
|
+
const pending = this.pendingRequests.get(msg.id);
|
|
141
|
+
if (pending) {
|
|
142
|
+
clearTimeout(pending.timer);
|
|
143
|
+
this.pendingRequests.delete(msg.id);
|
|
144
|
+
if (msg.error) {
|
|
145
|
+
pending.reject(new Error(`MCP error ${msg.error.code}: ${msg.error.message}`));
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
pending.resolve(msg.result);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Ignore non-JSON line outputs (e.g. startup banner)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
sendRequest(method, params, timeoutMs = 15000) {
|
|
159
|
+
return new Promise((resolve, reject) => {
|
|
160
|
+
if (!this.process || !this.process.stdin) {
|
|
161
|
+
return reject(new Error(`MCP server "${this.name}" process is not running.`));
|
|
162
|
+
}
|
|
163
|
+
const id = this.nextRequestId++;
|
|
164
|
+
const timer = setTimeout(() => {
|
|
165
|
+
if (this.pendingRequests.has(id)) {
|
|
166
|
+
this.pendingRequests.delete(id);
|
|
167
|
+
reject(new Error(`MCP request "${method}" to server "${this.name}" timed out (${timeoutMs / 1000}s).`));
|
|
168
|
+
}
|
|
169
|
+
}, timeoutMs);
|
|
170
|
+
this.pendingRequests.set(id, { resolve, reject, timer });
|
|
171
|
+
const request = {
|
|
172
|
+
jsonrpc: '2.0',
|
|
173
|
+
id,
|
|
174
|
+
method,
|
|
175
|
+
params
|
|
176
|
+
};
|
|
177
|
+
try {
|
|
178
|
+
this.process.stdin.write(JSON.stringify(request) + '\n');
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
this.pendingRequests.delete(id);
|
|
183
|
+
reject(new Error(`Failed to write to MCP server "${this.name}": ${err.message}`));
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
sendNotification(method, params) {
|
|
188
|
+
if (!this.process || !this.process.stdin)
|
|
189
|
+
return;
|
|
190
|
+
const notif = {
|
|
191
|
+
jsonrpc: '2.0',
|
|
192
|
+
method,
|
|
193
|
+
params
|
|
194
|
+
};
|
|
195
|
+
try {
|
|
196
|
+
this.process.stdin.write(JSON.stringify(notif) + '\n');
|
|
197
|
+
}
|
|
198
|
+
catch { }
|
|
199
|
+
}
|
|
200
|
+
close() {
|
|
201
|
+
this.cleanup();
|
|
202
|
+
if (this.process) {
|
|
203
|
+
try {
|
|
204
|
+
this.process.kill();
|
|
205
|
+
}
|
|
206
|
+
catch { }
|
|
207
|
+
this.process = null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
cleanup() {
|
|
211
|
+
this.isConnected = false;
|
|
212
|
+
for (const [id, req] of this.pendingRequests.entries()) {
|
|
213
|
+
clearTimeout(req.timer);
|
|
214
|
+
req.reject(new Error(`MCP server "${this.name}" disconnected.`));
|
|
215
|
+
}
|
|
216
|
+
this.pendingRequests.clear();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import fsSync from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import pc from 'picocolors';
|
|
6
|
+
import { MCPClient } from './client.js';
|
|
7
|
+
import { getCurrentTheme } from '../cli/theme.js';
|
|
8
|
+
function stripAnsi(str) {
|
|
9
|
+
return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
|
10
|
+
}
|
|
11
|
+
export class MCPManager {
|
|
12
|
+
static instance;
|
|
13
|
+
clients = new Map();
|
|
14
|
+
tools = [];
|
|
15
|
+
serverStatuses = new Map();
|
|
16
|
+
isInitialized = false;
|
|
17
|
+
constructor() { }
|
|
18
|
+
static getInstance() {
|
|
19
|
+
if (!MCPManager.instance) {
|
|
20
|
+
MCPManager.instance = new MCPManager();
|
|
21
|
+
}
|
|
22
|
+
return MCPManager.instance;
|
|
23
|
+
}
|
|
24
|
+
async init(explicitConfigs) {
|
|
25
|
+
if (this.isInitialized) {
|
|
26
|
+
return this.tools;
|
|
27
|
+
}
|
|
28
|
+
const configs = explicitConfigs || await this.loadConfigs();
|
|
29
|
+
const serverEntries = Object.entries(configs);
|
|
30
|
+
if (serverEntries.length === 0) {
|
|
31
|
+
this.isInitialized = true;
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
const initPromises = serverEntries.map(async ([name, cfg]) => {
|
|
35
|
+
if (cfg.disabled) {
|
|
36
|
+
this.serverStatuses.set(name, {
|
|
37
|
+
name,
|
|
38
|
+
command: `${cfg.command} ${(cfg.args || []).join(' ')}`.trim(),
|
|
39
|
+
status: 'disabled',
|
|
40
|
+
toolsCount: 0,
|
|
41
|
+
tools: []
|
|
42
|
+
});
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
this.serverStatuses.set(name, {
|
|
46
|
+
name,
|
|
47
|
+
command: `${cfg.command} ${(cfg.args || []).join(' ')}`.trim(),
|
|
48
|
+
status: 'connecting',
|
|
49
|
+
toolsCount: 0,
|
|
50
|
+
tools: []
|
|
51
|
+
});
|
|
52
|
+
const client = new MCPClient(name, cfg);
|
|
53
|
+
this.clients.set(name, client);
|
|
54
|
+
try {
|
|
55
|
+
const mcpTools = await client.start();
|
|
56
|
+
this.serverStatuses.set(name, {
|
|
57
|
+
name,
|
|
58
|
+
command: `${cfg.command} ${(cfg.args || []).join(' ')}`.trim(),
|
|
59
|
+
status: 'connected',
|
|
60
|
+
toolsCount: mcpTools.length,
|
|
61
|
+
tools: mcpTools.map(t => t.name)
|
|
62
|
+
});
|
|
63
|
+
// Convert MCP tools into devx Tool format
|
|
64
|
+
for (const mt of mcpTools) {
|
|
65
|
+
const namespacedName = `mcp__${name}__${mt.name}`;
|
|
66
|
+
const devxTool = {
|
|
67
|
+
name: namespacedName,
|
|
68
|
+
definition: {
|
|
69
|
+
name: namespacedName,
|
|
70
|
+
description: `[MCP: ${name}] ${mt.description || mt.name}`,
|
|
71
|
+
parameters: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: mt.inputSchema?.properties || {},
|
|
74
|
+
required: mt.inputSchema?.required
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
validateArgs(_args) { },
|
|
78
|
+
async execute(args) {
|
|
79
|
+
return await client.callTool(mt.name, args);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
this.tools.push(devxTool);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
this.serverStatuses.set(name, {
|
|
87
|
+
name,
|
|
88
|
+
command: `${cfg.command} ${(cfg.args || []).join(' ')}`.trim(),
|
|
89
|
+
status: 'failed',
|
|
90
|
+
toolsCount: 0,
|
|
91
|
+
tools: [],
|
|
92
|
+
error: err.message
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
await Promise.all(initPromises);
|
|
97
|
+
this.isInitialized = true;
|
|
98
|
+
return this.tools;
|
|
99
|
+
}
|
|
100
|
+
getTools() {
|
|
101
|
+
return this.tools;
|
|
102
|
+
}
|
|
103
|
+
getStatuses() {
|
|
104
|
+
return Array.from(this.serverStatuses.values());
|
|
105
|
+
}
|
|
106
|
+
async reload() {
|
|
107
|
+
this.stopAll();
|
|
108
|
+
this.isInitialized = false;
|
|
109
|
+
this.clients.clear();
|
|
110
|
+
this.tools = [];
|
|
111
|
+
this.serverStatuses.clear();
|
|
112
|
+
return await this.init();
|
|
113
|
+
}
|
|
114
|
+
stopAll() {
|
|
115
|
+
for (const client of this.clients.values()) {
|
|
116
|
+
try {
|
|
117
|
+
client.close();
|
|
118
|
+
}
|
|
119
|
+
catch { }
|
|
120
|
+
}
|
|
121
|
+
this.clients.clear();
|
|
122
|
+
}
|
|
123
|
+
renderStatusCard() {
|
|
124
|
+
const theme = getCurrentTheme();
|
|
125
|
+
const statuses = this.getStatuses();
|
|
126
|
+
const cols = Math.min(process.stdout.columns || 80, 80);
|
|
127
|
+
const boxWidth = Math.max(48, cols - 4);
|
|
128
|
+
const innerWidth = boxWidth - 6;
|
|
129
|
+
const padRow = (left, right) => {
|
|
130
|
+
const plainLen = stripAnsi(left).length + stripAnsi(right).length;
|
|
131
|
+
const spaces = Math.max(1, innerWidth - plainLen);
|
|
132
|
+
return `│ ${left}${' '.repeat(spaces)}${right} │`;
|
|
133
|
+
};
|
|
134
|
+
const header = theme.colorFn('┌─ ') + pc.bold('🔌 Model Context Protocol (MCP) Servers') + ' ' + theme.colorFn('─'.repeat(Math.max(2, boxWidth - 43)) + '┐');
|
|
135
|
+
const divider = theme.colorFn('├' + '─'.repeat(boxWidth - 2) + '┤');
|
|
136
|
+
const footer = theme.colorFn('└' + '─'.repeat(boxWidth - 2) + '┘');
|
|
137
|
+
const lines = [header];
|
|
138
|
+
if (statuses.length === 0) {
|
|
139
|
+
lines.push(padRow(pc.dim('No MCP servers configured.'), ''));
|
|
140
|
+
lines.push(padRow(pc.dim('Configure in .devx/mcp.json or ~/.devxrc.json'), ''));
|
|
141
|
+
lines.push(footer);
|
|
142
|
+
return lines.join('\n') + '\n';
|
|
143
|
+
}
|
|
144
|
+
for (let i = 0; i < statuses.length; i++) {
|
|
145
|
+
const s = statuses[i];
|
|
146
|
+
if (i > 0)
|
|
147
|
+
lines.push(divider);
|
|
148
|
+
let statusBadge = pc.green('🟢 Connected');
|
|
149
|
+
if (s.status === 'connecting')
|
|
150
|
+
statusBadge = pc.yellow('🟡 Connecting');
|
|
151
|
+
if (s.status === 'failed')
|
|
152
|
+
statusBadge = pc.red('🔴 Failed');
|
|
153
|
+
if (s.status === 'disabled')
|
|
154
|
+
statusBadge = pc.dim('⚪ Disabled');
|
|
155
|
+
lines.push(padRow(pc.bold(pc.white(`Server: ${s.name}`)), statusBadge));
|
|
156
|
+
lines.push(padRow(pc.dim(` Command: ${s.command.slice(0, 36)}${s.command.length > 36 ? '...' : ''}`), pc.cyan(`${s.toolsCount} tools`)));
|
|
157
|
+
if (s.tools.length > 0) {
|
|
158
|
+
const toolsListStr = s.tools.slice(0, 3).map(t => pc.dim(`• ${t}`)).join(' ');
|
|
159
|
+
const extra = s.tools.length > 3 ? pc.dim(` +${s.tools.length - 3} more`) : '';
|
|
160
|
+
lines.push(padRow(` ${toolsListStr}${extra}`, ''));
|
|
161
|
+
}
|
|
162
|
+
if (s.error) {
|
|
163
|
+
lines.push(padRow(pc.red(` Error: ${s.error.slice(0, innerWidth - 10)}`), ''));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
lines.push(footer);
|
|
167
|
+
return lines.join('\n') + '\n';
|
|
168
|
+
}
|
|
169
|
+
async loadConfigs() {
|
|
170
|
+
const result = {};
|
|
171
|
+
const configPaths = [
|
|
172
|
+
path.join(os.homedir(), '.devxrc.json'),
|
|
173
|
+
path.join(process.cwd(), '.devx', 'mcp.json'),
|
|
174
|
+
path.join(process.cwd(), '.claude', 'mcp.json'),
|
|
175
|
+
path.join(process.cwd(), '.devx.json'),
|
|
176
|
+
path.join(process.cwd(), '.mcp.json')
|
|
177
|
+
];
|
|
178
|
+
for (const pth of configPaths) {
|
|
179
|
+
try {
|
|
180
|
+
if (fsSync.existsSync(pth)) {
|
|
181
|
+
const raw = await fs.readFile(pth, 'utf8');
|
|
182
|
+
const parsed = JSON.parse(raw);
|
|
183
|
+
if (parsed && typeof parsed.mcpServers === 'object') {
|
|
184
|
+
for (const [name, cfg] of Object.entries(parsed.mcpServers)) {
|
|
185
|
+
if (cfg && typeof cfg === 'object' && cfg.command) {
|
|
186
|
+
result[name] = cfg;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch { }
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -23,25 +23,54 @@ function isPathOutsideCwd(targetPath) {
|
|
|
23
23
|
function isDangerousBashCommand(commandStr) {
|
|
24
24
|
if (!commandStr)
|
|
25
25
|
return false;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
26
|
+
// Normalize whitespace: collapse multiple spaces/tabs into single space
|
|
27
|
+
const norm = commandStr.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
28
|
+
// Pattern matching rm with combined flags (-rf, -fr, -r -f, etc.) targeting root, home, termux prefix, or glob
|
|
29
|
+
const isDangerousRm = /rm\s+-(?:[a-z]*r[a-z]*f|[a-z]*f[a-z]*r)\s+(?:\/|\/\*|~|~\/|\*|\$home|\$prefix|\/data\/data\/com\.termux)/i.test(norm) ||
|
|
30
|
+
/rm\s+-[a-z]*r[a-z]*\s+-[a-z]*f[a-z]*\s+(?:\/|\/\*|~|~\/|\*|\$home|\$prefix|\/data\/data\/com\.termux)/i.test(norm) ||
|
|
31
|
+
/rm\s+-[a-z]*f[a-z]*\s+-[a-z]*r[a-z]*\s+(?:\/|\/\*|~|~\/|\*|\$home|\$prefix|\/data\/data\/com\.termux)/i.test(norm);
|
|
32
|
+
return (isDangerousRm ||
|
|
33
|
+
norm.includes('rm -rf /') ||
|
|
34
|
+
norm.includes('rm -rf ~') ||
|
|
35
|
+
norm.includes('rm -rf *') ||
|
|
36
|
+
norm.includes('rm -rf $prefix') ||
|
|
37
|
+
norm.includes('rm -rf $home') ||
|
|
38
|
+
norm.includes('rm -rf /data/data/com.termux') ||
|
|
39
|
+
norm.includes('mkfs') ||
|
|
40
|
+
norm.includes('dd if=') ||
|
|
41
|
+
norm.includes(':(){ :|:& };:') ||
|
|
42
|
+
norm.includes('chmod -r 777 /') ||
|
|
43
|
+
norm.includes('> /dev/sda') ||
|
|
44
|
+
norm.includes('format c:'));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Checks if a command contains chaining, redirection, or subshell operators.
|
|
48
|
+
* Allowlist matches MUST be clean, single commands without hidden side-effects.
|
|
49
|
+
*/
|
|
50
|
+
function hasComplexChainingOrRedirection(commandStr) {
|
|
51
|
+
if (!commandStr)
|
|
52
|
+
return false;
|
|
53
|
+
return (commandStr.includes('&&') ||
|
|
54
|
+
commandStr.includes('||') ||
|
|
55
|
+
commandStr.includes(';') ||
|
|
56
|
+
commandStr.includes('|') ||
|
|
57
|
+
commandStr.includes('>') ||
|
|
58
|
+
commandStr.includes('<') ||
|
|
59
|
+
commandStr.includes('`') ||
|
|
60
|
+
commandStr.includes('$(') ||
|
|
61
|
+
commandStr.includes('\n') ||
|
|
62
|
+
commandStr.includes('\r'));
|
|
36
63
|
}
|
|
37
64
|
export class CLIConsoleGuard {
|
|
38
65
|
autoApprove;
|
|
39
|
-
|
|
66
|
+
bashAllowlist;
|
|
67
|
+
constructor(autoApprove = false, bashAllowlist = []) {
|
|
40
68
|
this.autoApprove = autoApprove;
|
|
69
|
+
this.bashAllowlist = bashAllowlist;
|
|
41
70
|
}
|
|
42
71
|
check(toolName, args) {
|
|
43
72
|
const t = (toolName || '').toLowerCase();
|
|
44
|
-
const cmd = args?.command || args?.cmd || '';
|
|
73
|
+
const cmd = (args?.command || args?.cmd || '').trim();
|
|
45
74
|
// Critical safety net: even in YOLO mode, warn and confirm destructive system commands
|
|
46
75
|
if (this.autoApprove) {
|
|
47
76
|
if (t === 'bash' && isDangerousBashCommand(cmd)) {
|
|
@@ -49,9 +78,19 @@ export class CLIConsoleGuard {
|
|
|
49
78
|
}
|
|
50
79
|
return false;
|
|
51
80
|
}
|
|
52
|
-
//
|
|
53
|
-
if (t === 'bash' || t === 'exec' || t === 'run_command') {
|
|
54
|
-
|
|
81
|
+
// Check Bash Allowlist: only allow if command is NOT dangerous AND does not use chaining/redirection
|
|
82
|
+
if ((t === 'bash' || t === 'exec' || t === 'run_command') && cmd) {
|
|
83
|
+
if (!isDangerousBashCommand(cmd) && !hasComplexChainingOrRedirection(cmd)) {
|
|
84
|
+
const isAllowed = this.bashAllowlist.some(pattern => {
|
|
85
|
+
const p = pattern.trim().toLowerCase();
|
|
86
|
+
const lowerCmd = cmd.toLowerCase();
|
|
87
|
+
return lowerCmd === p || lowerCmd.startsWith(p + ' ');
|
|
88
|
+
});
|
|
89
|
+
if (isAllowed) {
|
|
90
|
+
return false; // Automatically allowed!
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return true; // Requires user confirmation
|
|
55
94
|
}
|
|
56
95
|
// 2. Package installations require confirmation
|
|
57
96
|
if (t === 'install_package' || t === 'packages') {
|
|
@@ -68,15 +107,30 @@ export class CLIConsoleGuard {
|
|
|
68
107
|
return true;
|
|
69
108
|
}
|
|
70
109
|
}
|
|
110
|
+
// 5. MCP Tools: ask if autoApprove is false
|
|
111
|
+
if (t.startsWith('mcp__')) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
71
114
|
return false;
|
|
72
115
|
}
|
|
73
116
|
async askUser(toolName, args) {
|
|
74
117
|
const t = (toolName || '').toLowerCase();
|
|
75
118
|
const cmd = args?.command || args?.cmd || '';
|
|
76
119
|
const isDangerous = t === 'bash' && isDangerousBashCommand(cmd);
|
|
120
|
+
// In headless / non-interactive environment without TTY, deny confirmation-requiring actions immediately
|
|
121
|
+
if (!process.stdin.isTTY) {
|
|
122
|
+
console.error(pc.red(`\n🛡️ [PERMISSION DENIED] Action '${toolName}' requires user confirmation in safe mode, but no interactive terminal is available. Pass --yolo (-y) to auto-approve actions in headless mode.`));
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
77
125
|
if (isDangerous) {
|
|
78
126
|
p.log.error(pc.bold(pc.red('⚠️ [SECURITY WARNING] Agent requested a potentially dangerous system command!')));
|
|
79
127
|
}
|
|
128
|
+
else if (t.startsWith('mcp__')) {
|
|
129
|
+
const parts = toolName.split('__');
|
|
130
|
+
const serverName = parts[1] || 'mcp';
|
|
131
|
+
const mcpToolName = parts.slice(2).join('__');
|
|
132
|
+
p.log.warn(pc.bold(pc.yellow(`🛡️ [PERMISSION GUARD] Agent wants to call MCP Tool: ${pc.cyan(serverName)} / ${pc.green(mcpToolName)}`)));
|
|
133
|
+
}
|
|
80
134
|
else {
|
|
81
135
|
p.log.warn(pc.bold(pc.yellow(`🛡️ [PERMISSION GUARD] Agent wants to execute: ${pc.cyan(toolName)}`)));
|
|
82
136
|
}
|
package/dist/prompts/builder.js
CHANGED
|
@@ -72,5 +72,14 @@ export async function buildSystemPrompt(planMode) {
|
|
|
72
72
|
}
|
|
73
73
|
catch (e) {
|
|
74
74
|
}
|
|
75
|
+
// Load Compact Repo Map (AST structure & exported symbols)
|
|
76
|
+
try {
|
|
77
|
+
const { RepoMapGenerator } = await import('../core/repomap.js');
|
|
78
|
+
const repoMap = await RepoMapGenerator.generate(process.cwd(), 50);
|
|
79
|
+
if (repoMap.trim()) {
|
|
80
|
+
prompt += `\n--- Codebase Map & Exported Symbols ---\n${repoMap}\n----------------------------------------\n`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch { }
|
|
75
84
|
return prompt;
|
|
76
85
|
}
|
package/dist/providers/openai.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { calculateCost } from '../core/pricing.js';
|
|
2
|
+
import { UsageTracker } from '../core/usage.js';
|
|
2
3
|
export class OpenAIProvider {
|
|
3
4
|
baseUrl;
|
|
4
5
|
apiKey;
|
|
@@ -66,20 +67,23 @@ export class OpenAIProvider {
|
|
|
66
67
|
async chat(request) {
|
|
67
68
|
const url = `${this.baseUrl}/chat/completions`;
|
|
68
69
|
const payload = this.buildPayload(request, false);
|
|
70
|
+
const bodyStr = JSON.stringify(payload);
|
|
69
71
|
const headers = {
|
|
70
72
|
'Content-Type': 'application/json',
|
|
71
73
|
};
|
|
72
74
|
if (this.apiKey) {
|
|
73
75
|
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
|
74
76
|
}
|
|
77
|
+
UsageTracker.getInstance().recordRequest(Buffer.byteLength(bodyStr, 'utf8'));
|
|
75
78
|
const res = await fetch(url, {
|
|
76
79
|
method: 'POST',
|
|
77
80
|
headers,
|
|
78
|
-
body:
|
|
81
|
+
body: bodyStr,
|
|
79
82
|
signal: request.signal
|
|
80
83
|
});
|
|
81
84
|
if (!res.ok) {
|
|
82
85
|
const errText = await res.text();
|
|
86
|
+
UsageTracker.getInstance().recordResponseChunk(Buffer.byteLength(errText, 'utf8'));
|
|
83
87
|
let errMsg = errText;
|
|
84
88
|
try {
|
|
85
89
|
const parsed = JSON.parse(errText);
|
|
@@ -95,7 +99,15 @@ export class OpenAIProvider {
|
|
|
95
99
|
err.status = res.status;
|
|
96
100
|
throw err;
|
|
97
101
|
}
|
|
98
|
-
const
|
|
102
|
+
const rawResText = await res.text();
|
|
103
|
+
UsageTracker.getInstance().recordResponseChunk(Buffer.byteLength(rawResText, 'utf8'));
|
|
104
|
+
let data = {};
|
|
105
|
+
try {
|
|
106
|
+
data = JSON.parse(rawResText);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new Error(`Invalid JSON response from provider`);
|
|
110
|
+
}
|
|
99
111
|
if (data.error) {
|
|
100
112
|
const msg = data.error.message || data.error.code || JSON.stringify(data.error);
|
|
101
113
|
throw new Error(`Provider Error: ${msg}`);
|
|
@@ -115,34 +127,47 @@ export class OpenAIProvider {
|
|
|
115
127
|
totalTokens,
|
|
116
128
|
cost
|
|
117
129
|
};
|
|
130
|
+
UsageTracker.getInstance().recordTokens(promptTokens, completionTokens, cost);
|
|
118
131
|
}
|
|
119
132
|
return {
|
|
120
133
|
content: choice.content || null,
|
|
121
|
-
toolCalls: choice.tool_calls ? choice.tool_calls.map((tc) =>
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
134
|
+
toolCalls: choice.tool_calls ? choice.tool_calls.map((tc) => {
|
|
135
|
+
let parsedArgs = {};
|
|
136
|
+
try {
|
|
137
|
+
parsedArgs = JSON.parse(tc.function.arguments);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
parsedArgs = {};
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
id: tc.id || `call_${Math.random().toString(36).substring(2, 9)}`,
|
|
144
|
+
name: tc.function.name,
|
|
145
|
+
arguments: parsedArgs
|
|
146
|
+
};
|
|
147
|
+
}) : undefined,
|
|
126
148
|
usage
|
|
127
149
|
};
|
|
128
150
|
}
|
|
129
151
|
async *chatStream(request) {
|
|
130
152
|
const url = `${this.baseUrl}/chat/completions`;
|
|
131
153
|
const payload = this.buildPayload(request, true);
|
|
154
|
+
const bodyStr = JSON.stringify(payload);
|
|
132
155
|
const headers = {
|
|
133
156
|
'Content-Type': 'application/json',
|
|
134
157
|
};
|
|
135
158
|
if (this.apiKey) {
|
|
136
159
|
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
|
137
160
|
}
|
|
161
|
+
UsageTracker.getInstance().recordRequest(Buffer.byteLength(bodyStr, 'utf8'));
|
|
138
162
|
const res = await fetch(url, {
|
|
139
163
|
method: 'POST',
|
|
140
164
|
headers,
|
|
141
|
-
body:
|
|
165
|
+
body: bodyStr,
|
|
142
166
|
signal: request.signal
|
|
143
167
|
});
|
|
144
168
|
if (!res.ok) {
|
|
145
169
|
const errText = await res.text();
|
|
170
|
+
UsageTracker.getInstance().recordResponseChunk(Buffer.byteLength(errText, 'utf8'));
|
|
146
171
|
let errMsg = errText;
|
|
147
172
|
try {
|
|
148
173
|
const parsed = JSON.parse(errText);
|
|
@@ -184,6 +209,9 @@ export class OpenAIProvider {
|
|
|
184
209
|
const { done, value } = await reader.read();
|
|
185
210
|
if (done || request.signal?.aborted)
|
|
186
211
|
break;
|
|
212
|
+
if (value) {
|
|
213
|
+
UsageTracker.getInstance().recordResponseChunk(value.byteLength);
|
|
214
|
+
}
|
|
187
215
|
buffer += decoder.decode(value, { stream: true });
|
|
188
216
|
const lines = buffer.split('\n');
|
|
189
217
|
buffer = lines.pop() || '';
|
|
@@ -333,6 +361,7 @@ export class OpenAIProvider {
|
|
|
333
361
|
totalTokens,
|
|
334
362
|
cost
|
|
335
363
|
};
|
|
364
|
+
UsageTracker.getInstance().recordTokens(promptTokens, completionTokens, cost);
|
|
336
365
|
const finalResponse = {
|
|
337
366
|
content: accumulatedContent || null,
|
|
338
367
|
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|