bortexcode 1.5.0 → 1.8.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 +57 -0
- package/bin/bortex.js +2204 -89
- package/package.json +2 -1
package/bin/bortex.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const crypto = require('crypto');
|
|
7
8
|
const readline = require('readline');
|
|
8
9
|
const { spawn, spawnSync } = require('child_process');
|
|
9
10
|
const packageMeta = require('../package.json');
|
|
@@ -44,12 +45,30 @@ function parseArgs(argv) {
|
|
|
44
45
|
spinner: true,
|
|
45
46
|
progress: false,
|
|
46
47
|
icons: false
|
|
47
|
-
}
|
|
48
|
+
},
|
|
49
|
+
resume: false,
|
|
50
|
+
resumeTarget: '',
|
|
51
|
+
resumeAll: false,
|
|
52
|
+
execMode: false,
|
|
53
|
+
execJson: false,
|
|
54
|
+
execOutput: '',
|
|
55
|
+
execSchema: '',
|
|
56
|
+
execEphemeral: false,
|
|
57
|
+
execMaxTurns: 1,
|
|
58
|
+
permissionProfile: process.env.BORTEX_PERMISSION_PROFILE || process.env.BORTEX_SANDBOX || 'workspace'
|
|
48
59
|
};
|
|
49
60
|
|
|
50
61
|
const rest = [];
|
|
51
62
|
for (let i = 0; i < argv.length; i += 1) {
|
|
52
63
|
const a = argv[i];
|
|
64
|
+
if (a === 'resume') {
|
|
65
|
+
opts.resume = true;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (a === 'exec') {
|
|
69
|
+
opts.execMode = true;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
53
72
|
if (a === 'remote-control' || a === 'remote') {
|
|
54
73
|
opts.remoteControlServerMode = true;
|
|
55
74
|
continue;
|
|
@@ -120,6 +139,62 @@ function parseArgs(argv) {
|
|
|
120
139
|
opts.offline = true;
|
|
121
140
|
continue;
|
|
122
141
|
}
|
|
142
|
+
if (a === '--json') {
|
|
143
|
+
opts.execJson = true;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if ((a === '-o' || a === '--output-last-message' || a === '--output') && argv[i + 1]) {
|
|
147
|
+
opts.execOutput = argv[i + 1];
|
|
148
|
+
i += 1;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (a.startsWith('--output-last-message=')) {
|
|
152
|
+
opts.execOutput = a.slice('--output-last-message='.length);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (a.startsWith('--output=')) {
|
|
156
|
+
opts.execOutput = a.slice('--output='.length);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (a === '--output-schema' && argv[i + 1]) {
|
|
160
|
+
opts.execSchema = argv[i + 1];
|
|
161
|
+
i += 1;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (a.startsWith('--output-schema=')) {
|
|
165
|
+
opts.execSchema = a.slice('--output-schema='.length);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (a === '--ephemeral') {
|
|
169
|
+
opts.execEphemeral = true;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (a === '--max-turns' && argv[i + 1]) {
|
|
173
|
+
opts.execMaxTurns = Math.max(1, Number(argv[i + 1]) || 1);
|
|
174
|
+
i += 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (a.startsWith('--max-turns=')) {
|
|
178
|
+
opts.execMaxTurns = Math.max(1, Number(a.slice('--max-turns='.length)) || 1);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if ((a === '--sandbox' || a === '--permissions' || a === '--permission-profile') && argv[i + 1]) {
|
|
182
|
+
opts.permissionProfile = argv[i + 1];
|
|
183
|
+
i += 1;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (a.startsWith('--sandbox=')) {
|
|
187
|
+
opts.permissionProfile = a.slice('--sandbox='.length);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (a.startsWith('--permissions=')) {
|
|
191
|
+
opts.permissionProfile = a.slice('--permissions='.length);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (a.startsWith('--permission-profile=')) {
|
|
195
|
+
opts.permissionProfile = a.slice('--permission-profile='.length);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
123
198
|
if (a === '--verbose') {
|
|
124
199
|
opts.ux.verbose = true;
|
|
125
200
|
continue;
|
|
@@ -226,6 +301,24 @@ function parseArgs(argv) {
|
|
|
226
301
|
rest.push(a);
|
|
227
302
|
}
|
|
228
303
|
|
|
304
|
+
if (opts.resume && rest.length) {
|
|
305
|
+
const first = String(rest[0] || '').trim();
|
|
306
|
+
if (first === '--last' || first === 'last') {
|
|
307
|
+
opts.resumeTarget = 'last';
|
|
308
|
+
rest.shift();
|
|
309
|
+
} else if (first === '--all') {
|
|
310
|
+
opts.resumeAll = true;
|
|
311
|
+
rest.shift();
|
|
312
|
+
} else if (/^[a-z0-9][a-z0-9._-]{5,}$/i.test(first)) {
|
|
313
|
+
opts.resumeTarget = first;
|
|
314
|
+
rest.shift();
|
|
315
|
+
}
|
|
316
|
+
if (rest[0] === '--all') {
|
|
317
|
+
opts.resumeAll = true;
|
|
318
|
+
rest.shift();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
229
322
|
if (rest.length) {
|
|
230
323
|
opts.prompt = rest.join(' ').trim();
|
|
231
324
|
}
|
|
@@ -240,6 +333,8 @@ function usage() {
|
|
|
240
333
|
console.log(` ${cliName} [options] [prompt]`);
|
|
241
334
|
console.log(` ${cliName} remote-control [--name <title>] [--remote-lan]`);
|
|
242
335
|
console.log(` ${cliName} remote-control --cloud [--name <title>]`);
|
|
336
|
+
console.log(` ${cliName} exec [--json] [-o file] [--output-schema file] [prompt]`);
|
|
337
|
+
console.log(` ${cliName} resume [--last|<id>] [prompt]`);
|
|
243
338
|
console.log(` ${cliName} --api-key <apikey>`);
|
|
244
339
|
console.log(` ${cliName} "write a python function"`);
|
|
245
340
|
console.log('');
|
|
@@ -252,6 +347,14 @@ function usage() {
|
|
|
252
347
|
console.log(' -m, --model <name> Force a specific LLM model');
|
|
253
348
|
console.log(' --api-url <u> Custom LLM endpoint');
|
|
254
349
|
console.log(' --offline Local mode');
|
|
350
|
+
console.log(' --json Exec mode: emit JSONL events');
|
|
351
|
+
console.log(' -o, --output-last-message <file>');
|
|
352
|
+
console.log(' Exec mode: write final message to file');
|
|
353
|
+
console.log(' --output-schema <file>');
|
|
354
|
+
console.log(' Exec mode: request final JSON matching schema');
|
|
355
|
+
console.log(' --ephemeral Exec mode: do not persist transcript events');
|
|
356
|
+
console.log(' --sandbox <profile>');
|
|
357
|
+
console.log(' Permission profile: read-only, workspace, full');
|
|
255
358
|
console.log(' --verbose Show request routing details');
|
|
256
359
|
console.log(' --progress Show server progress events');
|
|
257
360
|
console.log(' --no-spinner Disable spinner');
|
|
@@ -447,15 +550,16 @@ async function fetchJsonWithTimeout(url, timeoutMs = 2500) {
|
|
|
447
550
|
}
|
|
448
551
|
|
|
449
552
|
async function resolveLatestCliVersion(opts = {}) {
|
|
553
|
+
const candidates = [];
|
|
450
554
|
try {
|
|
451
555
|
const npmLatest = await fetchJsonWithTimeout('https://registry.npmjs.org/bortexcode/latest');
|
|
452
556
|
if (npmLatest?.version) {
|
|
453
|
-
|
|
557
|
+
candidates.push({
|
|
454
558
|
source: 'npm',
|
|
455
559
|
version: String(npmLatest.version),
|
|
456
560
|
installTarget: 'bortexcode',
|
|
457
561
|
installCommand: 'npm install -g bortexcode'
|
|
458
|
-
};
|
|
562
|
+
});
|
|
459
563
|
}
|
|
460
564
|
} catch (_err) {
|
|
461
565
|
// Package may not be published yet. Fall back to bortex.site metadata.
|
|
@@ -466,16 +570,18 @@ async function resolveLatestCliVersion(opts = {}) {
|
|
|
466
570
|
const siteLatest = await fetchJsonWithTimeout(`${base}/bortex-code/latest.json`);
|
|
467
571
|
if (siteLatest?.version) {
|
|
468
572
|
const installTarget = String(siteLatest.installTarget || siteLatest.tarballUrl || `${base}/bortex-code/bortex-code-latest.tgz`);
|
|
469
|
-
|
|
573
|
+
candidates.push({
|
|
470
574
|
source: 'bortex.site',
|
|
471
575
|
version: String(siteLatest.version),
|
|
472
576
|
installTarget,
|
|
473
577
|
installCommand: String(siteLatest.installCommand || `npm install -g ${installTarget}`)
|
|
474
|
-
};
|
|
578
|
+
});
|
|
475
579
|
}
|
|
476
580
|
} catch (_err) { }
|
|
477
581
|
|
|
478
|
-
return null;
|
|
582
|
+
if (!candidates.length) return null;
|
|
583
|
+
candidates.sort((a, b) => compareSemver(b.version, a.version));
|
|
584
|
+
return candidates[0];
|
|
479
585
|
}
|
|
480
586
|
|
|
481
587
|
async function promptYesNo(question, defaultYes = true) {
|
|
@@ -982,7 +1088,7 @@ function detectGitUnmergedConflicts(opts) {
|
|
|
982
1088
|
}
|
|
983
1089
|
|
|
984
1090
|
function formatModeLine(opts) {
|
|
985
|
-
return `Server: ${opts.url} | Mode: ${opts.agent ? 'agent' : 'chat'} | Transport: ${opts.offline ? 'offline-native' : 'server'} | CWD: ${opts.cwd}`;
|
|
1091
|
+
return `Server: ${opts.url} | Mode: ${opts.agent ? 'agent' : 'chat'} | Transport: ${opts.offline ? 'offline-native' : 'server'} | Permissions: ${getEffectivePermissionProfile(opts).name} | CWD: ${opts.cwd}`;
|
|
986
1092
|
}
|
|
987
1093
|
|
|
988
1094
|
function getCliSessionPath(opts) {
|
|
@@ -1083,77 +1189,1665 @@ function sanitizeRunState(runState) {
|
|
|
1083
1189
|
};
|
|
1084
1190
|
}
|
|
1085
1191
|
|
|
1086
|
-
function sanitizeAgentRunDefaults(value) {
|
|
1087
|
-
const raw = value && typeof value === 'object' ? value : {};
|
|
1088
|
-
const policy = resolveAgentRunPolicyConfig(raw.policy || 'balanced').name;
|
|
1089
|
-
return { policy };
|
|
1192
|
+
function sanitizeAgentRunDefaults(value) {
|
|
1193
|
+
const raw = value && typeof value === 'object' ? value : {};
|
|
1194
|
+
const policy = resolveAgentRunPolicyConfig(raw.policy || 'balanced').name;
|
|
1195
|
+
return { policy };
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function saveCliWorkspaceState(opts) {
|
|
1199
|
+
const p = getCliSessionPath(opts);
|
|
1200
|
+
const payload = {
|
|
1201
|
+
version: 1,
|
|
1202
|
+
cwd: opts.cwd,
|
|
1203
|
+
updatedAt: Date.now(),
|
|
1204
|
+
todo: sanitizeTodoItems(opts.todo),
|
|
1205
|
+
plan: sanitizePlan(opts.plan),
|
|
1206
|
+
history: sanitizeHistory(opts.commandHistory),
|
|
1207
|
+
artifacts: sanitizeArtifacts(opts.artifacts),
|
|
1208
|
+
runState: sanitizeRunState(opts.runState),
|
|
1209
|
+
agentRunDefaults: sanitizeAgentRunDefaults(opts.agentRunDefaults)
|
|
1210
|
+
};
|
|
1211
|
+
fs.writeFileSync(p, JSON.stringify(payload, null, 2), 'utf8');
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function loadCliWorkspaceState(opts) {
|
|
1215
|
+
const p = getCliSessionPath(opts);
|
|
1216
|
+
opts.todo = [];
|
|
1217
|
+
opts.plan = null;
|
|
1218
|
+
opts.commandHistory = [];
|
|
1219
|
+
opts.artifacts = [];
|
|
1220
|
+
opts.runState = null;
|
|
1221
|
+
opts.agentRunDefaults = sanitizeAgentRunDefaults(null);
|
|
1222
|
+
if (!fs.existsSync(p)) return { loaded: false, path: p };
|
|
1223
|
+
try {
|
|
1224
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
1225
|
+
opts.todo = sanitizeTodoItems(raw.todo);
|
|
1226
|
+
opts.plan = sanitizePlan(raw.plan);
|
|
1227
|
+
opts.commandHistory = sanitizeHistory(raw.history);
|
|
1228
|
+
opts.artifacts = sanitizeArtifacts(raw.artifacts);
|
|
1229
|
+
opts.runState = sanitizeRunState(raw.runState);
|
|
1230
|
+
opts.agentRunDefaults = sanitizeAgentRunDefaults(raw.agentRunDefaults);
|
|
1231
|
+
return { loaded: true, path: p };
|
|
1232
|
+
} catch (_err) {
|
|
1233
|
+
opts.agentRunDefaults = sanitizeAgentRunDefaults(null);
|
|
1234
|
+
return { loaded: false, path: p, invalid: true };
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function pushCliHistory(opts, line) {
|
|
1239
|
+
if (!Array.isArray(opts.commandHistory)) opts.commandHistory = [];
|
|
1240
|
+
const text = String(line || '').trim();
|
|
1241
|
+
if (!text) return;
|
|
1242
|
+
const last = opts.commandHistory[opts.commandHistory.length - 1];
|
|
1243
|
+
if (last && last.text === text) {
|
|
1244
|
+
last.ts = Date.now();
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
opts.commandHistory.push({ text, ts: Date.now() });
|
|
1248
|
+
if (opts.commandHistory.length > 200) {
|
|
1249
|
+
opts.commandHistory = opts.commandHistory.slice(-200);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function recordArtifact(opts, kind, text) {
|
|
1254
|
+
if (!Array.isArray(opts.artifacts)) opts.artifacts = [];
|
|
1255
|
+
opts.artifacts.push({
|
|
1256
|
+
ts: Date.now(),
|
|
1257
|
+
kind: String(kind || 'event'),
|
|
1258
|
+
text: String(text || '')
|
|
1259
|
+
});
|
|
1260
|
+
if (opts.artifacts.length > 300) {
|
|
1261
|
+
opts.artifacts = opts.artifacts.slice(-300);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function getBortexUserDir() {
|
|
1266
|
+
return path.dirname(resolveSharedSettingsPath());
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function getBortexProjectDir(opts) {
|
|
1270
|
+
return path.join(opts.cwd || process.cwd(), '.bortex');
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function ensureDirSync(dirPath) {
|
|
1274
|
+
if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true });
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function safeSlug(value, fallback = 'item') {
|
|
1278
|
+
return String(value || fallback)
|
|
1279
|
+
.toLowerCase()
|
|
1280
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
1281
|
+
.replace(/^-+|-+$/g, '')
|
|
1282
|
+
.slice(0, 80) || fallback;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function safeReadJson(filePath, fallback = null) {
|
|
1286
|
+
try {
|
|
1287
|
+
if (!fs.existsSync(filePath)) return fallback;
|
|
1288
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
1289
|
+
} catch (_err) {
|
|
1290
|
+
return fallback;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function safeWriteJson(filePath, payload) {
|
|
1295
|
+
ensureDirSync(path.dirname(filePath));
|
|
1296
|
+
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8');
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function parseFrontmatterMarkdown(rawText) {
|
|
1300
|
+
const text = String(rawText || '');
|
|
1301
|
+
const out = { meta: {}, body: text };
|
|
1302
|
+
if (!text.startsWith('---')) return out;
|
|
1303
|
+
const end = text.indexOf('\n---', 3);
|
|
1304
|
+
if (end < 0) return out;
|
|
1305
|
+
const yaml = text.slice(3, end).trim();
|
|
1306
|
+
const body = text.slice(end + 4).replace(/^\r?\n/, '');
|
|
1307
|
+
const meta = {};
|
|
1308
|
+
yaml.split(/\r?\n/).forEach((line) => {
|
|
1309
|
+
const m = line.match(/^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
|
|
1310
|
+
if (!m) return;
|
|
1311
|
+
const key = m[1].trim();
|
|
1312
|
+
let value = m[2].trim();
|
|
1313
|
+
value = value.replace(/^['"]|['"]$/g, '');
|
|
1314
|
+
if (value === 'true') meta[key] = true;
|
|
1315
|
+
else if (value === 'false') meta[key] = false;
|
|
1316
|
+
else meta[key] = value;
|
|
1317
|
+
});
|
|
1318
|
+
return { meta, body };
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function listMarkdownFiles(dirPath) {
|
|
1322
|
+
try {
|
|
1323
|
+
if (!fs.existsSync(dirPath)) return [];
|
|
1324
|
+
return fs.readdirSync(dirPath, { withFileTypes: true })
|
|
1325
|
+
.filter((entry) => entry.isFile() && /\.md$/i.test(entry.name))
|
|
1326
|
+
.map((entry) => path.join(dirPath, entry.name));
|
|
1327
|
+
} catch (_err) {
|
|
1328
|
+
return [];
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
function listSkillFiles(dirPath) {
|
|
1333
|
+
try {
|
|
1334
|
+
if (!fs.existsSync(dirPath)) return [];
|
|
1335
|
+
return fs.readdirSync(dirPath, { withFileTypes: true })
|
|
1336
|
+
.filter((entry) => entry.isDirectory())
|
|
1337
|
+
.map((entry) => path.join(dirPath, entry.name, 'SKILL.md'))
|
|
1338
|
+
.filter((filePath) => fs.existsSync(filePath));
|
|
1339
|
+
} catch (_err) {
|
|
1340
|
+
return [];
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function getPluginStatePath(opts) {
|
|
1345
|
+
return path.join(getBortexProjectDir(opts), 'plugins.json');
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function loadPluginState(opts) {
|
|
1349
|
+
const raw = safeReadJson(getPluginStatePath(opts), { disabled: {} });
|
|
1350
|
+
return { disabled: raw && typeof raw.disabled === 'object' ? raw.disabled : {} };
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function savePluginState(opts, state) {
|
|
1354
|
+
safeWriteJson(getPluginStatePath(opts), { disabled: state.disabled || {} });
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function pluginBaseDirs(opts) {
|
|
1358
|
+
return [
|
|
1359
|
+
{ scope: 'user', dir: path.join(getBortexUserDir(), 'plugins') },
|
|
1360
|
+
{ scope: 'project', dir: path.join(getBortexProjectDir(opts), 'plugins') }
|
|
1361
|
+
];
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function readPluginManifest(pluginDir) {
|
|
1365
|
+
const candidates = [
|
|
1366
|
+
path.join(pluginDir, '.bortex-plugin', 'plugin.json'),
|
|
1367
|
+
path.join(pluginDir, 'plugin.json')
|
|
1368
|
+
];
|
|
1369
|
+
for (const filePath of candidates) {
|
|
1370
|
+
const manifest = safeReadJson(filePath, null);
|
|
1371
|
+
if (manifest && typeof manifest === 'object') return { manifest, filePath };
|
|
1372
|
+
}
|
|
1373
|
+
return null;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function discoverPlugins(opts) {
|
|
1377
|
+
const state = loadPluginState(opts);
|
|
1378
|
+
const rows = [];
|
|
1379
|
+
pluginBaseDirs(opts).forEach((root) => {
|
|
1380
|
+
try {
|
|
1381
|
+
if (!fs.existsSync(root.dir)) return;
|
|
1382
|
+
fs.readdirSync(root.dir, { withFileTypes: true })
|
|
1383
|
+
.filter((entry) => entry.isDirectory())
|
|
1384
|
+
.forEach((entry) => {
|
|
1385
|
+
const dir = path.join(root.dir, entry.name);
|
|
1386
|
+
const loaded = readPluginManifest(dir);
|
|
1387
|
+
if (!loaded) return;
|
|
1388
|
+
const id = safeSlug(loaded.manifest.id || loaded.manifest.name || entry.name);
|
|
1389
|
+
rows.push({
|
|
1390
|
+
id,
|
|
1391
|
+
name: loaded.manifest.name || id,
|
|
1392
|
+
description: loaded.manifest.description || '',
|
|
1393
|
+
version: loaded.manifest.version || '',
|
|
1394
|
+
scope: root.scope,
|
|
1395
|
+
dir,
|
|
1396
|
+
manifestPath: loaded.filePath,
|
|
1397
|
+
manifest: loaded.manifest,
|
|
1398
|
+
enabled: !state.disabled[id]
|
|
1399
|
+
});
|
|
1400
|
+
});
|
|
1401
|
+
} catch (_err) {}
|
|
1402
|
+
});
|
|
1403
|
+
return rows;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function getEnabledPluginContentRoots(opts) {
|
|
1407
|
+
return discoverPlugins(opts)
|
|
1408
|
+
.filter((plugin) => plugin.enabled)
|
|
1409
|
+
.map((plugin) => ({
|
|
1410
|
+
scope: `plugin:${plugin.id}`,
|
|
1411
|
+
commands: path.join(plugin.dir, plugin.manifest.commandsDir || 'commands'),
|
|
1412
|
+
skills: path.join(plugin.dir, plugin.manifest.skillsDir || 'skills'),
|
|
1413
|
+
plugin
|
|
1414
|
+
}));
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function loadCustomSlashCommands(opts) {
|
|
1418
|
+
const roots = [
|
|
1419
|
+
{ scope: 'user', commands: path.join(getBortexUserDir(), 'commands'), skills: path.join(getBortexUserDir(), 'skills') },
|
|
1420
|
+
{ scope: 'project', commands: path.join(getBortexProjectDir(opts), 'commands'), skills: path.join(getBortexProjectDir(opts), 'skills') }
|
|
1421
|
+
].concat(getEnabledPluginContentRoots(opts));
|
|
1422
|
+
const rows = [];
|
|
1423
|
+
roots.forEach((root) => {
|
|
1424
|
+
listMarkdownFiles(root.commands).forEach((filePath) => {
|
|
1425
|
+
const parsed = parseFrontmatterMarkdown(fs.readFileSync(filePath, 'utf8'));
|
|
1426
|
+
const name = safeSlug(parsed.meta.name || path.basename(filePath, '.md'));
|
|
1427
|
+
rows.push({
|
|
1428
|
+
scope: root.scope,
|
|
1429
|
+
type: 'command',
|
|
1430
|
+
name,
|
|
1431
|
+
command: `/${name}`,
|
|
1432
|
+
description: String(parsed.meta.description || parsed.body.split(/\r?\n/).find(Boolean) || 'Custom command').slice(0, 160),
|
|
1433
|
+
filePath,
|
|
1434
|
+
meta: parsed.meta,
|
|
1435
|
+
body: parsed.body
|
|
1436
|
+
});
|
|
1437
|
+
});
|
|
1438
|
+
listSkillFiles(root.skills).forEach((filePath) => {
|
|
1439
|
+
const parsed = parseFrontmatterMarkdown(fs.readFileSync(filePath, 'utf8'));
|
|
1440
|
+
const name = safeSlug(parsed.meta.name || path.basename(path.dirname(filePath)));
|
|
1441
|
+
rows.push({
|
|
1442
|
+
scope: root.scope,
|
|
1443
|
+
type: 'skill',
|
|
1444
|
+
name,
|
|
1445
|
+
command: `/${name}`,
|
|
1446
|
+
description: String(parsed.meta.description || parsed.body.split(/\r?\n/).find(Boolean) || 'Custom skill').slice(0, 160),
|
|
1447
|
+
filePath,
|
|
1448
|
+
meta: parsed.meta,
|
|
1449
|
+
body: parsed.body
|
|
1450
|
+
});
|
|
1451
|
+
});
|
|
1452
|
+
});
|
|
1453
|
+
const seen = new Set();
|
|
1454
|
+
return rows.reverse().filter((row) => {
|
|
1455
|
+
if (seen.has(row.command)) return false;
|
|
1456
|
+
seen.add(row.command);
|
|
1457
|
+
return true;
|
|
1458
|
+
}).reverse();
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function getAllSlashCommandRows(opts = {}) {
|
|
1462
|
+
const custom = loadCustomSlashCommands(opts).map((row) => [row.command, `${row.description} (${row.scope} ${row.type})`]);
|
|
1463
|
+
const existing = new Set(SLASH_COMMANDS.map(([command]) => command.split(/\s+/)[0]));
|
|
1464
|
+
return SLASH_COMMANDS.concat(custom.filter(([command]) => !existing.has(command)));
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function redactSensitiveText(text) {
|
|
1468
|
+
return String(text || '')
|
|
1469
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, 'Bearer [REDACTED]')
|
|
1470
|
+
.replace(/(sk-[A-Za-z0-9_-]{12,})/g, '[REDACTED_API_KEY]')
|
|
1471
|
+
.replace(/(npm_[A-Za-z0-9]{12,})/g, '[REDACTED_NPM_TOKEN]')
|
|
1472
|
+
.replace(/("?(?:password|token|api[_-]?key|secret)"?\s*[:=]\s*)("[^"]+"|'[^']+'|[^\s,}]+)/gi, '$1[REDACTED]');
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function redactPayload(value) {
|
|
1476
|
+
if (typeof value === 'string') return redactSensitiveText(value);
|
|
1477
|
+
if (Array.isArray(value)) return value.map((item) => redactPayload(item));
|
|
1478
|
+
if (value && typeof value === 'object') {
|
|
1479
|
+
const out = {};
|
|
1480
|
+
Object.entries(value).forEach(([key, item]) => {
|
|
1481
|
+
const sensitiveKey = /password|token|api[_-]?key|secret|authorization|cookie/i.test(key);
|
|
1482
|
+
out[key] = sensitiveKey ? '[REDACTED]' : redactPayload(item);
|
|
1483
|
+
});
|
|
1484
|
+
return out;
|
|
1485
|
+
}
|
|
1486
|
+
return value;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function getTranscriptDir(opts) {
|
|
1490
|
+
return path.join(getBortexProjectDir(opts), 'transcripts');
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
function ensureTranscriptId(opts) {
|
|
1494
|
+
if (opts.transcriptId) return opts.transcriptId;
|
|
1495
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1496
|
+
const slug = safeSlug(path.basename(opts.cwd || process.cwd()), 'session');
|
|
1497
|
+
const rand = crypto.randomBytes(4).toString('hex');
|
|
1498
|
+
opts.transcriptId = `${stamp}-${slug}-${rand}`;
|
|
1499
|
+
return opts.transcriptId;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
function getCurrentTranscriptPath(opts) {
|
|
1503
|
+
return path.join(getTranscriptDir(opts), `${ensureTranscriptId(opts)}.jsonl`);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function appendTranscriptEvent(opts, type, payload = {}) {
|
|
1507
|
+
try {
|
|
1508
|
+
if (opts?.execEphemeral || opts?.ephemeral) return null;
|
|
1509
|
+
const filePath = getCurrentTranscriptPath(opts);
|
|
1510
|
+
ensureDirSync(path.dirname(filePath));
|
|
1511
|
+
const event = {
|
|
1512
|
+
version: 1,
|
|
1513
|
+
id: crypto.randomBytes(6).toString('hex'),
|
|
1514
|
+
ts: Date.now(),
|
|
1515
|
+
type: String(type || 'event'),
|
|
1516
|
+
cwd: opts.cwd || process.cwd(),
|
|
1517
|
+
payload: redactPayload(payload || {})
|
|
1518
|
+
};
|
|
1519
|
+
fs.appendFileSync(filePath, `${JSON.stringify(event)}\n`, 'utf8');
|
|
1520
|
+
return filePath;
|
|
1521
|
+
} catch (_err) {
|
|
1522
|
+
return null;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function listTranscriptFiles(opts, all = false) {
|
|
1527
|
+
const roots = [getTranscriptDir(opts)];
|
|
1528
|
+
if (all) roots.push(path.join(getBortexUserDir(), 'transcripts'));
|
|
1529
|
+
const files = [];
|
|
1530
|
+
roots.forEach((dirPath) => {
|
|
1531
|
+
try {
|
|
1532
|
+
if (!fs.existsSync(dirPath)) return;
|
|
1533
|
+
fs.readdirSync(dirPath)
|
|
1534
|
+
.filter((name) => /\.jsonl$/i.test(name))
|
|
1535
|
+
.forEach((name) => {
|
|
1536
|
+
const filePath = path.join(dirPath, name);
|
|
1537
|
+
const stat = fs.statSync(filePath);
|
|
1538
|
+
files.push({ id: path.basename(name, '.jsonl'), filePath, mtime: stat.mtimeMs, size: stat.size });
|
|
1539
|
+
});
|
|
1540
|
+
} catch (_err) {}
|
|
1541
|
+
});
|
|
1542
|
+
return files.sort((a, b) => b.mtime - a.mtime);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function readTranscriptEvents(filePath, maxEvents = 80) {
|
|
1546
|
+
try {
|
|
1547
|
+
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/).filter(Boolean);
|
|
1548
|
+
return lines.slice(-maxEvents).map((line) => JSON.parse(line));
|
|
1549
|
+
} catch (_err) {
|
|
1550
|
+
return [];
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
function summarizeTranscriptForResume(filePath) {
|
|
1555
|
+
const events = readTranscriptEvents(filePath, 60);
|
|
1556
|
+
const turns = [];
|
|
1557
|
+
events.forEach((event) => {
|
|
1558
|
+
const type = event.type;
|
|
1559
|
+
const payload = event.payload || {};
|
|
1560
|
+
if (type === 'user') turns.push(`User: ${String(payload.text || '').slice(0, 500)}`);
|
|
1561
|
+
if (type === 'assistant') turns.push(`Assistant: ${String(payload.text || payload.message || '').slice(0, 500)}`);
|
|
1562
|
+
if (type === 'tool') turns.push(`Tool: ${String(payload.command || payload.name || '').slice(0, 240)}`);
|
|
1563
|
+
});
|
|
1564
|
+
return turns.slice(-20).join('\n');
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function findTranscriptByRef(opts, ref = '', all = false) {
|
|
1568
|
+
const rows = listTranscriptFiles(opts, all);
|
|
1569
|
+
if (!rows.length) return null;
|
|
1570
|
+
const target = String(ref || 'last').trim();
|
|
1571
|
+
if (!target || target === 'last' || target === '--last') return rows[0];
|
|
1572
|
+
return rows.find((row) => row.id === target || row.id.includes(target) || row.filePath === target) || null;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
function maybeAugmentPromptWithResumeContext(opts, prompt) {
|
|
1576
|
+
if (!opts.resumeContext || opts._resumeContextUsed) return prompt;
|
|
1577
|
+
opts._resumeContextUsed = true;
|
|
1578
|
+
return [
|
|
1579
|
+
'Previous Bortex Code session context:',
|
|
1580
|
+
opts.resumeContext,
|
|
1581
|
+
'',
|
|
1582
|
+
'Continue from that context and answer this request:',
|
|
1583
|
+
String(prompt || '')
|
|
1584
|
+
].join('\n');
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
async function prepareResumeContext(opts) {
|
|
1588
|
+
const found = findTranscriptByRef(opts, opts.resumeTarget || 'last', opts.resumeAll);
|
|
1589
|
+
if (!found) {
|
|
1590
|
+
console.log('No transcript found to resume.');
|
|
1591
|
+
return null;
|
|
1592
|
+
}
|
|
1593
|
+
opts.resumeTranscript = found;
|
|
1594
|
+
opts.resumeContext = summarizeTranscriptForResume(found.filePath);
|
|
1595
|
+
console.log(`Resumed transcript: ${found.id}`);
|
|
1596
|
+
console.log(`Transcript path: ${found.filePath}`);
|
|
1597
|
+
return found;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const BUILTIN_SUBAGENTS = [
|
|
1601
|
+
{ name: 'reviewer', description: 'Find regressions, risky diffs, missing tests, and code review findings.' },
|
|
1602
|
+
{ name: 'security', description: 'Inspect secrets, auth, injection, unsafe shell/network/file operations, and trust boundaries.' },
|
|
1603
|
+
{ name: 'tester', description: 'Identify test strategy, missing coverage, flaky paths, and verification commands.' },
|
|
1604
|
+
{ name: 'architect', description: 'Assess architecture, module boundaries, maintainability, and migration risks.' },
|
|
1605
|
+
{ name: 'fixer', description: 'Propose concrete implementation steps and patches for the current task.' },
|
|
1606
|
+
{ name: 'researcher', description: 'Gather local context, docs, dependencies, and unknowns before implementation.' }
|
|
1607
|
+
];
|
|
1608
|
+
|
|
1609
|
+
function loadCustomSubagents(opts) {
|
|
1610
|
+
const roots = [
|
|
1611
|
+
{ scope: 'user', dir: path.join(getBortexUserDir(), 'agents') },
|
|
1612
|
+
{ scope: 'project', dir: path.join(getBortexProjectDir(opts), 'agents') }
|
|
1613
|
+
];
|
|
1614
|
+
const rows = [];
|
|
1615
|
+
roots.forEach((root) => {
|
|
1616
|
+
listMarkdownFiles(root.dir).forEach((filePath) => {
|
|
1617
|
+
const parsed = parseFrontmatterMarkdown(fs.readFileSync(filePath, 'utf8'));
|
|
1618
|
+
rows.push({
|
|
1619
|
+
scope: root.scope,
|
|
1620
|
+
name: safeSlug(parsed.meta.name || path.basename(filePath, '.md')),
|
|
1621
|
+
description: String(parsed.meta.description || parsed.body.split(/\r?\n/).find(Boolean) || 'Custom agent').slice(0, 240),
|
|
1622
|
+
instructions: parsed.body,
|
|
1623
|
+
filePath
|
|
1624
|
+
});
|
|
1625
|
+
});
|
|
1626
|
+
});
|
|
1627
|
+
return rows;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
function getAllSubagents(opts) {
|
|
1631
|
+
const custom = loadCustomSubagents(opts);
|
|
1632
|
+
const map = new Map();
|
|
1633
|
+
BUILTIN_SUBAGENTS.forEach((a) => map.set(a.name, { ...a, scope: 'builtin', instructions: a.description }));
|
|
1634
|
+
custom.forEach((a) => map.set(a.name, a));
|
|
1635
|
+
return Array.from(map.values());
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function getMcpConfigPath(opts, scope = 'project') {
|
|
1639
|
+
return scope === 'user'
|
|
1640
|
+
? path.join(getBortexUserDir(), 'mcp.json')
|
|
1641
|
+
: path.join(getBortexProjectDir(opts), 'mcp.json');
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
function loadMcpConfig(opts, scope = 'project') {
|
|
1645
|
+
const filePath = getMcpConfigPath(opts, scope);
|
|
1646
|
+
const raw = safeReadJson(filePath, { mcpServers: {} });
|
|
1647
|
+
const servers = raw && typeof raw === 'object' && raw.mcpServers && typeof raw.mcpServers === 'object' ? raw.mcpServers : {};
|
|
1648
|
+
return { filePath, mcpServers: servers };
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
function saveMcpConfig(opts, scope, config) {
|
|
1652
|
+
safeWriteJson(getMcpConfigPath(opts, scope), { mcpServers: config.mcpServers || {} });
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function getMergedMcpServers(opts) {
|
|
1656
|
+
const user = loadMcpConfig(opts, 'user');
|
|
1657
|
+
const project = loadMcpConfig(opts, 'project');
|
|
1658
|
+
const rows = [];
|
|
1659
|
+
Object.entries(user.mcpServers).forEach(([name, server]) => rows.push({ scope: 'user', name, server }));
|
|
1660
|
+
discoverPlugins(opts).filter((plugin) => plugin.enabled).forEach((plugin) => {
|
|
1661
|
+
const servers = plugin.manifest.mcpServers && typeof plugin.manifest.mcpServers === 'object' ? plugin.manifest.mcpServers : {};
|
|
1662
|
+
Object.entries(servers).forEach(([name, server]) => rows.push({ scope: `plugin:${plugin.id}`, name, server }));
|
|
1663
|
+
});
|
|
1664
|
+
Object.entries(project.mcpServers).forEach(([name, server]) => rows.push({ scope: 'project', name, server }));
|
|
1665
|
+
return rows;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
function resolveMcpServer(opts, name) {
|
|
1669
|
+
const target = String(name || '').trim();
|
|
1670
|
+
return getMergedMcpServers(opts).find((row) => row.name === target) || null;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function buildMcpClientInfo() {
|
|
1674
|
+
return { name: 'bortexcode', version: CLI_VERSION };
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function parseMcpJsonLines(buffer, onMessage) {
|
|
1678
|
+
let remaining = buffer;
|
|
1679
|
+
let nl = remaining.indexOf('\n');
|
|
1680
|
+
while (nl >= 0) {
|
|
1681
|
+
const line = remaining.slice(0, nl).trim();
|
|
1682
|
+
remaining = remaining.slice(nl + 1);
|
|
1683
|
+
if (line) {
|
|
1684
|
+
try { onMessage(JSON.parse(line)); } catch (_err) {}
|
|
1685
|
+
}
|
|
1686
|
+
nl = remaining.indexOf('\n');
|
|
1687
|
+
}
|
|
1688
|
+
return remaining;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
async function runMcpStdioRequest(opts, row, method, params = {}, options = {}) {
|
|
1692
|
+
const server = row.server || {};
|
|
1693
|
+
if (!server.command) throw new Error(`MCP server "${row.name}" has no command.`);
|
|
1694
|
+
const timeoutMs = Math.max(1000, Number(server.tool_timeout_ms || server.tool_timeout_sec * 1000 || options.timeoutMs || 60000));
|
|
1695
|
+
const env = { ...process.env, ...(server.env || {}) };
|
|
1696
|
+
const child = spawn(String(server.command), Array.isArray(server.args) ? server.args.map(String) : [], {
|
|
1697
|
+
cwd: server.cwd ? resolveCliPath(opts, server.cwd) : (opts.cwd || process.cwd()),
|
|
1698
|
+
env,
|
|
1699
|
+
shell: false,
|
|
1700
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1701
|
+
windowsHide: true
|
|
1702
|
+
});
|
|
1703
|
+
let nextId = 1;
|
|
1704
|
+
let stdoutBuffer = '';
|
|
1705
|
+
let stderr = '';
|
|
1706
|
+
const pending = new Map();
|
|
1707
|
+
let settled = false;
|
|
1708
|
+
|
|
1709
|
+
const close = () => {
|
|
1710
|
+
if (settled) return;
|
|
1711
|
+
settled = true;
|
|
1712
|
+
try { child.stdin.end(); } catch (_err) {}
|
|
1713
|
+
try { child.kill('SIGTERM'); } catch (_err) {}
|
|
1714
|
+
setTimeout(() => {
|
|
1715
|
+
try { child.kill('SIGKILL'); } catch (_err) {}
|
|
1716
|
+
}, 1000).unref?.();
|
|
1717
|
+
};
|
|
1718
|
+
|
|
1719
|
+
const send = (message) => {
|
|
1720
|
+
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
1721
|
+
};
|
|
1722
|
+
const request = (reqMethod, reqParams = {}) => new Promise((resolve, reject) => {
|
|
1723
|
+
const id = nextId++;
|
|
1724
|
+
pending.set(id, { resolve, reject });
|
|
1725
|
+
send({ jsonrpc: '2.0', id, method: reqMethod, params: reqParams });
|
|
1726
|
+
});
|
|
1727
|
+
|
|
1728
|
+
child.stdout.on('data', (chunk) => {
|
|
1729
|
+
stdoutBuffer += String(chunk || '');
|
|
1730
|
+
stdoutBuffer = parseMcpJsonLines(stdoutBuffer, (msg) => {
|
|
1731
|
+
if (!msg || typeof msg !== 'object' || msg.id == null) return;
|
|
1732
|
+
const waiter = pending.get(msg.id);
|
|
1733
|
+
if (!waiter) return;
|
|
1734
|
+
pending.delete(msg.id);
|
|
1735
|
+
if (msg.error) waiter.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
|
|
1736
|
+
else waiter.resolve(msg.result);
|
|
1737
|
+
});
|
|
1738
|
+
});
|
|
1739
|
+
child.stderr.on('data', (chunk) => {
|
|
1740
|
+
stderr += String(chunk || '');
|
|
1741
|
+
});
|
|
1742
|
+
child.on('error', (err) => {
|
|
1743
|
+
for (const waiter of pending.values()) waiter.reject(err);
|
|
1744
|
+
pending.clear();
|
|
1745
|
+
});
|
|
1746
|
+
child.on('close', (code) => {
|
|
1747
|
+
const err = new Error(`MCP server "${row.name}" exited with code ${code}${stderr ? `: ${stderr.trim().slice(0, 500)}` : ''}`);
|
|
1748
|
+
for (const waiter of pending.values()) waiter.reject(err);
|
|
1749
|
+
pending.clear();
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
const timer = setTimeout(() => {
|
|
1753
|
+
const err = new Error(`MCP request timed out after ${timeoutMs}ms.`);
|
|
1754
|
+
for (const waiter of pending.values()) waiter.reject(err);
|
|
1755
|
+
pending.clear();
|
|
1756
|
+
close();
|
|
1757
|
+
}, timeoutMs);
|
|
1758
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
1759
|
+
|
|
1760
|
+
try {
|
|
1761
|
+
await request('initialize', {
|
|
1762
|
+
protocolVersion: server.protocolVersion || '2025-06-18',
|
|
1763
|
+
capabilities: {},
|
|
1764
|
+
clientInfo: buildMcpClientInfo()
|
|
1765
|
+
});
|
|
1766
|
+
send({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} });
|
|
1767
|
+
return await request(method, params);
|
|
1768
|
+
} finally {
|
|
1769
|
+
clearTimeout(timer);
|
|
1770
|
+
close();
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
function mcpHttpHeaders(server) {
|
|
1775
|
+
const headers = { 'Content-Type': 'application/json', ...(server.headers || {}), ...(server.http_headers || {}) };
|
|
1776
|
+
const bearerEnv = server.bearerTokenEnvVar || server.bearer_token_env_var;
|
|
1777
|
+
if (bearerEnv && process.env[bearerEnv]) headers.Authorization = `Bearer ${process.env[bearerEnv]}`;
|
|
1778
|
+
if (server.env_http_headers && typeof server.env_http_headers === 'object') {
|
|
1779
|
+
Object.entries(server.env_http_headers).forEach(([key, envName]) => {
|
|
1780
|
+
if (process.env[String(envName)]) headers[key] = process.env[String(envName)];
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
return headers;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
async function runMcpHttpRequest(_opts, row, method, params = {}) {
|
|
1787
|
+
const server = row.server || {};
|
|
1788
|
+
if (!server.url) throw new Error(`MCP server "${row.name}" has no URL.`);
|
|
1789
|
+
const body = {
|
|
1790
|
+
jsonrpc: '2.0',
|
|
1791
|
+
id: Date.now(),
|
|
1792
|
+
method,
|
|
1793
|
+
params
|
|
1794
|
+
};
|
|
1795
|
+
const res = await fetch(server.url, {
|
|
1796
|
+
method: 'POST',
|
|
1797
|
+
headers: mcpHttpHeaders(server),
|
|
1798
|
+
body: JSON.stringify(body)
|
|
1799
|
+
});
|
|
1800
|
+
const text = await res.text();
|
|
1801
|
+
if (!res.ok) throw new Error(`MCP HTTP ${res.status}: ${text.slice(0, 1000)}`);
|
|
1802
|
+
const jsonText = text.includes('\ndata:')
|
|
1803
|
+
? text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).filter((line) => line && line !== '[DONE]').pop()
|
|
1804
|
+
: text.trim();
|
|
1805
|
+
const payload = JSON.parse(jsonText || '{}');
|
|
1806
|
+
if (payload.error) throw new Error(payload.error.message || JSON.stringify(payload.error));
|
|
1807
|
+
return payload.result ?? payload;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
async function runMcpRequest(opts, serverName, method, params = {}, options = {}) {
|
|
1811
|
+
const row = resolveMcpServer(opts, serverName);
|
|
1812
|
+
if (!row) throw new Error(`MCP server not found: ${serverName}`);
|
|
1813
|
+
const server = row.server || {};
|
|
1814
|
+
const type = server.type || (server.command ? 'stdio' : 'http');
|
|
1815
|
+
if (type === 'stdio') return runMcpStdioRequest(opts, row, method, params, options);
|
|
1816
|
+
if (type === 'http' || server.url) return runMcpHttpRequest(opts, row, method, params, options);
|
|
1817
|
+
throw new Error(`Unsupported MCP server type for "${serverName}".`);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function parseScopeFlag(rest) {
|
|
1821
|
+
const user = rest.includes('--global') || rest.includes('--user');
|
|
1822
|
+
const cleaned = rest.filter((x) => x !== '--global' && x !== '--user' && x !== '--project');
|
|
1823
|
+
return { scope: user ? 'user' : 'project', rest: cleaned };
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
function getPermissionsConfigPath(opts, scope = 'project') {
|
|
1827
|
+
return scope === 'user'
|
|
1828
|
+
? path.join(getBortexUserDir(), 'permissions.json')
|
|
1829
|
+
: path.join(getBortexProjectDir(opts), 'permissions.json');
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
function loadPermissionsConfig(opts, scope = 'project') {
|
|
1833
|
+
return safeReadJson(getPermissionsConfigPath(opts, scope), { defaultProfile: '', profiles: {} }) || { defaultProfile: '', profiles: {} };
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
function getEffectivePermissionProfile(opts) {
|
|
1837
|
+
const project = loadPermissionsConfig(opts, 'project');
|
|
1838
|
+
const user = loadPermissionsConfig(opts, 'user');
|
|
1839
|
+
const name = safeSlug(opts.permissionProfile || project.defaultProfile || user.defaultProfile || 'workspace');
|
|
1840
|
+
const builtins = {
|
|
1841
|
+
'read-only': {
|
|
1842
|
+
name: 'read-only',
|
|
1843
|
+
allowWrites: false,
|
|
1844
|
+
allowShell: false,
|
|
1845
|
+
allowNetwork: false,
|
|
1846
|
+
denyPatterns: ['rm -rf', 'del /s', 'format ', 'shutdown', 'reboot']
|
|
1847
|
+
},
|
|
1848
|
+
workspace: {
|
|
1849
|
+
name: 'workspace',
|
|
1850
|
+
allowWrites: true,
|
|
1851
|
+
allowShell: true,
|
|
1852
|
+
allowNetwork: false,
|
|
1853
|
+
denyPatterns: ['rm -rf /', 'rm -rf .', 'format ', 'shutdown', 'reboot']
|
|
1854
|
+
},
|
|
1855
|
+
full: {
|
|
1856
|
+
name: 'full',
|
|
1857
|
+
allowWrites: true,
|
|
1858
|
+
allowShell: true,
|
|
1859
|
+
allowNetwork: true,
|
|
1860
|
+
denyPatterns: []
|
|
1861
|
+
},
|
|
1862
|
+
'danger-full-access': {
|
|
1863
|
+
name: 'danger-full-access',
|
|
1864
|
+
allowWrites: true,
|
|
1865
|
+
allowShell: true,
|
|
1866
|
+
allowNetwork: true,
|
|
1867
|
+
denyPatterns: []
|
|
1868
|
+
}
|
|
1869
|
+
};
|
|
1870
|
+
const custom = {
|
|
1871
|
+
...(user.profiles && typeof user.profiles === 'object' ? user.profiles : {}),
|
|
1872
|
+
...(project.profiles && typeof project.profiles === 'object' ? project.profiles : {})
|
|
1873
|
+
};
|
|
1874
|
+
return { ...(builtins[name] || builtins.workspace), ...(custom[name] || {}), name };
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
function checkLocalPermission(opts, lc, line) {
|
|
1878
|
+
if (lc === '/permissions' || lc === '/help' || lc === '/status' || lc === '/commands') return { allowed: true };
|
|
1879
|
+
const profile = getEffectivePermissionProfile(opts);
|
|
1880
|
+
const text = String(line || '');
|
|
1881
|
+
const writeCommands = new Set(['/write', '/append', '/mkdir']);
|
|
1882
|
+
const shellCommands = new Set(['/sh', '/shell', '/git', '/tool-run', '/agent-run', '/run']);
|
|
1883
|
+
if (!profile.allowWrites && writeCommands.has(lc)) {
|
|
1884
|
+
return { allowed: false, reason: `Permission profile "${profile.name}" does not allow file writes.` };
|
|
1885
|
+
}
|
|
1886
|
+
if (!profile.allowShell && shellCommands.has(lc)) {
|
|
1887
|
+
return { allowed: false, reason: `Permission profile "${profile.name}" does not allow shell/tool execution.` };
|
|
1888
|
+
}
|
|
1889
|
+
const denyPatterns = Array.isArray(profile.denyPatterns) ? profile.denyPatterns : [];
|
|
1890
|
+
const lowered = text.toLowerCase();
|
|
1891
|
+
const blocked = denyPatterns.find((pattern) => lowered.includes(String(pattern).toLowerCase()));
|
|
1892
|
+
if (blocked) return { allowed: false, reason: `Permission profile "${profile.name}" denied pattern: ${blocked}` };
|
|
1893
|
+
return { allowed: true, profile };
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
async function handlePermissionsCommand(opts, rest) {
|
|
1897
|
+
const sub = String(rest[0] || 'show').toLowerCase();
|
|
1898
|
+
if (sub === 'show' || sub === 'status') {
|
|
1899
|
+
const profile = getEffectivePermissionProfile(opts);
|
|
1900
|
+
console.log(JSON.stringify({
|
|
1901
|
+
selected: opts.permissionProfile || 'workspace',
|
|
1902
|
+
effective: profile,
|
|
1903
|
+
projectConfig: getPermissionsConfigPath(opts, 'project'),
|
|
1904
|
+
userConfig: getPermissionsConfigPath(opts, 'user')
|
|
1905
|
+
}, null, 2));
|
|
1906
|
+
return { handled: true, data: profile };
|
|
1907
|
+
}
|
|
1908
|
+
if (sub === 'init') {
|
|
1909
|
+
const filePath = getPermissionsConfigPath(opts, 'project');
|
|
1910
|
+
if (fs.existsSync(filePath)) {
|
|
1911
|
+
console.log(`Permissions file already exists: ${filePath}`);
|
|
1912
|
+
return { handled: true };
|
|
1913
|
+
}
|
|
1914
|
+
safeWriteJson(filePath, {
|
|
1915
|
+
defaultProfile: 'workspace',
|
|
1916
|
+
profiles: {
|
|
1917
|
+
workspace: {
|
|
1918
|
+
allowWrites: true,
|
|
1919
|
+
allowShell: true,
|
|
1920
|
+
allowNetwork: false,
|
|
1921
|
+
denyPatterns: ['rm -rf /', 'format ', 'shutdown', 'reboot']
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
});
|
|
1925
|
+
console.log(`Created permissions file: ${filePath}`);
|
|
1926
|
+
return { handled: true, data: { filePath } };
|
|
1927
|
+
}
|
|
1928
|
+
if (sub === 'profile' || sub === 'use') {
|
|
1929
|
+
const name = safeSlug(rest[1] || '');
|
|
1930
|
+
if (!name) {
|
|
1931
|
+
console.log('Usage: /permissions profile <read-only|workspace|full>');
|
|
1932
|
+
return { handled: true };
|
|
1933
|
+
}
|
|
1934
|
+
opts.permissionProfile = name;
|
|
1935
|
+
console.log(`Permission profile: ${name}`);
|
|
1936
|
+
return { handled: true, data: getEffectivePermissionProfile(opts) };
|
|
1937
|
+
}
|
|
1938
|
+
if (sub === 'check') {
|
|
1939
|
+
const line = rest.slice(1).join(' ');
|
|
1940
|
+
const [cmd] = parseWords(line);
|
|
1941
|
+
const result = checkLocalPermission(opts, String(cmd || '').toLowerCase(), line);
|
|
1942
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1943
|
+
return { handled: true, data: result };
|
|
1944
|
+
}
|
|
1945
|
+
console.log('Usage: /permissions show|init|profile <name>|check <slash-command>');
|
|
1946
|
+
return { handled: true };
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
function getHookFiles(opts) {
|
|
1950
|
+
const files = [
|
|
1951
|
+
{ scope: 'user', filePath: path.join(getBortexUserDir(), 'hooks.json') },
|
|
1952
|
+
{ scope: 'project', filePath: path.join(getBortexProjectDir(opts), 'hooks.json') }
|
|
1953
|
+
];
|
|
1954
|
+
discoverPlugins(opts).filter((plugin) => plugin.enabled).forEach((plugin) => {
|
|
1955
|
+
const rel = plugin.manifest.hooksFile || path.join('hooks', 'hooks.json');
|
|
1956
|
+
files.push({ scope: `plugin:${plugin.id}`, filePath: path.join(plugin.dir, rel) });
|
|
1957
|
+
});
|
|
1958
|
+
return files;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
function loadHookEntries(opts, eventName = '') {
|
|
1962
|
+
const rows = [];
|
|
1963
|
+
getHookFiles(opts).forEach((source) => {
|
|
1964
|
+
const raw = safeReadJson(source.filePath, null);
|
|
1965
|
+
if (!raw || typeof raw !== 'object') return;
|
|
1966
|
+
const hooksRoot = raw.hooks && typeof raw.hooks === 'object' ? raw.hooks : raw;
|
|
1967
|
+
Object.entries(hooksRoot).forEach(([event, hooks]) => {
|
|
1968
|
+
if (eventName && event !== eventName) return;
|
|
1969
|
+
const list = Array.isArray(hooks) ? hooks : [];
|
|
1970
|
+
list.forEach((hook, index) => {
|
|
1971
|
+
if (Array.isArray(hook?.hooks)) {
|
|
1972
|
+
hook.hooks.forEach((inner, innerIndex) => rows.push({
|
|
1973
|
+
...inner,
|
|
1974
|
+
event,
|
|
1975
|
+
matcher: inner.matcher || hook.matcher || '',
|
|
1976
|
+
index,
|
|
1977
|
+
innerIndex,
|
|
1978
|
+
source: source.scope,
|
|
1979
|
+
filePath: source.filePath
|
|
1980
|
+
}));
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
rows.push({ ...hook, event, index, source: source.scope, filePath: source.filePath });
|
|
1984
|
+
});
|
|
1985
|
+
});
|
|
1986
|
+
});
|
|
1987
|
+
return rows.map((row) => ({ ...row, hash: hashHookEntry(row) }));
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
function hashHookEntry(hook) {
|
|
1991
|
+
const stable = {
|
|
1992
|
+
event: hook.event,
|
|
1993
|
+
matcher: hook.matcher || '',
|
|
1994
|
+
type: hook.type || 'command',
|
|
1995
|
+
command: hook.command || '',
|
|
1996
|
+
source: hook.source,
|
|
1997
|
+
filePath: hook.filePath
|
|
1998
|
+
};
|
|
1999
|
+
return crypto.createHash('sha256').update(JSON.stringify(stable)).digest('hex').slice(0, 16);
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
function getHookTrustPath() {
|
|
2003
|
+
return path.join(getBortexUserDir(), 'hooks-trust.json');
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
function loadHookTrust() {
|
|
2007
|
+
const raw = safeReadJson(getHookTrustPath(), { trusted: {}, disabled: {} });
|
|
2008
|
+
return {
|
|
2009
|
+
trusted: raw && typeof raw.trusted === 'object' ? raw.trusted : {},
|
|
2010
|
+
disabled: raw && typeof raw.disabled === 'object' ? raw.disabled : {}
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
function saveHookTrust(trust) {
|
|
2015
|
+
safeWriteJson(getHookTrustPath(), {
|
|
2016
|
+
trusted: trust.trusted || {},
|
|
2017
|
+
disabled: trust.disabled || {}
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
function annotateHookTrust(rows) {
|
|
2022
|
+
const trust = loadHookTrust();
|
|
2023
|
+
return rows.map((row) => ({
|
|
2024
|
+
...row,
|
|
2025
|
+
trusted: !!trust.trusted[row.hash],
|
|
2026
|
+
disabled: !!trust.disabled[row.hash]
|
|
2027
|
+
}));
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
function hookMatches(hook, payload) {
|
|
2031
|
+
const matcher = String(hook.matcher || '').trim();
|
|
2032
|
+
if (!matcher || matcher === '*') return true;
|
|
2033
|
+
const haystack = [
|
|
2034
|
+
payload.tool_name,
|
|
2035
|
+
payload.command,
|
|
2036
|
+
payload.tool_input?.command,
|
|
2037
|
+
payload.prompt
|
|
2038
|
+
].filter(Boolean).join(' ');
|
|
2039
|
+
if (!haystack) return false;
|
|
2040
|
+
if (matcher.startsWith('/') && matcher.endsWith('/')) {
|
|
2041
|
+
try { return new RegExp(matcher.slice(1, -1)).test(haystack); } catch (_err) { return false; }
|
|
2042
|
+
}
|
|
2043
|
+
return haystack.toLowerCase().includes(matcher.toLowerCase());
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
async function runBortexHooks(opts, eventName, payload = {}, options = {}) {
|
|
2047
|
+
if (opts._runningBortexHooks) return { denied: false, results: [] };
|
|
2048
|
+
const hooks = annotateHookTrust(loadHookEntries(opts, eventName))
|
|
2049
|
+
.filter((hook) => !hook.disabled)
|
|
2050
|
+
.filter((hook) => !(process.env.BORTEX_HOOK_TRUST === '1' && hook.source !== 'user' && !hook.trusted))
|
|
2051
|
+
.filter((hook) => hookMatches(hook, payload));
|
|
2052
|
+
const results = [];
|
|
2053
|
+
if (!hooks.length) return { denied: false, results };
|
|
2054
|
+
opts._runningBortexHooks = true;
|
|
2055
|
+
try {
|
|
2056
|
+
for (const hook of hooks) {
|
|
2057
|
+
if (String(hook.type || 'command') !== 'command' || !hook.command) continue;
|
|
2058
|
+
const hookPayload = {
|
|
2059
|
+
event: eventName,
|
|
2060
|
+
cwd: opts.cwd || process.cwd(),
|
|
2061
|
+
source: hook.source,
|
|
2062
|
+
payload
|
|
2063
|
+
};
|
|
2064
|
+
const res = await runChild(String(hook.command), [], {
|
|
2065
|
+
cwd: opts.cwd,
|
|
2066
|
+
shell: true,
|
|
2067
|
+
env: {
|
|
2068
|
+
BORTEX_HOOK_EVENT: eventName,
|
|
2069
|
+
BORTEX_HOOK_PAYLOAD: redactSensitiveText(JSON.stringify(hookPayload))
|
|
2070
|
+
}
|
|
2071
|
+
});
|
|
2072
|
+
let parsed = null;
|
|
2073
|
+
try { parsed = JSON.parse(String(res.stdout || '').trim()); } catch (_err) {}
|
|
2074
|
+
results.push({ hook, ok: res.ok, stdout: res.stdout, stderr: res.stderr, parsed });
|
|
2075
|
+
const decision = parsed?.permissionDecision || parsed?.hookSpecificOutput?.permissionDecision || parsed?.decision;
|
|
2076
|
+
if (options.decision && ['deny', 'block'].includes(String(decision || '').toLowerCase())) {
|
|
2077
|
+
return {
|
|
2078
|
+
denied: true,
|
|
2079
|
+
reason: parsed?.permissionDecisionReason || parsed?.hookSpecificOutput?.permissionDecisionReason || parsed?.reason || `Denied by ${hook.source} hook`,
|
|
2080
|
+
results
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
if (options.decision && ['ask'].includes(String(decision || '').toLowerCase())) {
|
|
2084
|
+
const ok = await confirmDangerous(parsed?.reason || `Hook ${hook.source}:${eventName} asks for approval. Continue?`);
|
|
2085
|
+
if (!ok) return { denied: true, reason: 'Rejected by user after hook ask decision', results };
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
} finally {
|
|
2089
|
+
opts._runningBortexHooks = false;
|
|
2090
|
+
}
|
|
2091
|
+
return { denied: false, results };
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
async function renderCustomSlashPrompt(opts, entry, argsText) {
|
|
2095
|
+
let body = String(entry.body || '');
|
|
2096
|
+
body = body
|
|
2097
|
+
.replace(/\$ARGUMENTS/g, argsText)
|
|
2098
|
+
.replace(/\$\{BORTEX_CWD\}/g, opts.cwd || process.cwd())
|
|
2099
|
+
.replace(/\$\{BORTEX_SESSION_ID\}/g, opts.transcriptId || ensureTranscriptId(opts))
|
|
2100
|
+
.replace(/\$\{BORTEX_COMMAND_DIR\}/g, path.dirname(entry.filePath));
|
|
2101
|
+
|
|
2102
|
+
if (entry.meta && entry.meta['allow-shell-injection'] === true) {
|
|
2103
|
+
const lines = [];
|
|
2104
|
+
for (const line of body.split(/\r?\n/)) {
|
|
2105
|
+
const match = line.match(/!\`([^`]+)\`/);
|
|
2106
|
+
if (!match) {
|
|
2107
|
+
lines.push(line);
|
|
2108
|
+
continue;
|
|
2109
|
+
}
|
|
2110
|
+
const res = await runChild(match[1], [], { cwd: opts.cwd, shell: true });
|
|
2111
|
+
const replacement = redactSensitiveText((res.stdout || res.stderr || '').trim()).slice(0, 12000);
|
|
2112
|
+
lines.push(line.replace(match[0], replacement || '(no output)'));
|
|
2113
|
+
}
|
|
2114
|
+
body = lines.join('\n');
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
return [
|
|
2118
|
+
`Run Bortex ${entry.type}: /${entry.name}`,
|
|
2119
|
+
entry.description ? `Purpose: ${entry.description}` : '',
|
|
2120
|
+
argsText ? `Arguments: ${argsText}` : '',
|
|
2121
|
+
'',
|
|
2122
|
+
body
|
|
2123
|
+
].filter(Boolean).join('\n');
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
async function handleCustomSlashCommand(opts, lc, rest, line) {
|
|
2127
|
+
const entry = loadCustomSlashCommands(opts).find((row) => row.command.toLowerCase() === lc);
|
|
2128
|
+
if (!entry) return false;
|
|
2129
|
+
const argsText = line.slice(line.indexOf(lc) + lc.length).trim();
|
|
2130
|
+
const prompt = await renderCustomSlashPrompt(opts, entry, argsText);
|
|
2131
|
+
recordArtifact(opts, entry.type, `${entry.command} ${argsText}`.trim());
|
|
2132
|
+
appendTranscriptEvent(opts, 'tool', { command: entry.command, type: entry.type, filePath: entry.filePath, args: argsText });
|
|
2133
|
+
if (opts.offline) {
|
|
2134
|
+
console.log(prompt);
|
|
2135
|
+
return true;
|
|
2136
|
+
}
|
|
2137
|
+
const data = await askServer(opts, maybeAugmentPromptWithResumeContext(opts, prompt));
|
|
2138
|
+
printResponse(opts, data);
|
|
2139
|
+
appendTranscriptEvent(opts, 'assistant', { text: data?.message || JSON.stringify(data || {}) });
|
|
2140
|
+
return true;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
async function handleSkillsCommand(opts, rest) {
|
|
2144
|
+
const sub = String(rest[0] || 'list').toLowerCase();
|
|
2145
|
+
const commands = loadCustomSlashCommands(opts);
|
|
2146
|
+
if (sub === 'list') {
|
|
2147
|
+
const rows = commands.filter((row) => row.type === 'skill' || row.type === 'command');
|
|
2148
|
+
if (!rows.length) {
|
|
2149
|
+
console.log('No custom skills or commands found.');
|
|
2150
|
+
console.log(`Project path: ${path.join(getBortexProjectDir(opts), 'skills')}`);
|
|
2151
|
+
console.log(`User path : ${path.join(getBortexUserDir(), 'skills')}`);
|
|
2152
|
+
return { handled: true, data: [] };
|
|
2153
|
+
}
|
|
2154
|
+
rows.forEach((row) => console.log(`${row.command.padEnd(24)} ${row.description} [${row.scope}/${row.type}]`));
|
|
2155
|
+
return { handled: true, data: rows };
|
|
2156
|
+
}
|
|
2157
|
+
if (sub === 'show') {
|
|
2158
|
+
const name = String(rest[1] || '').replace(/^\//, '').toLowerCase();
|
|
2159
|
+
const row = commands.find((item) => item.name.toLowerCase() === name || item.command.toLowerCase() === `/${name}`);
|
|
2160
|
+
if (!row) {
|
|
2161
|
+
console.log('Skill not found.');
|
|
2162
|
+
return { handled: true };
|
|
2163
|
+
}
|
|
2164
|
+
console.log(`${row.command} (${row.scope}/${row.type})`);
|
|
2165
|
+
console.log(`Path: ${row.filePath}`);
|
|
2166
|
+
console.log('');
|
|
2167
|
+
process.stdout.write(row.body.endsWith('\n') ? row.body : `${row.body}\n`);
|
|
2168
|
+
return { handled: true, data: row };
|
|
2169
|
+
}
|
|
2170
|
+
if (sub === 'new') {
|
|
2171
|
+
const name = safeSlug(rest[1] || '');
|
|
2172
|
+
if (!name) {
|
|
2173
|
+
console.log('Usage: /skills new <name>');
|
|
2174
|
+
return { handled: true };
|
|
2175
|
+
}
|
|
2176
|
+
const dirPath = path.join(getBortexProjectDir(opts), 'skills', name);
|
|
2177
|
+
const filePath = path.join(dirPath, 'SKILL.md');
|
|
2178
|
+
if (fs.existsSync(filePath)) {
|
|
2179
|
+
console.log(`Skill already exists: ${filePath}`);
|
|
2180
|
+
return { handled: true };
|
|
2181
|
+
}
|
|
2182
|
+
ensureDirSync(dirPath);
|
|
2183
|
+
const body = [
|
|
2184
|
+
'---',
|
|
2185
|
+
`description: ${name} workflow for this repository.`,
|
|
2186
|
+
'disable-model-invocation: true',
|
|
2187
|
+
'---',
|
|
2188
|
+
'',
|
|
2189
|
+
`Run the ${name} workflow for $ARGUMENTS.`,
|
|
2190
|
+
'',
|
|
2191
|
+
'1. Inspect the relevant files.',
|
|
2192
|
+
'2. Make a concise plan.',
|
|
2193
|
+
'3. Execute only the necessary changes.',
|
|
2194
|
+
'4. Verify the result.'
|
|
2195
|
+
].join('\n');
|
|
2196
|
+
fs.writeFileSync(filePath, body, 'utf8');
|
|
2197
|
+
console.log(`Created skill: ${filePath}`);
|
|
2198
|
+
return { handled: true, data: { filePath } };
|
|
2199
|
+
}
|
|
2200
|
+
console.log('Usage: /skills list|show <name>|new <name>');
|
|
2201
|
+
return { handled: true };
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
async function handlePluginsCommand(opts, rest) {
|
|
2205
|
+
const sub = String(rest[0] || 'list').toLowerCase();
|
|
2206
|
+
if (sub === 'list') {
|
|
2207
|
+
const rows = discoverPlugins(opts);
|
|
2208
|
+
if (!rows.length) {
|
|
2209
|
+
console.log('No plugins installed.');
|
|
2210
|
+
console.log(`Project plugins: ${path.join(getBortexProjectDir(opts), 'plugins')}`);
|
|
2211
|
+
console.log(`User plugins : ${path.join(getBortexUserDir(), 'plugins')}`);
|
|
2212
|
+
return { handled: true, data: [] };
|
|
2213
|
+
}
|
|
2214
|
+
rows.forEach((plugin) => {
|
|
2215
|
+
const state = plugin.enabled ? 'enabled' : 'disabled';
|
|
2216
|
+
console.log(`${plugin.id.padEnd(22)} ${state.padEnd(9)} ${plugin.version || '-'} ${plugin.description || plugin.name} [${plugin.scope}]`);
|
|
2217
|
+
});
|
|
2218
|
+
return { handled: true, data: rows };
|
|
2219
|
+
}
|
|
2220
|
+
if (sub === 'init') {
|
|
2221
|
+
const name = safeSlug(rest[1] || '');
|
|
2222
|
+
if (!name) {
|
|
2223
|
+
console.log('Usage: /plugins init <name>');
|
|
2224
|
+
return { handled: true };
|
|
2225
|
+
}
|
|
2226
|
+
const dir = path.join(getBortexProjectDir(opts), 'plugins', name);
|
|
2227
|
+
const manifestPath = path.join(dir, '.bortex-plugin', 'plugin.json');
|
|
2228
|
+
if (fs.existsSync(manifestPath)) {
|
|
2229
|
+
console.log(`Plugin already exists: ${manifestPath}`);
|
|
2230
|
+
return { handled: true };
|
|
2231
|
+
}
|
|
2232
|
+
ensureDirSync(path.dirname(manifestPath));
|
|
2233
|
+
ensureDirSync(path.join(dir, 'skills', name));
|
|
2234
|
+
ensureDirSync(path.join(dir, 'commands'));
|
|
2235
|
+
ensureDirSync(path.join(dir, 'hooks'));
|
|
2236
|
+
safeWriteJson(manifestPath, {
|
|
2237
|
+
id: name,
|
|
2238
|
+
name,
|
|
2239
|
+
version: '0.1.0',
|
|
2240
|
+
description: `${name} plugin for Bortex Code`,
|
|
2241
|
+
skillsDir: 'skills',
|
|
2242
|
+
commandsDir: 'commands',
|
|
2243
|
+
hooksFile: 'hooks/hooks.json',
|
|
2244
|
+
mcpServers: {}
|
|
2245
|
+
});
|
|
2246
|
+
fs.writeFileSync(path.join(dir, 'skills', name, 'SKILL.md'), [
|
|
2247
|
+
'---',
|
|
2248
|
+
`description: ${name} plugin skill.`,
|
|
2249
|
+
'---',
|
|
2250
|
+
'',
|
|
2251
|
+
`Run the ${name} plugin workflow for $ARGUMENTS.`
|
|
2252
|
+
].join('\n'), 'utf8');
|
|
2253
|
+
safeWriteJson(path.join(dir, 'hooks', 'hooks.json'), { hooks: {} });
|
|
2254
|
+
console.log(`Created plugin: ${dir}`);
|
|
2255
|
+
return { handled: true, data: { dir, manifestPath } };
|
|
2256
|
+
}
|
|
2257
|
+
if (sub === 'install') {
|
|
2258
|
+
const srcRaw = String(rest[1] || '').trim();
|
|
2259
|
+
if (!srcRaw) {
|
|
2260
|
+
console.log('Usage: /plugins install <local-path> [--global]');
|
|
2261
|
+
return { handled: true };
|
|
2262
|
+
}
|
|
2263
|
+
const parsed = parseScopeFlag(rest.slice(2));
|
|
2264
|
+
const src = resolveCliPath(opts, srcRaw);
|
|
2265
|
+
if (!fs.existsSync(src) || !fs.statSync(src).isDirectory()) {
|
|
2266
|
+
console.log(`Plugin path not found: ${src}`);
|
|
2267
|
+
return { handled: true };
|
|
2268
|
+
}
|
|
2269
|
+
const loaded = readPluginManifest(src);
|
|
2270
|
+
if (!loaded) {
|
|
2271
|
+
console.log('Plugin manifest not found. Expected .bortex-plugin/plugin.json or plugin.json.');
|
|
2272
|
+
return { handled: true };
|
|
2273
|
+
}
|
|
2274
|
+
const id = safeSlug(loaded.manifest.id || loaded.manifest.name || path.basename(src));
|
|
2275
|
+
const dest = path.join(parsed.scope === 'user' ? getBortexUserDir() : getBortexProjectDir(opts), 'plugins', id);
|
|
2276
|
+
if (fs.existsSync(dest)) {
|
|
2277
|
+
console.log(`Plugin already installed: ${dest}`);
|
|
2278
|
+
return { handled: true };
|
|
2279
|
+
}
|
|
2280
|
+
ensureDirSync(path.dirname(dest));
|
|
2281
|
+
fs.cpSync(src, dest, { recursive: true });
|
|
2282
|
+
console.log(`Installed plugin "${id}" to ${parsed.scope}: ${dest}`);
|
|
2283
|
+
return { handled: true, data: { id, dest } };
|
|
2284
|
+
}
|
|
2285
|
+
if (sub === 'enable' || sub === 'disable') {
|
|
2286
|
+
const id = safeSlug(rest[1] || '');
|
|
2287
|
+
if (!id) {
|
|
2288
|
+
console.log(`Usage: /plugins ${sub} <id>`);
|
|
2289
|
+
return { handled: true };
|
|
2290
|
+
}
|
|
2291
|
+
const state = loadPluginState(opts);
|
|
2292
|
+
if (sub === 'disable') state.disabled[id] = { ts: Date.now() };
|
|
2293
|
+
else delete state.disabled[id];
|
|
2294
|
+
savePluginState(opts, state);
|
|
2295
|
+
console.log(`${sub === 'disable' ? 'Disabled' : 'Enabled'} plugin: ${id}`);
|
|
2296
|
+
return { handled: true, data: state };
|
|
2297
|
+
}
|
|
2298
|
+
if (sub === 'show') {
|
|
2299
|
+
const id = safeSlug(rest[1] || '');
|
|
2300
|
+
const row = discoverPlugins(opts).find((plugin) => plugin.id === id);
|
|
2301
|
+
if (!row) {
|
|
2302
|
+
console.log('Plugin not found.');
|
|
2303
|
+
return { handled: true };
|
|
2304
|
+
}
|
|
2305
|
+
console.log(JSON.stringify(row, null, 2));
|
|
2306
|
+
return { handled: true, data: row };
|
|
2307
|
+
}
|
|
2308
|
+
console.log('Usage: /plugins list|show <id>|init <name>|install <path> [--global]|enable <id>|disable <id>');
|
|
2309
|
+
return { handled: true };
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
async function handleBrowserCommand(opts, rest) {
|
|
2313
|
+
const sub = String(rest[0] || 'status').toLowerCase();
|
|
2314
|
+
if (sub === 'setup' || sub === 'install') {
|
|
2315
|
+
const parsed = parseScopeFlag(rest.slice(1));
|
|
2316
|
+
const cfg = loadMcpConfig(opts, parsed.scope);
|
|
2317
|
+
cfg.mcpServers.playwright = {
|
|
2318
|
+
type: 'stdio',
|
|
2319
|
+
command: 'npx',
|
|
2320
|
+
args: ['-y', '@playwright/mcp'],
|
|
2321
|
+
env: {}
|
|
2322
|
+
};
|
|
2323
|
+
saveMcpConfig(opts, parsed.scope, cfg);
|
|
2324
|
+
console.log(`Configured Playwright MCP browser server in ${parsed.scope} config.`);
|
|
2325
|
+
console.log('Use: /mcp tools playwright');
|
|
2326
|
+
return { handled: true, data: cfg.mcpServers.playwright };
|
|
2327
|
+
}
|
|
2328
|
+
if (sub === 'status') {
|
|
2329
|
+
const row = resolveMcpServer(opts, 'playwright');
|
|
2330
|
+
if (!row) {
|
|
2331
|
+
console.log('Playwright MCP is not configured. Run: /browser setup');
|
|
2332
|
+
return { handled: true, data: null };
|
|
2333
|
+
}
|
|
2334
|
+
console.log(JSON.stringify(row, null, 2));
|
|
2335
|
+
return { handled: true, data: row };
|
|
2336
|
+
}
|
|
2337
|
+
console.log('Usage: /browser status|setup [--global]');
|
|
2338
|
+
return { handled: true };
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
async function handleMcpCommand(opts, rest, line) {
|
|
2342
|
+
const sub = String(rest[0] || 'list').toLowerCase();
|
|
2343
|
+
if (sub === 'list' || sub === 'status') {
|
|
2344
|
+
const rows = getMergedMcpServers(opts);
|
|
2345
|
+
if (!rows.length) {
|
|
2346
|
+
console.log('No MCP servers configured.');
|
|
2347
|
+
console.log(`Project config: ${getMcpConfigPath(opts, 'project')}`);
|
|
2348
|
+
console.log(`User config : ${getMcpConfigPath(opts, 'user')}`);
|
|
2349
|
+
return { handled: true, data: [] };
|
|
2350
|
+
}
|
|
2351
|
+
rows.forEach((row) => {
|
|
2352
|
+
const type = row.server.type || (row.server.command ? 'stdio' : 'http');
|
|
2353
|
+
const target = row.server.command || row.server.url || '(missing target)';
|
|
2354
|
+
console.log(`${row.name.padEnd(22)} ${type.padEnd(8)} ${target} [${row.scope}]`);
|
|
2355
|
+
});
|
|
2356
|
+
return { handled: true, data: rows };
|
|
2357
|
+
}
|
|
2358
|
+
if (sub === 'get') {
|
|
2359
|
+
const name = String(rest[1] || '');
|
|
2360
|
+
const row = getMergedMcpServers(opts).find((item) => item.name === name);
|
|
2361
|
+
if (!row) console.log('MCP server not found.');
|
|
2362
|
+
else console.log(JSON.stringify(row, null, 2));
|
|
2363
|
+
return { handled: true, data: row || null };
|
|
2364
|
+
}
|
|
2365
|
+
if (sub === 'remove' || sub === 'rm') {
|
|
2366
|
+
const name = String(rest[1] || '');
|
|
2367
|
+
if (!name) {
|
|
2368
|
+
console.log('Usage: /mcp remove <name> [--global]');
|
|
2369
|
+
return { handled: true };
|
|
2370
|
+
}
|
|
2371
|
+
const parsed = parseScopeFlag(rest.slice(2));
|
|
2372
|
+
const cfg = loadMcpConfig(opts, parsed.scope);
|
|
2373
|
+
delete cfg.mcpServers[name];
|
|
2374
|
+
saveMcpConfig(opts, parsed.scope, cfg);
|
|
2375
|
+
console.log(`Removed MCP server "${name}" from ${parsed.scope} config.`);
|
|
2376
|
+
return { handled: true };
|
|
2377
|
+
}
|
|
2378
|
+
if (sub === 'add') {
|
|
2379
|
+
const name = safeSlug(rest[1] || '');
|
|
2380
|
+
const sep = rest.indexOf('--');
|
|
2381
|
+
if (!name || sep < 0 || sep >= rest.length - 1) {
|
|
2382
|
+
console.log('Usage: /mcp add <name> [--global] -- <command> [args...]');
|
|
2383
|
+
return { handled: true };
|
|
2384
|
+
}
|
|
2385
|
+
const parsed = parseScopeFlag(rest.slice(2, sep));
|
|
2386
|
+
const commandParts = rest.slice(sep + 1);
|
|
2387
|
+
const cfg = loadMcpConfig(opts, parsed.scope);
|
|
2388
|
+
cfg.mcpServers[name] = { type: 'stdio', command: commandParts[0], args: commandParts.slice(1), env: {} };
|
|
2389
|
+
saveMcpConfig(opts, parsed.scope, cfg);
|
|
2390
|
+
console.log(`Added stdio MCP server "${name}" to ${parsed.scope} config.`);
|
|
2391
|
+
return { handled: true, data: cfg.mcpServers[name] };
|
|
2392
|
+
}
|
|
2393
|
+
if (sub === 'add-http') {
|
|
2394
|
+
const name = safeSlug(rest[1] || '');
|
|
2395
|
+
const url = String(rest[2] || '').trim();
|
|
2396
|
+
if (!name || !/^https?:\/\//i.test(url)) {
|
|
2397
|
+
console.log('Usage: /mcp add-http <name> <url> [--global] [--header Key=Value]');
|
|
2398
|
+
return { handled: true };
|
|
2399
|
+
}
|
|
2400
|
+
const parsed = parseScopeFlag(rest.slice(3));
|
|
2401
|
+
const headers = {};
|
|
2402
|
+
for (let i = 0; i < parsed.rest.length; i += 1) {
|
|
2403
|
+
if (parsed.rest[i] === '--header' && parsed.rest[i + 1]) {
|
|
2404
|
+
const raw = parsed.rest[i + 1];
|
|
2405
|
+
const idx = raw.includes('=') ? raw.indexOf('=') : raw.indexOf(':');
|
|
2406
|
+
if (idx > 0) headers[raw.slice(0, idx).trim()] = raw.slice(idx + 1).trim();
|
|
2407
|
+
i += 1;
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
const cfg = loadMcpConfig(opts, parsed.scope);
|
|
2411
|
+
cfg.mcpServers[name] = { type: 'http', url, headers };
|
|
2412
|
+
saveMcpConfig(opts, parsed.scope, cfg);
|
|
2413
|
+
console.log(`Added HTTP MCP server "${name}" to ${parsed.scope} config.`);
|
|
2414
|
+
return { handled: true, data: cfg.mcpServers[name] };
|
|
2415
|
+
}
|
|
2416
|
+
if (sub === 'add-json') {
|
|
2417
|
+
const name = safeSlug(rest[1] || '');
|
|
2418
|
+
const jsonText = rest.slice(2).join(' ').trim();
|
|
2419
|
+
if (!name || !jsonText) {
|
|
2420
|
+
console.log('Usage: /mcp add-json <name> <json>');
|
|
2421
|
+
return { handled: true };
|
|
2422
|
+
}
|
|
2423
|
+
let server;
|
|
2424
|
+
try { server = JSON.parse(jsonText); } catch (err) {
|
|
2425
|
+
console.log(`Invalid JSON: ${err.message}`);
|
|
2426
|
+
return { handled: true };
|
|
2427
|
+
}
|
|
2428
|
+
const cfg = loadMcpConfig(opts, 'project');
|
|
2429
|
+
cfg.mcpServers[name] = server;
|
|
2430
|
+
saveMcpConfig(opts, 'project', cfg);
|
|
2431
|
+
console.log(`Added JSON MCP server "${name}" to project config.`);
|
|
2432
|
+
return { handled: true, data: server };
|
|
2433
|
+
}
|
|
2434
|
+
if (sub === 'tools' || sub === 'tool-list') {
|
|
2435
|
+
const name = String(rest[1] || '').trim();
|
|
2436
|
+
if (!name) {
|
|
2437
|
+
console.log('Usage: /mcp tools <server>');
|
|
2438
|
+
return { handled: true };
|
|
2439
|
+
}
|
|
2440
|
+
try {
|
|
2441
|
+
const result = await runMcpRequest(opts, name, 'tools/list', {});
|
|
2442
|
+
const tools = Array.isArray(result?.tools) ? result.tools : [];
|
|
2443
|
+
if (!tools.length) console.log('No tools returned.');
|
|
2444
|
+
tools.forEach((tool) => console.log(`${String(tool.name || '').padEnd(28)} ${tool.description || ''}`));
|
|
2445
|
+
return { handled: true, data: result };
|
|
2446
|
+
} catch (err) {
|
|
2447
|
+
console.log(`MCP tools failed: ${err.message}`);
|
|
2448
|
+
return { handled: true, data: { error: err.message } };
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
if (sub === 'call') {
|
|
2452
|
+
const name = String(rest[1] || '').trim();
|
|
2453
|
+
const toolName = String(rest[2] || '').trim();
|
|
2454
|
+
const jsonText = rest.slice(3).join(' ').trim() || '{}';
|
|
2455
|
+
if (!name || !toolName) {
|
|
2456
|
+
console.log('Usage: /mcp call <server> <tool> [json-arguments]');
|
|
2457
|
+
return { handled: true };
|
|
2458
|
+
}
|
|
2459
|
+
let argsPayload;
|
|
2460
|
+
try { argsPayload = JSON.parse(jsonText); } catch (err) {
|
|
2461
|
+
console.log(`Invalid tool arguments JSON: ${err.message}`);
|
|
2462
|
+
return { handled: true };
|
|
2463
|
+
}
|
|
2464
|
+
const hookDecision = await runBortexHooks(opts, 'PreToolUse', {
|
|
2465
|
+
tool_name: `mcp__${name}__${toolName}`,
|
|
2466
|
+
tool_input: argsPayload,
|
|
2467
|
+
command: `/mcp call ${name} ${toolName}`
|
|
2468
|
+
}, { decision: true });
|
|
2469
|
+
if (hookDecision.denied) {
|
|
2470
|
+
console.log(`MCP tool denied by hook: ${hookDecision.reason}`);
|
|
2471
|
+
return { handled: true, data: hookDecision };
|
|
2472
|
+
}
|
|
2473
|
+
try {
|
|
2474
|
+
const result = await runMcpRequest(opts, name, 'tools/call', { name: toolName, arguments: argsPayload });
|
|
2475
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2476
|
+
await runBortexHooks(opts, 'PostToolUse', {
|
|
2477
|
+
tool_name: `mcp__${name}__${toolName}`,
|
|
2478
|
+
tool_input: argsPayload,
|
|
2479
|
+
result
|
|
2480
|
+
});
|
|
2481
|
+
return { handled: true, data: result };
|
|
2482
|
+
} catch (err) {
|
|
2483
|
+
console.log(`MCP call failed: ${err.message}`);
|
|
2484
|
+
await runBortexHooks(opts, 'PostToolUse', {
|
|
2485
|
+
tool_name: `mcp__${name}__${toolName}`,
|
|
2486
|
+
tool_input: argsPayload,
|
|
2487
|
+
error: err.message
|
|
2488
|
+
});
|
|
2489
|
+
return { handled: true, data: { error: err.message } };
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
if (sub === 'ping') {
|
|
2493
|
+
const name = String(rest[1] || '').trim();
|
|
2494
|
+
if (!name) {
|
|
2495
|
+
console.log('Usage: /mcp ping <server>');
|
|
2496
|
+
return { handled: true };
|
|
2497
|
+
}
|
|
2498
|
+
try {
|
|
2499
|
+
const result = await runMcpRequest(opts, name, 'tools/list', {}, { timeoutMs: 15000 });
|
|
2500
|
+
console.log(`MCP server "${name}" responded. tools=${Array.isArray(result?.tools) ? result.tools.length : 0}`);
|
|
2501
|
+
return { handled: true, data: result };
|
|
2502
|
+
} catch (err) {
|
|
2503
|
+
console.log(`MCP ping failed: ${err.message}`);
|
|
2504
|
+
return { handled: true, data: { error: err.message } };
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
if (sub === 'login') {
|
|
2508
|
+
const name = String(rest[1] || '');
|
|
2509
|
+
const row = getMergedMcpServers(opts).find((item) => item.name === name);
|
|
2510
|
+
if (!row) {
|
|
2511
|
+
console.log('MCP server not found.');
|
|
2512
|
+
return { handled: true };
|
|
2513
|
+
}
|
|
2514
|
+
console.log('OAuth login handoff is not interactive yet. Store headers or OAuth config in the MCP entry, then use /mcp get to verify it.');
|
|
2515
|
+
return { handled: true, data: row };
|
|
2516
|
+
}
|
|
2517
|
+
console.log('Usage: /mcp list|get|add|add-http|add-json|remove|tools|call|ping|login');
|
|
2518
|
+
return { handled: true };
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
async function handleTranscriptCommand(opts, rest) {
|
|
2522
|
+
const sub = String(rest[0] || 'path').toLowerCase();
|
|
2523
|
+
if (sub === 'path') {
|
|
2524
|
+
console.log(getCurrentTranscriptPath(opts));
|
|
2525
|
+
return { handled: true, data: { path: getCurrentTranscriptPath(opts) } };
|
|
2526
|
+
}
|
|
2527
|
+
if (sub === 'list') {
|
|
2528
|
+
const rows = listTranscriptFiles(opts, rest.includes('--all')).slice(0, Number(rest[1]) || 20);
|
|
2529
|
+
if (!rows.length) console.log('No transcripts found.');
|
|
2530
|
+
rows.forEach((row, i) => console.log(`${i + 1}. ${row.id} ${Math.round(row.size / 1024)}KB ${row.filePath}`));
|
|
2531
|
+
return { handled: true, data: rows };
|
|
2532
|
+
}
|
|
2533
|
+
if (sub === 'export') {
|
|
2534
|
+
const current = getCurrentTranscriptPath(opts);
|
|
2535
|
+
if (!fs.existsSync(current)) {
|
|
2536
|
+
console.log('No current transcript exists yet.');
|
|
2537
|
+
return { handled: true };
|
|
2538
|
+
}
|
|
2539
|
+
const outPath = rest[1]
|
|
2540
|
+
? resolveCliPath(opts, rest.slice(1).join(' '))
|
|
2541
|
+
: path.join(getTranscriptDir(opts), `${opts.transcriptId}.json`);
|
|
2542
|
+
const events = readTranscriptEvents(current, 100000);
|
|
2543
|
+
safeWriteJson(outPath, { version: 1, id: opts.transcriptId, cwd: opts.cwd, events });
|
|
2544
|
+
console.log(`Transcript exported: ${outPath}`);
|
|
2545
|
+
return { handled: true, data: { outPath, events: events.length } };
|
|
2546
|
+
}
|
|
2547
|
+
console.log('Usage: /transcript path|list [--all]|export [file]');
|
|
2548
|
+
return { handled: true };
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
async function handleResumeCommand(opts, rest) {
|
|
2552
|
+
const sub = String(rest[0] || 'last').toLowerCase();
|
|
2553
|
+
if (sub === 'list') {
|
|
2554
|
+
const rows = listTranscriptFiles(opts, rest.includes('--all')).slice(0, 30);
|
|
2555
|
+
if (!rows.length) console.log('No transcripts found.');
|
|
2556
|
+
rows.forEach((row, i) => console.log(`${i + 1}. ${row.id} ${row.filePath}`));
|
|
2557
|
+
return { handled: true, data: rows };
|
|
2558
|
+
}
|
|
2559
|
+
const target = sub === 'last' ? 'last' : String(rest[0] || 'last');
|
|
2560
|
+
const found = findTranscriptByRef(opts, target, rest.includes('--all'));
|
|
2561
|
+
if (!found) {
|
|
2562
|
+
console.log('Transcript not found.');
|
|
2563
|
+
return { handled: true };
|
|
2564
|
+
}
|
|
2565
|
+
opts.resumeTranscript = found;
|
|
2566
|
+
opts.resumeContext = summarizeTranscriptForResume(found.filePath);
|
|
2567
|
+
opts._resumeContextUsed = false;
|
|
2568
|
+
console.log(`Resume context loaded: ${found.id}`);
|
|
2569
|
+
return { handled: true, data: found };
|
|
1090
2570
|
}
|
|
1091
2571
|
|
|
1092
|
-
function
|
|
1093
|
-
const
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
2572
|
+
async function handleHooksCommand(opts, rest) {
|
|
2573
|
+
const sub = String(rest[0] || 'list').toLowerCase();
|
|
2574
|
+
if (sub === 'list') {
|
|
2575
|
+
const rows = annotateHookTrust(loadHookEntries(opts));
|
|
2576
|
+
if (!rows.length) {
|
|
2577
|
+
console.log('No hooks configured.');
|
|
2578
|
+
console.log(`Project hooks: ${path.join(getBortexProjectDir(opts), 'hooks.json')}`);
|
|
2579
|
+
console.log(`User hooks : ${path.join(getBortexUserDir(), 'hooks.json')}`);
|
|
2580
|
+
return { handled: true, data: [] };
|
|
2581
|
+
}
|
|
2582
|
+
rows.forEach((row) => {
|
|
2583
|
+
const state = row.disabled ? 'disabled' : (row.trusted ? 'trusted' : 'untrusted');
|
|
2584
|
+
console.log(`${row.hash} ${row.event.padEnd(18)} ${(row.matcher || '*').padEnd(16)} ${state.padEnd(10)} ${row.command || row.type || 'hook'} [${row.source}]`);
|
|
2585
|
+
});
|
|
2586
|
+
return { handled: true, data: rows };
|
|
2587
|
+
}
|
|
2588
|
+
if (sub === 'init') {
|
|
2589
|
+
const filePath = path.join(getBortexProjectDir(opts), 'hooks.json');
|
|
2590
|
+
if (fs.existsSync(filePath)) {
|
|
2591
|
+
console.log(`Hooks file already exists: ${filePath}`);
|
|
2592
|
+
return { handled: true };
|
|
2593
|
+
}
|
|
2594
|
+
safeWriteJson(filePath, {
|
|
2595
|
+
hooks: {
|
|
2596
|
+
UserPromptSubmit: [],
|
|
2597
|
+
PreToolUse: [
|
|
2598
|
+
{
|
|
2599
|
+
type: 'command',
|
|
2600
|
+
matcher: 'rm -rf',
|
|
2601
|
+
command: 'node -e "console.log(JSON.stringify({permissionDecision:\\"deny\\",permissionDecisionReason:\\"Blocked destructive command\\"}))"'
|
|
2602
|
+
}
|
|
2603
|
+
],
|
|
2604
|
+
PostToolUse: []
|
|
2605
|
+
}
|
|
2606
|
+
});
|
|
2607
|
+
console.log(`Created hooks file: ${filePath}`);
|
|
2608
|
+
return { handled: true, data: { filePath } };
|
|
2609
|
+
}
|
|
2610
|
+
if (sub === 'review') {
|
|
2611
|
+
const rows = annotateHookTrust(loadHookEntries(opts));
|
|
2612
|
+
if (!rows.length) {
|
|
2613
|
+
console.log('No hooks configured.');
|
|
2614
|
+
return { handled: true, data: [] };
|
|
2615
|
+
}
|
|
2616
|
+
rows.forEach((row) => {
|
|
2617
|
+
console.log('');
|
|
2618
|
+
console.log(`${row.hash} ${row.event} [${row.source}] ${row.trusted ? 'trusted' : 'untrusted'}${row.disabled ? ' disabled' : ''}`);
|
|
2619
|
+
console.log(`Matcher: ${row.matcher || '*'}`);
|
|
2620
|
+
console.log(`Command: ${row.command || '(none)'}`);
|
|
2621
|
+
console.log(`Path : ${row.filePath}`);
|
|
2622
|
+
});
|
|
2623
|
+
return { handled: true, data: rows };
|
|
2624
|
+
}
|
|
2625
|
+
if (sub === 'trust') {
|
|
2626
|
+
const target = String(rest[1] || '').trim();
|
|
2627
|
+
if (!target) {
|
|
2628
|
+
console.log('Usage: /hooks trust <hash|all>');
|
|
2629
|
+
return { handled: true };
|
|
2630
|
+
}
|
|
2631
|
+
const rows = loadHookEntries(opts);
|
|
2632
|
+
const trust = loadHookTrust();
|
|
2633
|
+
rows.filter((row) => target === 'all' || row.hash === target).forEach((row) => {
|
|
2634
|
+
trust.trusted[row.hash] = { ts: Date.now(), event: row.event, command: row.command || '' };
|
|
2635
|
+
delete trust.disabled[row.hash];
|
|
2636
|
+
});
|
|
2637
|
+
saveHookTrust(trust);
|
|
2638
|
+
console.log(`Trusted hooks: ${target}`);
|
|
2639
|
+
return { handled: true, data: trust };
|
|
2640
|
+
}
|
|
2641
|
+
if (sub === 'disable' || sub === 'enable') {
|
|
2642
|
+
const target = String(rest[1] || '').trim();
|
|
2643
|
+
if (!target) {
|
|
2644
|
+
console.log(`Usage: /hooks ${sub} <hash>`);
|
|
2645
|
+
return { handled: true };
|
|
2646
|
+
}
|
|
2647
|
+
const trust = loadHookTrust();
|
|
2648
|
+
if (sub === 'disable') trust.disabled[target] = { ts: Date.now() };
|
|
2649
|
+
else delete trust.disabled[target];
|
|
2650
|
+
saveHookTrust(trust);
|
|
2651
|
+
console.log(`${sub === 'disable' ? 'Disabled' : 'Enabled'} hook: ${target}`);
|
|
2652
|
+
return { handled: true, data: trust };
|
|
2653
|
+
}
|
|
2654
|
+
if (sub === 'test') {
|
|
2655
|
+
const event = String(rest[1] || 'UserPromptSubmit');
|
|
2656
|
+
const result = await runBortexHooks(opts, event, { prompt: rest.slice(2).join(' ') || 'test', command: rest.slice(2).join(' ') || 'test' }, { decision: true });
|
|
2657
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2658
|
+
return { handled: true, data: result };
|
|
2659
|
+
}
|
|
2660
|
+
console.log('Usage: /hooks list|review|trust <hash|all>|disable <hash>|enable <hash>|init|test <event> [payload]');
|
|
2661
|
+
return { handled: true };
|
|
1106
2662
|
}
|
|
1107
2663
|
|
|
1108
|
-
function
|
|
1109
|
-
const
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
const
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
2664
|
+
async function handleSubagentsCommand(opts, rest, line) {
|
|
2665
|
+
const sub = String(rest[0] || 'list').toLowerCase();
|
|
2666
|
+
const agents = getAllSubagents(opts);
|
|
2667
|
+
if (sub === 'list') {
|
|
2668
|
+
agents.forEach((agent) => console.log(`${agent.name.padEnd(16)} ${agent.description} [${agent.scope}]`));
|
|
2669
|
+
return { handled: true, data: agents };
|
|
2670
|
+
}
|
|
2671
|
+
if (sub === 'run') {
|
|
2672
|
+
const raw = line.slice(line.toLowerCase().indexOf('run') + 3).trim();
|
|
2673
|
+
const agentMatch = raw.match(/(?:^|\s)--agents\s+([a-z0-9_,.-]+)(?=\s|$)/i);
|
|
2674
|
+
const useWorktree = /(?:^|\s)--worktree(?=\s|$)/i.test(raw);
|
|
2675
|
+
const selectedNames = agentMatch
|
|
2676
|
+
? agentMatch[1].split(',').map((x) => safeSlug(x)).filter(Boolean)
|
|
2677
|
+
: ['reviewer', 'tester', 'security'];
|
|
2678
|
+
const goal = raw
|
|
2679
|
+
.replace(/(?:^|\s)--agents\s+[a-z0-9_,.-]+(?=\s|$)/ig, ' ')
|
|
2680
|
+
.replace(/(?:^|\s)--worktree(?=\s|$)/ig, ' ')
|
|
2681
|
+
.trim();
|
|
2682
|
+
if (!goal) {
|
|
2683
|
+
console.log('Usage: /subagents run [--agents reviewer,tester] <goal>');
|
|
2684
|
+
return { handled: true };
|
|
2685
|
+
}
|
|
2686
|
+
const selected = selectedNames.map((name) => agents.find((agent) => agent.name === name)).filter(Boolean);
|
|
2687
|
+
if (!selected.length) {
|
|
2688
|
+
console.log('No matching subagents.');
|
|
2689
|
+
return { handled: true };
|
|
2690
|
+
}
|
|
2691
|
+
if (opts.offline) {
|
|
2692
|
+
selected.forEach((agent) => console.log(`[${agent.name}] ${agent.instructions || agent.description}\nTask: ${goal}\n`));
|
|
2693
|
+
return { handled: true };
|
|
2694
|
+
}
|
|
2695
|
+
console.log(`Running ${selected.length} subagent(s): ${selected.map((a) => a.name).join(', ')}${useWorktree ? ' with isolated worktrees' : ''}`);
|
|
2696
|
+
const results = await Promise.all(selected.map(async (agent) => {
|
|
2697
|
+
let agentCwd = opts.cwd;
|
|
2698
|
+
let worktree = null;
|
|
2699
|
+
if (useWorktree) {
|
|
2700
|
+
const wtName = safeSlug(`${agent.name}-${Date.now().toString(36)}-${crypto.randomBytes(2).toString('hex')}`);
|
|
2701
|
+
const wtDir = path.join(getBortexProjectDir(opts), 'worktrees', wtName);
|
|
2702
|
+
const branch = `bortex/${wtName}`;
|
|
2703
|
+
const wtRes = await runChild('git', ['worktree', 'add', '-b', branch, wtDir, 'HEAD'], { cwd: opts.cwd, shell: false });
|
|
2704
|
+
if (wtRes.ok) {
|
|
2705
|
+
agentCwd = wtDir;
|
|
2706
|
+
worktree = { path: wtDir, branch };
|
|
2707
|
+
} else {
|
|
2708
|
+
return { agent: agent.name, ok: false, text: `Worktree creation failed: ${wtRes.stderr || wtRes.stdout}`, worktree: null };
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
const prompt = [
|
|
2712
|
+
`You are Bortex subagent "${agent.name}".`,
|
|
2713
|
+
`Role: ${agent.description}`,
|
|
2714
|
+
worktree ? `Isolated worktree: ${worktree.path}` : '',
|
|
2715
|
+
agent.instructions ? `Instructions:\n${agent.instructions}` : '',
|
|
2716
|
+
'',
|
|
2717
|
+
`Task: ${goal}`,
|
|
2718
|
+
'',
|
|
2719
|
+
'Return concise findings, risks, and next actions. Do not claim file edits unless you performed them.'
|
|
2720
|
+
].filter(Boolean).join('\n');
|
|
2721
|
+
try {
|
|
2722
|
+
const data = await askServer({ ...opts, cwd: agentCwd, agent: false }, prompt);
|
|
2723
|
+
return { agent: agent.name, ok: true, text: data?.message || JSON.stringify(data || {}), worktree };
|
|
2724
|
+
} catch (err) {
|
|
2725
|
+
return { agent: agent.name, ok: false, text: err.message, worktree };
|
|
2726
|
+
}
|
|
2727
|
+
}));
|
|
2728
|
+
results.forEach((res) => {
|
|
2729
|
+
console.log('');
|
|
2730
|
+
console.log(`== ${res.agent} ${res.ok ? 'ok' : 'error'} ==`);
|
|
2731
|
+
console.log(res.text);
|
|
2732
|
+
});
|
|
2733
|
+
appendTranscriptEvent(opts, 'subagents', { goal, results });
|
|
2734
|
+
return { handled: true, data: results };
|
|
1129
2735
|
}
|
|
2736
|
+
console.log('Usage: /subagents list|run [--agents a,b] <goal>');
|
|
2737
|
+
return { handled: true };
|
|
1130
2738
|
}
|
|
1131
2739
|
|
|
1132
|
-
function
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
last.ts = Date.now();
|
|
1139
|
-
return;
|
|
2740
|
+
async function handleWorktreeCommand(opts, rest, line) {
|
|
2741
|
+
const sub = String(rest[0] || 'list').toLowerCase();
|
|
2742
|
+
if (sub === 'list') {
|
|
2743
|
+
const res = await runChild('git', ['worktree', 'list'], { cwd: opts.cwd, shell: false });
|
|
2744
|
+
printExecResult(res, '');
|
|
2745
|
+
return { handled: true, data: res };
|
|
1140
2746
|
}
|
|
1141
|
-
|
|
1142
|
-
if (
|
|
1143
|
-
|
|
2747
|
+
const name = safeSlug(sub === 'create' || sub === 'add' ? rest[1] : rest[0]);
|
|
2748
|
+
if (!name) {
|
|
2749
|
+
console.log('Usage: /fork <name> [base] or /worktrees list');
|
|
2750
|
+
return { handled: true };
|
|
1144
2751
|
}
|
|
2752
|
+
const base = String(sub === 'create' || sub === 'add' ? (rest[2] || 'HEAD') : (rest[1] || 'HEAD'));
|
|
2753
|
+
const targetDir = path.join(getBortexProjectDir(opts), 'worktrees', name);
|
|
2754
|
+
ensureDirSync(path.dirname(targetDir));
|
|
2755
|
+
const branch = `bortex/${name}`;
|
|
2756
|
+
const res = await runChild('git', ['worktree', 'add', '-b', branch, targetDir, base], { cwd: opts.cwd, shell: false });
|
|
2757
|
+
printExecResult(res, `Worktree created: ${targetDir}`);
|
|
2758
|
+
return { handled: true, data: { targetDir, branch, ok: res.ok } };
|
|
1145
2759
|
}
|
|
1146
2760
|
|
|
1147
|
-
function
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
text: String(text || '')
|
|
1153
|
-
});
|
|
1154
|
-
if (opts.artifacts.length > 300) {
|
|
1155
|
-
opts.artifacts = opts.artifacts.slice(-300);
|
|
2761
|
+
async function handleGithubActionCommand(opts, rest) {
|
|
2762
|
+
const sub = String(rest[0] || 'init').toLowerCase();
|
|
2763
|
+
if (sub !== 'init') {
|
|
2764
|
+
console.log('Usage: /github-action init');
|
|
2765
|
+
return { handled: true };
|
|
1156
2766
|
}
|
|
2767
|
+
const workflowPath = path.join(opts.cwd, '.github', 'workflows', 'bortex-code.yml');
|
|
2768
|
+
if (fs.existsSync(workflowPath)) {
|
|
2769
|
+
console.log(`Workflow already exists: ${workflowPath}`);
|
|
2770
|
+
return { handled: true };
|
|
2771
|
+
}
|
|
2772
|
+
ensureDirSync(path.dirname(workflowPath));
|
|
2773
|
+
const body = [
|
|
2774
|
+
'name: Bortex Code',
|
|
2775
|
+
'',
|
|
2776
|
+
'on:',
|
|
2777
|
+
' issue_comment:',
|
|
2778
|
+
' types: [created]',
|
|
2779
|
+
' pull_request_review_comment:',
|
|
2780
|
+
' types: [created]',
|
|
2781
|
+
'',
|
|
2782
|
+
'jobs:',
|
|
2783
|
+
' bortex:',
|
|
2784
|
+
' if: contains(github.event.comment.body, \'@bortex\')',
|
|
2785
|
+
' runs-on: ubuntu-latest',
|
|
2786
|
+
' permissions:',
|
|
2787
|
+
' contents: write',
|
|
2788
|
+
' pull-requests: write',
|
|
2789
|
+
' issues: write',
|
|
2790
|
+
' steps:',
|
|
2791
|
+
' - uses: actions/checkout@v4',
|
|
2792
|
+
' - uses: actions/setup-node@v4',
|
|
2793
|
+
' with:',
|
|
2794
|
+
' node-version: "20"',
|
|
2795
|
+
' - run: npm install -g bortexcode',
|
|
2796
|
+
' - name: Run Bortex Code',
|
|
2797
|
+
' env:',
|
|
2798
|
+
' BORTEX_API_KEY: ${{ secrets.BORTEX_API_KEY }}',
|
|
2799
|
+
' GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}',
|
|
2800
|
+
' run: bortexcode --agent "${{ github.event.comment.body }}"'
|
|
2801
|
+
].join('\n');
|
|
2802
|
+
fs.writeFileSync(workflowPath, body, 'utf8');
|
|
2803
|
+
console.log(`Created workflow: ${workflowPath}`);
|
|
2804
|
+
return { handled: true, data: { workflowPath } };
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
async function runReviewPreset(opts, rest) {
|
|
2808
|
+
const baseIndex = rest.findIndex((x) => x === '--base' || x === 'base');
|
|
2809
|
+
const commitIndex = rest.findIndex((x) => x === '--commit' || x === 'commit');
|
|
2810
|
+
const customIndex = rest.findIndex((x) => x === '--custom' || x === 'custom');
|
|
2811
|
+
const ai = rest.includes('--ai');
|
|
2812
|
+
if (baseIndex >= 0) {
|
|
2813
|
+
const base = String(rest[baseIndex + 1] || 'main');
|
|
2814
|
+
const merge = await runChild('git', ['merge-base', base, 'HEAD'], { cwd: opts.cwd, shell: false });
|
|
2815
|
+
const baseRef = String(merge.stdout || '').trim() || base;
|
|
2816
|
+
const stat = await runChild('git', ['diff', '--stat', '--no-color', `${baseRef}..HEAD`], { cwd: opts.cwd, shell: false });
|
|
2817
|
+
const patch = await runChild('git', ['diff', '--no-color', `${baseRef}..HEAD`], { cwd: opts.cwd, shell: false });
|
|
2818
|
+
console.log(`Review preset: base branch ${base}`);
|
|
2819
|
+
printExecResult(stat, '(no diff)');
|
|
2820
|
+
if (ai && !opts.offline) {
|
|
2821
|
+
const data = await askServer(opts, `Review this diff against ${base}. Prioritize bugs, regressions, security, and missing tests.\n\n${String(patch.stdout || '').slice(0, 50000)}`);
|
|
2822
|
+
printResponse(opts, data);
|
|
2823
|
+
return { preset: 'base', base, ai: true, data };
|
|
2824
|
+
}
|
|
2825
|
+
return { preset: 'base', base, stat: stat.stdout };
|
|
2826
|
+
}
|
|
2827
|
+
if (commitIndex >= 0) {
|
|
2828
|
+
const sha = String(rest[commitIndex + 1] || 'HEAD');
|
|
2829
|
+
const show = await runChild('git', ['show', '--stat', '--patch', '--no-color', sha], { cwd: opts.cwd, shell: false });
|
|
2830
|
+
console.log(`Review preset: commit ${sha}`);
|
|
2831
|
+
process.stdout.write(String(show.stdout || show.stderr || '').slice(0, 50000));
|
|
2832
|
+
if (ai && !opts.offline) {
|
|
2833
|
+
const data = await askServer(opts, `Review this commit. Prioritize actionable findings with file/line references when possible.\n\n${String(show.stdout || '').slice(0, 50000)}`);
|
|
2834
|
+
printResponse(opts, data);
|
|
2835
|
+
return { preset: 'commit', sha, ai: true, data };
|
|
2836
|
+
}
|
|
2837
|
+
return { preset: 'commit', sha };
|
|
2838
|
+
}
|
|
2839
|
+
if (customIndex >= 0) {
|
|
2840
|
+
const instructions = rest.slice(customIndex + 1).filter((x) => x !== '--ai').join(' ').trim() || 'Focus on correctness and missing tests.';
|
|
2841
|
+
const diff = await runChild('git', ['diff', '--no-color'], { cwd: opts.cwd, shell: false });
|
|
2842
|
+
if (opts.offline) {
|
|
2843
|
+
console.log(`Custom review instructions: ${instructions}`);
|
|
2844
|
+
return { preset: 'custom', instructions };
|
|
2845
|
+
}
|
|
2846
|
+
const data = await askServer(opts, `Review the current uncommitted diff with these instructions: ${instructions}\n\n${String(diff.stdout || '').slice(0, 50000)}`);
|
|
2847
|
+
printResponse(opts, data);
|
|
2848
|
+
return { preset: 'custom', instructions, data };
|
|
2849
|
+
}
|
|
2850
|
+
return null;
|
|
1157
2851
|
}
|
|
1158
2852
|
|
|
1159
2853
|
function createRunStateFromGoal(goal) {
|
|
@@ -3967,7 +5661,7 @@ function terminateChildProcessTree(child) {
|
|
|
3967
5661
|
}, 1500).unref?.();
|
|
3968
5662
|
}
|
|
3969
5663
|
|
|
3970
|
-
function runChild(command, args, { cwd, shell = false } = {}) {
|
|
5664
|
+
function runChild(command, args, { cwd, shell = false, env = null } = {}) {
|
|
3971
5665
|
return new Promise((resolve) => {
|
|
3972
5666
|
const cancelState = ACTIVE_REMOTE_CONTROL_EXECUTION_STATE;
|
|
3973
5667
|
if (cancelState?.cancelRequested || cancelState?.revoked || cancelState?.active === false) {
|
|
@@ -3976,6 +5670,7 @@ function runChild(command, args, { cwd, shell = false } = {}) {
|
|
|
3976
5670
|
const child = spawn(command, args, {
|
|
3977
5671
|
cwd: cwd || process.cwd(),
|
|
3978
5672
|
shell,
|
|
5673
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
3979
5674
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
3980
5675
|
windowsHide: true
|
|
3981
5676
|
});
|
|
@@ -4041,6 +5736,18 @@ function printLocalHelp() {
|
|
|
4041
5736
|
console.log(' /remote-control [name] [--lan|--host <host>|--port <port>]');
|
|
4042
5737
|
console.log(' /remote-control --cloud [name]');
|
|
4043
5738
|
console.log(' /remote-control stop');
|
|
5739
|
+
console.log(' /mcp list|get|add|add-http|add-json|remove|tools|call|ping|login');
|
|
5740
|
+
console.log(' /skills list|show <name>|new <name>');
|
|
5741
|
+
console.log(' /plugins list|show|init|install|enable|disable');
|
|
5742
|
+
console.log(' /permissions show|init|profile|check');
|
|
5743
|
+
console.log(' /browser status|setup');
|
|
5744
|
+
console.log(' /hooks list|review|trust|disable|enable|init|test <event>');
|
|
5745
|
+
console.log(' /subagents list|run [--agents a,b] <goal>');
|
|
5746
|
+
console.log(' /transcript path|list|export [file]');
|
|
5747
|
+
console.log(' /resume list|last|<id>');
|
|
5748
|
+
console.log(' /fork <name> [base] create isolated git worktree');
|
|
5749
|
+
console.log(' /worktrees list list git worktrees');
|
|
5750
|
+
console.log(' /github-action init create @bortex GitHub workflow');
|
|
4044
5751
|
console.log(' /diff [unstaged|staged|all]');
|
|
4045
5752
|
console.log(' /stage <file>|--all');
|
|
4046
5753
|
console.log(' /unstage <file>|--all');
|
|
@@ -4705,7 +6412,8 @@ function isReadOnlyRemoteSlashCommand(line) {
|
|
|
4705
6412
|
if ([
|
|
4706
6413
|
'/help', '/commands', '/menu', '/status', '/pwd', '/ls', '/tree', '/read',
|
|
4707
6414
|
'/diff', '/hunks', '/show-hunk', '/ssh-status', '/sys-status',
|
|
4708
|
-
'/process-status', '/port-status', '/history'
|
|
6415
|
+
'/process-status', '/port-status', '/history', '/mcp', '/skills',
|
|
6416
|
+
'/plugins', '/permissions', '/browser', '/hooks', '/subagents', '/agents', '/transcript', '/resume', '/worktrees'
|
|
4709
6417
|
].includes(lc)) return true;
|
|
4710
6418
|
if (lc === '/llm-config') {
|
|
4711
6419
|
const sub = String(rest[0] || 'show').toLowerCase();
|
|
@@ -4752,6 +6460,7 @@ function assessRemoteControlPromptPermission(opts, line) {
|
|
|
4752
6460
|
const mode = getRemoteControlPermissionMode(opts);
|
|
4753
6461
|
const text = String(line || '').trim();
|
|
4754
6462
|
if (!text) return { ok: false, mode, reason: 'empty prompt' };
|
|
6463
|
+
if (opts._remoteControlApprovalBypass) return { ok: true, mode, approved: true };
|
|
4755
6464
|
if (mode === 'full') return { ok: true, mode };
|
|
4756
6465
|
if (!text.startsWith('/')) return { ok: true, mode, skipNaturalActions: true };
|
|
4757
6466
|
if (mode === 'read-only') {
|
|
@@ -4855,6 +6564,24 @@ async function postCloudRemoteChunk(state, commandId, stream, text) {
|
|
|
4855
6564
|
}, 12000);
|
|
4856
6565
|
}
|
|
4857
6566
|
|
|
6567
|
+
async function postCloudRemoteApprovalRequest(state, commandId, permission, text) {
|
|
6568
|
+
const approvalUrl = withRemoteControlQuery(
|
|
6569
|
+
state.approvalUrl || `${state.baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(state.sessionId)}/approval-request`,
|
|
6570
|
+
state.baseUrl,
|
|
6571
|
+
{ agentToken: state.agentToken }
|
|
6572
|
+
);
|
|
6573
|
+
await fetchRemoteControlJson(approvalUrl, {
|
|
6574
|
+
method: 'POST',
|
|
6575
|
+
headers: { 'Content-Type': 'application/json' },
|
|
6576
|
+
body: JSON.stringify({
|
|
6577
|
+
commandId,
|
|
6578
|
+
text: String(text || ''),
|
|
6579
|
+
mode: permission?.mode || state.permission || 'balanced',
|
|
6580
|
+
reason: permission?.reason || 'This command requires explicit approval.'
|
|
6581
|
+
})
|
|
6582
|
+
}, 12000);
|
|
6583
|
+
}
|
|
6584
|
+
|
|
4858
6585
|
function createCloudRemoteChunkStreamer(state, commandId) {
|
|
4859
6586
|
const queue = [];
|
|
4860
6587
|
let timer = null;
|
|
@@ -4972,10 +6699,32 @@ async function pollCloudRemoteControlLoop(opts, state) {
|
|
|
4972
6699
|
if (!text) continue;
|
|
4973
6700
|
state.cancelRequested = false;
|
|
4974
6701
|
state.cancelCommandId = null;
|
|
6702
|
+
const approved = !!command.approved;
|
|
6703
|
+
const permission = assessRemoteControlPromptPermission(opts, text);
|
|
6704
|
+
if (!permission.ok && !approved) {
|
|
6705
|
+
state.busy = true;
|
|
6706
|
+
opts._remoteControlExecutionState = state;
|
|
6707
|
+
try {
|
|
6708
|
+
await postCloudRemoteApprovalRequest(state, commandId, permission, text);
|
|
6709
|
+
console.log(`Cloud Remote Control approval requested for command #${commandId}: ${permission.reason}.`);
|
|
6710
|
+
} catch (err) {
|
|
6711
|
+
const message = err.message || String(err);
|
|
6712
|
+
await postCloudRemoteResult(state, commandId, {
|
|
6713
|
+
ok: false,
|
|
6714
|
+
output: `Approval request failed: ${message}`
|
|
6715
|
+
}).catch(() => {});
|
|
6716
|
+
} finally {
|
|
6717
|
+
state.busy = false;
|
|
6718
|
+
delete opts._remoteControlExecutionState;
|
|
6719
|
+
}
|
|
6720
|
+
continue;
|
|
6721
|
+
}
|
|
4975
6722
|
state.busy = true;
|
|
4976
6723
|
opts._remoteControlExecutionState = state;
|
|
4977
6724
|
const streamer = createCloudRemoteChunkStreamer(state, commandId);
|
|
4978
6725
|
let result;
|
|
6726
|
+
const previousApprovalBypass = opts._remoteControlApprovalBypass;
|
|
6727
|
+
if (approved) opts._remoteControlApprovalBypass = true;
|
|
4979
6728
|
try {
|
|
4980
6729
|
result = await runRemoteControlPrompt(opts, text, (stream, chunk) => streamer.push(stream, chunk));
|
|
4981
6730
|
} catch (err) {
|
|
@@ -4984,6 +6733,8 @@ async function pollCloudRemoteControlLoop(opts, state) {
|
|
|
4984
6733
|
await streamer.flush().catch(() => {});
|
|
4985
6734
|
state.busy = false;
|
|
4986
6735
|
delete opts._remoteControlExecutionState;
|
|
6736
|
+
if (previousApprovalBypass === undefined) delete opts._remoteControlApprovalBypass;
|
|
6737
|
+
else opts._remoteControlApprovalBypass = previousApprovalBypass;
|
|
4987
6738
|
}
|
|
4988
6739
|
if (state.cancelRequested && result.ok) {
|
|
4989
6740
|
result = { ok: false, output: 'Canceled by remote request.' };
|
|
@@ -5032,6 +6783,11 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
|
|
|
5032
6783
|
const name = getRemoteControlDisplayName(opts, overrides);
|
|
5033
6784
|
const permission = getRemoteControlPermissionMode(opts, overrides);
|
|
5034
6785
|
opts.remoteControlPermission = permission;
|
|
6786
|
+
const sharedSettings = loadSharedSettings();
|
|
6787
|
+
const registerCookie = getCookieHeaderFromSharedSettings();
|
|
6788
|
+
const registerApiKey = String(
|
|
6789
|
+
opts.apiKey || process.env.BORTEX_API_KEY || sharedSettings?.llmSettings?.apiKey || ''
|
|
6790
|
+
).trim();
|
|
5035
6791
|
const payload = {
|
|
5036
6792
|
name,
|
|
5037
6793
|
cwd: opts.cwd,
|
|
@@ -5042,9 +6798,12 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
|
|
|
5042
6798
|
mode: opts.agent ? 'agent' : 'chat',
|
|
5043
6799
|
permission
|
|
5044
6800
|
};
|
|
6801
|
+
const registerHeaders = { 'Content-Type': 'application/json' };
|
|
6802
|
+
if (registerCookie) registerHeaders.Cookie = registerCookie;
|
|
6803
|
+
if (registerApiKey) registerHeaders['x-api-key'] = registerApiKey;
|
|
5045
6804
|
const data = await fetchRemoteControlJson(`${baseUrl}/api/bortex-code/remote/register`, {
|
|
5046
6805
|
method: 'POST',
|
|
5047
|
-
headers:
|
|
6806
|
+
headers: registerHeaders,
|
|
5048
6807
|
body: JSON.stringify(payload)
|
|
5049
6808
|
}, 30000);
|
|
5050
6809
|
|
|
@@ -5068,6 +6827,7 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
|
|
|
5068
6827
|
pollUrl: data.pollUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/poll`,
|
|
5069
6828
|
resultUrl: data.resultUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/result`,
|
|
5070
6829
|
chunkUrl: data.chunkUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/chunk`,
|
|
6830
|
+
approvalUrl: data.approvalUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/approval-request`,
|
|
5071
6831
|
heartbeatUrl: data.heartbeatUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/heartbeat`,
|
|
5072
6832
|
controlUrl: data.controlUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/control`,
|
|
5073
6833
|
lastCommandId: 0,
|
|
@@ -5088,6 +6848,8 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
|
|
|
5088
6848
|
|
|
5089
6849
|
console.log(`Cloud Remote Control active: ${state.name}`);
|
|
5090
6850
|
console.log(`URL: ${state.url}`);
|
|
6851
|
+
if (data.accountUrl) console.log(`Account URL: ${data.accountUrl}`);
|
|
6852
|
+
if (data.sessionsUrl) console.log(`Sessions: ${data.sessionsUrl}`);
|
|
5091
6853
|
console.log(`Permission: ${state.permission}`);
|
|
5092
6854
|
console.log('No inbound port required. Keep this process running.');
|
|
5093
6855
|
console.log('Security: keep this tokenized URL private. Anyone with the URL can control this Bortex Code session.');
|
|
@@ -6452,17 +8214,69 @@ async function handleLocalCommand(opts, line) {
|
|
|
6452
8214
|
const [cmd, ...rest] = parseWords(line);
|
|
6453
8215
|
if (!cmd) return { handled: false };
|
|
6454
8216
|
const lc = cmd.toLowerCase();
|
|
8217
|
+
const permissionDecision = checkLocalPermission(opts, lc, line);
|
|
8218
|
+
if (!permissionDecision.allowed) {
|
|
8219
|
+
console.log(`Command denied by permissions: ${permissionDecision.reason}`);
|
|
8220
|
+
return { handled: true, data: permissionDecision };
|
|
8221
|
+
}
|
|
8222
|
+
if (!opts._skipLocalCommandHooks && lc !== '/hooks') {
|
|
8223
|
+
const hookDecision = await runBortexHooks(opts, 'PreToolUse', {
|
|
8224
|
+
tool_name: 'SlashCommand',
|
|
8225
|
+
tool_input: { command: line },
|
|
8226
|
+
command: line
|
|
8227
|
+
}, { decision: true });
|
|
8228
|
+
if (hookDecision.denied) {
|
|
8229
|
+
console.log(`Command denied by hook: ${hookDecision.reason}`);
|
|
8230
|
+
return { handled: true, data: hookDecision };
|
|
8231
|
+
}
|
|
8232
|
+
}
|
|
6455
8233
|
if (lc === '/help') {
|
|
6456
8234
|
printLocalHelp();
|
|
6457
8235
|
return { handled: true };
|
|
6458
8236
|
}
|
|
6459
8237
|
if (lc === '/commands' || lc === '/menu') {
|
|
6460
8238
|
console.log('Commands');
|
|
6461
|
-
|
|
8239
|
+
getAllSlashCommandRows(opts).forEach(([command, description]) => {
|
|
6462
8240
|
console.log(` ${command.padEnd(28)} ${description}`);
|
|
6463
8241
|
});
|
|
6464
8242
|
return { handled: true };
|
|
6465
8243
|
}
|
|
8244
|
+
if (lc === '/skills' || lc === '/skill') {
|
|
8245
|
+
return handleSkillsCommand(opts, rest);
|
|
8246
|
+
}
|
|
8247
|
+
if (lc === '/plugins' || lc === '/plugin') {
|
|
8248
|
+
return handlePluginsCommand(opts, rest);
|
|
8249
|
+
}
|
|
8250
|
+
if (lc === '/permissions' || lc === '/permission' || lc === '/sandbox') {
|
|
8251
|
+
return handlePermissionsCommand(opts, rest);
|
|
8252
|
+
}
|
|
8253
|
+
if (lc === '/browser' || lc === '/playwright') {
|
|
8254
|
+
return handleBrowserCommand(opts, rest);
|
|
8255
|
+
}
|
|
8256
|
+
if (lc === '/mcp') {
|
|
8257
|
+
return handleMcpCommand(opts, rest, line);
|
|
8258
|
+
}
|
|
8259
|
+
if (lc === '/hooks') {
|
|
8260
|
+
return handleHooksCommand(opts, rest);
|
|
8261
|
+
}
|
|
8262
|
+
if (lc === '/transcript' || lc === '/transcripts') {
|
|
8263
|
+
return handleTranscriptCommand(opts, rest);
|
|
8264
|
+
}
|
|
8265
|
+
if (lc === '/resume') {
|
|
8266
|
+
return handleResumeCommand(opts, rest);
|
|
8267
|
+
}
|
|
8268
|
+
if (lc === '/subagents' || lc === '/agents') {
|
|
8269
|
+
return handleSubagentsCommand(opts, rest, line);
|
|
8270
|
+
}
|
|
8271
|
+
if (lc === '/fork' || lc === '/side' || lc === '/worktree' || lc === '/worktrees') {
|
|
8272
|
+
return handleWorktreeCommand(opts, rest, line);
|
|
8273
|
+
}
|
|
8274
|
+
if (lc === '/github-action' || lc === '/install-github-app') {
|
|
8275
|
+
return handleGithubActionCommand(opts, rest);
|
|
8276
|
+
}
|
|
8277
|
+
if (await handleCustomSlashCommand(opts, lc, rest, line)) {
|
|
8278
|
+
return { handled: true };
|
|
8279
|
+
}
|
|
6466
8280
|
if (lc === '/agent') {
|
|
6467
8281
|
const v = String(rest[0] || '').toLowerCase();
|
|
6468
8282
|
if (!['on', 'off', 'true', 'false', '1', '0'].includes(v)) {
|
|
@@ -7475,6 +9289,11 @@ async function handleLocalCommand(opts, line) {
|
|
|
7475
9289
|
return { handled: true };
|
|
7476
9290
|
}
|
|
7477
9291
|
if (lc === '/review') {
|
|
9292
|
+
const preset = await runReviewPreset(opts, rest);
|
|
9293
|
+
if (preset) {
|
|
9294
|
+
recordArtifact(opts, 'review-preset', JSON.stringify({ preset: preset.preset, base: preset.base, sha: preset.sha }).slice(0, 300));
|
|
9295
|
+
return { handled: true, data: preset };
|
|
9296
|
+
}
|
|
7478
9297
|
let mode = 'all';
|
|
7479
9298
|
if (rest.includes('--staged')) mode = 'staged';
|
|
7480
9299
|
else if (rest.includes('--unstaged')) mode = 'unstaged';
|
|
@@ -7650,6 +9469,219 @@ function printResponse(opts, data) {
|
|
|
7650
9469
|
}
|
|
7651
9470
|
}
|
|
7652
9471
|
|
|
9472
|
+
function responseToFinalText(opts, data) {
|
|
9473
|
+
if (!opts?.agent) return String(data?.message || '');
|
|
9474
|
+
if (data?.message) return String(data.message);
|
|
9475
|
+
const applied = Array.isArray(data?.applied) ? data.applied : [];
|
|
9476
|
+
const errors = Array.isArray(data?.errors) ? data.errors : [];
|
|
9477
|
+
const lines = [];
|
|
9478
|
+
if (applied.length) lines.push(`Applied changes (${applied.length}):`, ...applied.map((p) => `- ${p}`));
|
|
9479
|
+
if (errors.length) lines.push(`Errors (${errors.length}):`, ...errors.map((e) => `- ${e.path || '?'}: ${e.error || 'error'}`));
|
|
9480
|
+
return lines.join('\n') || JSON.stringify(data || {});
|
|
9481
|
+
}
|
|
9482
|
+
|
|
9483
|
+
function emitExecJson(type, payload = {}) {
|
|
9484
|
+
process.stdout.write(`${JSON.stringify({ type, ...payload })}\n`);
|
|
9485
|
+
}
|
|
9486
|
+
|
|
9487
|
+
async function readPipedStdin() {
|
|
9488
|
+
if (process.stdin.isTTY) return '';
|
|
9489
|
+
let stat;
|
|
9490
|
+
try {
|
|
9491
|
+
stat = fs.fstatSync(0);
|
|
9492
|
+
if (!stat.isFIFO() && !stat.isFile()) return '';
|
|
9493
|
+
} catch (_err) {
|
|
9494
|
+
return '';
|
|
9495
|
+
}
|
|
9496
|
+
if (stat.isFile()) {
|
|
9497
|
+
try { return fs.readFileSync(0, 'utf8'); } catch (_err) { return ''; }
|
|
9498
|
+
}
|
|
9499
|
+
if (!stat.isFIFO()) return '';
|
|
9500
|
+
return new Promise((resolve) => {
|
|
9501
|
+
const chunks = [];
|
|
9502
|
+
let settled = false;
|
|
9503
|
+
const cleanup = () => {
|
|
9504
|
+
process.stdin.off('data', onFirstData);
|
|
9505
|
+
process.stdin.off('data', onData);
|
|
9506
|
+
process.stdin.off('end', onEnd);
|
|
9507
|
+
process.stdin.off('error', onError);
|
|
9508
|
+
try { process.stdin.pause(); } catch (_err) {}
|
|
9509
|
+
};
|
|
9510
|
+
const finish = () => {
|
|
9511
|
+
if (settled) return;
|
|
9512
|
+
settled = true;
|
|
9513
|
+
clearTimeout(timer);
|
|
9514
|
+
cleanup();
|
|
9515
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
9516
|
+
};
|
|
9517
|
+
const onData = (chunk) => chunks.push(Buffer.from(chunk));
|
|
9518
|
+
const onFirstData = (chunk) => {
|
|
9519
|
+
clearTimeout(timer);
|
|
9520
|
+
chunks.push(Buffer.from(chunk));
|
|
9521
|
+
process.stdin.on('data', onData);
|
|
9522
|
+
};
|
|
9523
|
+
const onEnd = () => finish();
|
|
9524
|
+
const onError = () => {
|
|
9525
|
+
if (settled) return;
|
|
9526
|
+
settled = true;
|
|
9527
|
+
clearTimeout(timer);
|
|
9528
|
+
cleanup();
|
|
9529
|
+
resolve('');
|
|
9530
|
+
};
|
|
9531
|
+
const timer = setTimeout(() => {
|
|
9532
|
+
if (settled) return;
|
|
9533
|
+
settled = true;
|
|
9534
|
+
cleanup();
|
|
9535
|
+
resolve('');
|
|
9536
|
+
}, 200);
|
|
9537
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
9538
|
+
process.stdin.once('data', onFirstData);
|
|
9539
|
+
process.stdin.once('end', onEnd);
|
|
9540
|
+
process.stdin.once('error', onError);
|
|
9541
|
+
try { process.stdin.resume(); } catch (_err) { onError(); }
|
|
9542
|
+
});
|
|
9543
|
+
}
|
|
9544
|
+
|
|
9545
|
+
async function captureStdout(fn) {
|
|
9546
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
9547
|
+
let output = '';
|
|
9548
|
+
process.stdout.write = (chunk, encoding, cb) => {
|
|
9549
|
+
output += Buffer.isBuffer(chunk) ? chunk.toString(typeof encoding === 'string' ? encoding : 'utf8') : String(chunk || '');
|
|
9550
|
+
if (typeof encoding === 'function') encoding();
|
|
9551
|
+
if (typeof cb === 'function') cb();
|
|
9552
|
+
return true;
|
|
9553
|
+
};
|
|
9554
|
+
try {
|
|
9555
|
+
const result = await fn();
|
|
9556
|
+
return { result, output };
|
|
9557
|
+
} finally {
|
|
9558
|
+
process.stdout.write = originalWrite;
|
|
9559
|
+
}
|
|
9560
|
+
}
|
|
9561
|
+
|
|
9562
|
+
function loadExecSchemaInstruction(opts) {
|
|
9563
|
+
if (!opts.execSchema) return '';
|
|
9564
|
+
const schemaPath = resolveCliPath(opts, opts.execSchema);
|
|
9565
|
+
const schemaText = fs.readFileSync(schemaPath, 'utf8');
|
|
9566
|
+
JSON.parse(schemaText);
|
|
9567
|
+
return [
|
|
9568
|
+
'',
|
|
9569
|
+
'Structured output requirement:',
|
|
9570
|
+
'Return only valid JSON. Do not wrap it in Markdown. The JSON must conform to this JSON Schema:',
|
|
9571
|
+
schemaText.trim()
|
|
9572
|
+
].join('\n');
|
|
9573
|
+
}
|
|
9574
|
+
|
|
9575
|
+
async function runExecMode(opts) {
|
|
9576
|
+
const startedAt = Date.now();
|
|
9577
|
+
const threadId = opts.execEphemeral
|
|
9578
|
+
? `exec-${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}`
|
|
9579
|
+
: ensureTranscriptId(opts);
|
|
9580
|
+
const json = !!opts.execJson;
|
|
9581
|
+
const oldSpinner = opts.ux.spinner;
|
|
9582
|
+
const oldProgress = opts.ux.progress;
|
|
9583
|
+
opts.ux.spinner = false;
|
|
9584
|
+
opts.ux.progress = false;
|
|
9585
|
+
|
|
9586
|
+
const stdinText = await readPipedStdin();
|
|
9587
|
+
let prompt = String(opts.prompt || '').trim();
|
|
9588
|
+
if (prompt === '-') {
|
|
9589
|
+
prompt = stdinText.trim();
|
|
9590
|
+
} else if (stdinText.trim()) {
|
|
9591
|
+
prompt = [
|
|
9592
|
+
prompt || 'Process the following stdin context.',
|
|
9593
|
+
'',
|
|
9594
|
+
'STDIN context:',
|
|
9595
|
+
stdinText.trim()
|
|
9596
|
+
].join('\n');
|
|
9597
|
+
}
|
|
9598
|
+
|
|
9599
|
+
if (!prompt) {
|
|
9600
|
+
const msg = 'Usage: bortexcode exec [--json] [-o file] [--output-schema file] <prompt>';
|
|
9601
|
+
if (json) emitExecJson('error', { message: msg });
|
|
9602
|
+
else console.error(msg);
|
|
9603
|
+
process.exitCode = 2;
|
|
9604
|
+
opts.ux.spinner = oldSpinner;
|
|
9605
|
+
opts.ux.progress = oldProgress;
|
|
9606
|
+
return;
|
|
9607
|
+
}
|
|
9608
|
+
|
|
9609
|
+
let schemaInstruction = '';
|
|
9610
|
+
try {
|
|
9611
|
+
schemaInstruction = loadExecSchemaInstruction(opts);
|
|
9612
|
+
} catch (err) {
|
|
9613
|
+
const msg = `Invalid --output-schema: ${err.message}`;
|
|
9614
|
+
if (json) emitExecJson('error', { message: msg });
|
|
9615
|
+
else console.error(msg);
|
|
9616
|
+
process.exitCode = 2;
|
|
9617
|
+
opts.ux.spinner = oldSpinner;
|
|
9618
|
+
opts.ux.progress = oldProgress;
|
|
9619
|
+
return;
|
|
9620
|
+
}
|
|
9621
|
+
if (schemaInstruction) prompt += schemaInstruction;
|
|
9622
|
+
|
|
9623
|
+
if (json) {
|
|
9624
|
+
emitExecJson('thread.started', { thread_id: threadId, cwd: opts.cwd, mode: opts.agent ? 'agent' : 'chat' });
|
|
9625
|
+
emitExecJson('turn.started', { thread_id: threadId });
|
|
9626
|
+
}
|
|
9627
|
+
|
|
9628
|
+
appendTranscriptEvent(opts, 'user', { text: prompt, exec: true, json, outputSchema: !!opts.execSchema });
|
|
9629
|
+
const submitHook = await runBortexHooks(opts, 'UserPromptSubmit', { prompt, exec: true }, { decision: true });
|
|
9630
|
+
if (submitHook.denied) {
|
|
9631
|
+
const msg = `Prompt denied by hook: ${submitHook.reason}`;
|
|
9632
|
+
if (json) emitExecJson('turn.failed', { thread_id: threadId, error: msg });
|
|
9633
|
+
else console.error(msg);
|
|
9634
|
+
process.exitCode = 1;
|
|
9635
|
+
opts.ux.spinner = oldSpinner;
|
|
9636
|
+
opts.ux.progress = oldProgress;
|
|
9637
|
+
return;
|
|
9638
|
+
}
|
|
9639
|
+
|
|
9640
|
+
try {
|
|
9641
|
+
let finalText = '';
|
|
9642
|
+
if (prompt.trim().startsWith('/')) {
|
|
9643
|
+
if (json) throw new Error('Exec --json does not support local slash commands yet. Run the slash command without --json.');
|
|
9644
|
+
const captured = await captureStdout(() => handleLocalCommand(opts, prompt.trim()));
|
|
9645
|
+
const localResult = captured.result;
|
|
9646
|
+
finalText = localResult.handled
|
|
9647
|
+
? (captured.output.trim() || JSON.stringify(localResult.data ?? { handled: true }, null, 2))
|
|
9648
|
+
: '';
|
|
9649
|
+
} else {
|
|
9650
|
+
if (opts.offline) throw new Error('Exec mode needs the Bortex server unless the prompt is a local slash command.');
|
|
9651
|
+
const data = await askServer(opts, maybeAugmentPromptWithResumeContext(opts, prompt));
|
|
9652
|
+
finalText = responseToFinalText(opts, data);
|
|
9653
|
+
}
|
|
9654
|
+
|
|
9655
|
+
if (opts.execSchema) {
|
|
9656
|
+
try { JSON.parse(finalText); } catch (err) {
|
|
9657
|
+
throw new Error(`Model output did not parse as JSON for --output-schema: ${err.message}`);
|
|
9658
|
+
}
|
|
9659
|
+
}
|
|
9660
|
+
|
|
9661
|
+
if (opts.execOutput) {
|
|
9662
|
+
const outPath = resolveCliPath(opts, opts.execOutput);
|
|
9663
|
+
ensureDirSync(path.dirname(outPath));
|
|
9664
|
+
fs.writeFileSync(outPath, finalText.endsWith('\n') ? finalText : `${finalText}\n`, 'utf8');
|
|
9665
|
+
}
|
|
9666
|
+
appendTranscriptEvent(opts, 'assistant', { text: finalText, exec: true });
|
|
9667
|
+
|
|
9668
|
+
if (json) {
|
|
9669
|
+
emitExecJson('item.completed', { item: { id: 'final', type: 'agent_message', text: finalText } });
|
|
9670
|
+
emitExecJson('turn.completed', { thread_id: threadId, duration_ms: Date.now() - startedAt });
|
|
9671
|
+
} else {
|
|
9672
|
+
process.stdout.write(finalText.endsWith('\n') ? finalText : `${finalText}\n`);
|
|
9673
|
+
}
|
|
9674
|
+
} catch (err) {
|
|
9675
|
+
appendTranscriptEvent(opts, 'assistant-error', { exec: true, error: err.message });
|
|
9676
|
+
if (json) emitExecJson('turn.failed', { thread_id: threadId, error: err.message, duration_ms: Date.now() - startedAt });
|
|
9677
|
+
else console.error(err.message);
|
|
9678
|
+
process.exitCode = 1;
|
|
9679
|
+
} finally {
|
|
9680
|
+
opts.ux.spinner = oldSpinner;
|
|
9681
|
+
opts.ux.progress = oldProgress;
|
|
9682
|
+
}
|
|
9683
|
+
}
|
|
9684
|
+
|
|
7653
9685
|
const SLASH_COMMANDS = [
|
|
7654
9686
|
['/agent on', 'Enable agent mode'],
|
|
7655
9687
|
['/agent off', 'Disable agent mode'],
|
|
@@ -7660,6 +9692,23 @@ const SLASH_COMMANDS = [
|
|
|
7660
9692
|
['/remote-control --lan', 'Expose Remote Control on the local network'],
|
|
7661
9693
|
['/remote-control --cloud', 'Control this session through bortex.site relay'],
|
|
7662
9694
|
['/rc', 'Toggle Remote Control'],
|
|
9695
|
+
['/mcp list', 'List configured MCP servers'],
|
|
9696
|
+
['/mcp add <name> -- <cmd>', 'Add a stdio MCP server'],
|
|
9697
|
+
['/skills list', 'List custom skills and slash commands'],
|
|
9698
|
+
['/skills new <name>', 'Create a project skill'],
|
|
9699
|
+
['/plugins list', 'List installed plugins'],
|
|
9700
|
+
['/plugins init <name>', 'Create a project plugin'],
|
|
9701
|
+
['/permissions show', 'Show active permission profile'],
|
|
9702
|
+
['/browser setup', 'Configure Playwright MCP browser automation'],
|
|
9703
|
+
['/hooks list', 'List lifecycle hooks'],
|
|
9704
|
+
['/hooks init', 'Create project hooks.json'],
|
|
9705
|
+
['/subagents list', 'List available subagents'],
|
|
9706
|
+
['/subagents run <goal>', 'Run a multi-agent analysis'],
|
|
9707
|
+
['/transcript path', 'Show current transcript path'],
|
|
9708
|
+
['/transcript export', 'Export current transcript as JSON'],
|
|
9709
|
+
['/resume last', 'Load the latest transcript context'],
|
|
9710
|
+
['/fork <name>', 'Create an isolated git worktree'],
|
|
9711
|
+
['/github-action init', 'Create GitHub @bortex workflow'],
|
|
7663
9712
|
['/llm-config show', 'Show cached LLM configuration'],
|
|
7664
9713
|
['/llm-config sync', 'Sync LLM configuration from Bortex'],
|
|
7665
9714
|
['/pwd', 'Show current working directory'],
|
|
@@ -7683,10 +9732,10 @@ const SLASH_COMMANDS = [
|
|
|
7683
9732
|
['/exit', 'Exit']
|
|
7684
9733
|
];
|
|
7685
9734
|
|
|
7686
|
-
function getSlashCompletions(line = '') {
|
|
9735
|
+
function getSlashCompletions(line = '', opts = {}) {
|
|
7687
9736
|
const prefix = String(line || '').trim();
|
|
7688
9737
|
if (!prefix.startsWith('/')) return [];
|
|
7689
|
-
return
|
|
9738
|
+
return getAllSlashCommandRows(opts)
|
|
7690
9739
|
.map(([command]) => command)
|
|
7691
9740
|
.filter((command) => command.startsWith(prefix));
|
|
7692
9741
|
}
|
|
@@ -7696,7 +9745,8 @@ function printSlashMenu(rl, opts) {
|
|
|
7696
9745
|
const prompt = opts._currentPrompt || `bortex:${opts.agent ? 'agent' : 'chat'}> `;
|
|
7697
9746
|
const line = String(rl.line || '');
|
|
7698
9747
|
const query = line.startsWith('/') ? line : '/';
|
|
7699
|
-
const
|
|
9748
|
+
const allRows = getAllSlashCommandRows(opts);
|
|
9749
|
+
const rows = allRows
|
|
7700
9750
|
.filter(([command]) => command.startsWith(query) || query === '/')
|
|
7701
9751
|
.slice(0, 12);
|
|
7702
9752
|
|
|
@@ -7705,7 +9755,7 @@ function printSlashMenu(rl, opts) {
|
|
|
7705
9755
|
rows.forEach(([command, description]) => {
|
|
7706
9756
|
console.log(` ${command.padEnd(28)} ${description}`);
|
|
7707
9757
|
});
|
|
7708
|
-
if (
|
|
9758
|
+
if (allRows.length > rows.length) {
|
|
7709
9759
|
console.log(` ${'...'.padEnd(28)} Keep typing to narrow results`);
|
|
7710
9760
|
}
|
|
7711
9761
|
process.stdout.write('\n');
|
|
@@ -7780,23 +9830,40 @@ function printWelcomeCard(opts) {
|
|
|
7780
9830
|
}
|
|
7781
9831
|
|
|
7782
9832
|
async function runSinglePrompt(opts) {
|
|
7783
|
-
|
|
7784
|
-
|
|
7785
|
-
|
|
9833
|
+
const rawPrompt = String(opts.prompt || '').trim();
|
|
9834
|
+
appendTranscriptEvent(opts, 'user', { text: rawPrompt, singlePrompt: true });
|
|
9835
|
+
const submitHook = await runBortexHooks(opts, 'UserPromptSubmit', { prompt: rawPrompt }, { decision: true });
|
|
9836
|
+
if (submitHook.denied) {
|
|
9837
|
+
console.log(`Prompt denied by hook: ${submitHook.reason}`);
|
|
9838
|
+
return;
|
|
9839
|
+
}
|
|
9840
|
+
if (rawPrompt.startsWith('/')) {
|
|
9841
|
+
const localResult = await handleLocalCommand(opts, rawPrompt);
|
|
9842
|
+
if (localResult.handled) {
|
|
9843
|
+
appendTranscriptEvent(opts, 'tool', { command: rawPrompt, data: localResult.data || null });
|
|
9844
|
+
return;
|
|
9845
|
+
}
|
|
9846
|
+
}
|
|
9847
|
+
const naturalFileAction = await runNaturalLocalFileAction(opts, rawPrompt);
|
|
9848
|
+
if (naturalFileAction.handled) {
|
|
9849
|
+
appendTranscriptEvent(opts, 'tool', { command: 'natural-file-action', prompt: rawPrompt, data: naturalFileAction.data || null });
|
|
9850
|
+
return;
|
|
7786
9851
|
}
|
|
7787
|
-
const
|
|
7788
|
-
if (naturalFileAction.handled) return;
|
|
7789
|
-
const localIntent = classifyLocalPromptIntent(opts.prompt);
|
|
9852
|
+
const localIntent = classifyLocalPromptIntent(rawPrompt);
|
|
7790
9853
|
if (localIntent?.command) {
|
|
7791
9854
|
const localResult = await handleLocalCommand(opts, localIntent.command);
|
|
7792
|
-
if (localResult.handled)
|
|
9855
|
+
if (localResult.handled) {
|
|
9856
|
+
appendTranscriptEvent(opts, 'tool', { command: localIntent.command, data: localResult.data || null });
|
|
9857
|
+
return;
|
|
9858
|
+
}
|
|
7793
9859
|
}
|
|
7794
9860
|
if (opts.offline) {
|
|
7795
9861
|
console.error('Modalita --offline: usa REPL interattiva e comandi locali (/help).');
|
|
7796
9862
|
process.exit(1);
|
|
7797
9863
|
}
|
|
7798
|
-
const data = await askServer(opts, opts
|
|
9864
|
+
const data = await askServer(opts, maybeAugmentPromptWithResumeContext(opts, rawPrompt));
|
|
7799
9865
|
printResponse(opts, data);
|
|
9866
|
+
appendTranscriptEvent(opts, 'assistant', { text: data?.message || JSON.stringify(data || {}) });
|
|
7800
9867
|
}
|
|
7801
9868
|
|
|
7802
9869
|
async function runRepl(opts) {
|
|
@@ -7818,8 +9885,8 @@ async function runRepl(opts) {
|
|
|
7818
9885
|
terminal: true,
|
|
7819
9886
|
completer: (line) => {
|
|
7820
9887
|
if (!String(line || '').startsWith('/')) return [[], line];
|
|
7821
|
-
const hits = getSlashCompletions(line);
|
|
7822
|
-
return [hits.length ? hits :
|
|
9888
|
+
const hits = getSlashCompletions(line, opts);
|
|
9889
|
+
return [hits.length ? hits : getAllSlashCommandRows(opts).map(([command]) => command), line];
|
|
7823
9890
|
}
|
|
7824
9891
|
});
|
|
7825
9892
|
const uninstallSlashMenu = installSlashMenu(rl, opts);
|
|
@@ -7858,6 +9925,13 @@ async function runRepl(opts) {
|
|
|
7858
9925
|
while (true) {
|
|
7859
9926
|
const line = (await question()).trim();
|
|
7860
9927
|
if (!line) continue;
|
|
9928
|
+
appendTranscriptEvent(opts, 'user', { text: line });
|
|
9929
|
+
const submitHook = await runBortexHooks(opts, 'UserPromptSubmit', { prompt: line }, { decision: true });
|
|
9930
|
+
if (submitHook.denied) {
|
|
9931
|
+
console.log(`Prompt denied by hook: ${submitHook.reason}`);
|
|
9932
|
+
appendTranscriptEvent(opts, 'hook-deny', { event: 'UserPromptSubmit', reason: submitHook.reason });
|
|
9933
|
+
continue;
|
|
9934
|
+
}
|
|
7861
9935
|
pushCliHistory(opts, line);
|
|
7862
9936
|
try { saveCliWorkspaceState(opts); } catch (_err) { }
|
|
7863
9937
|
if (line === '/exit' || line === '/quit') break;
|
|
@@ -7883,26 +9957,38 @@ async function runRepl(opts) {
|
|
|
7883
9957
|
}
|
|
7884
9958
|
try {
|
|
7885
9959
|
const localResult = await handleLocalCommand(opts, line);
|
|
7886
|
-
if (localResult.handled)
|
|
9960
|
+
if (localResult.handled) {
|
|
9961
|
+
appendTranscriptEvent(opts, 'tool', { command: line, data: localResult.data || null });
|
|
9962
|
+
continue;
|
|
9963
|
+
}
|
|
7887
9964
|
} catch (err) {
|
|
7888
9965
|
console.error(`Local tool error: ${err.message}`);
|
|
9966
|
+
appendTranscriptEvent(opts, 'tool-error', { command: line, error: err.message });
|
|
7889
9967
|
continue;
|
|
7890
9968
|
}
|
|
7891
9969
|
const localIntent = classifyLocalPromptIntent(line);
|
|
7892
9970
|
if (localIntent?.command) {
|
|
7893
9971
|
try {
|
|
7894
9972
|
const localResult = await handleLocalCommand(opts, localIntent.command);
|
|
7895
|
-
if (localResult.handled)
|
|
9973
|
+
if (localResult.handled) {
|
|
9974
|
+
appendTranscriptEvent(opts, 'tool', { command: localIntent.command, sourcePrompt: line, data: localResult.data || null });
|
|
9975
|
+
continue;
|
|
9976
|
+
}
|
|
7896
9977
|
} catch (err) {
|
|
7897
9978
|
console.error(`Local tool error: ${err.message}`);
|
|
9979
|
+
appendTranscriptEvent(opts, 'tool-error', { command: localIntent.command, sourcePrompt: line, error: err.message });
|
|
7898
9980
|
continue;
|
|
7899
9981
|
}
|
|
7900
9982
|
}
|
|
7901
9983
|
try {
|
|
7902
9984
|
const naturalFileAction = await runNaturalLocalFileAction(opts, line);
|
|
7903
|
-
if (naturalFileAction.handled)
|
|
9985
|
+
if (naturalFileAction.handled) {
|
|
9986
|
+
appendTranscriptEvent(opts, 'tool', { command: 'natural-file-action', prompt: line, data: naturalFileAction.data || null });
|
|
9987
|
+
continue;
|
|
9988
|
+
}
|
|
7904
9989
|
} catch (err) {
|
|
7905
9990
|
console.error(`Local tool error: ${err.message}`);
|
|
9991
|
+
appendTranscriptEvent(opts, 'tool-error', { command: 'natural-file-action', prompt: line, error: err.message });
|
|
7906
9992
|
continue;
|
|
7907
9993
|
}
|
|
7908
9994
|
if (opts.offline) {
|
|
@@ -7910,10 +9996,12 @@ async function runRepl(opts) {
|
|
|
7910
9996
|
continue;
|
|
7911
9997
|
}
|
|
7912
9998
|
try {
|
|
7913
|
-
const data = await askServer(opts, line);
|
|
9999
|
+
const data = await askServer(opts, maybeAugmentPromptWithResumeContext(opts, line));
|
|
7914
10000
|
printResponse(opts, data);
|
|
10001
|
+
appendTranscriptEvent(opts, 'assistant', { text: data?.message || JSON.stringify(data || {}) });
|
|
7915
10002
|
} catch (err) {
|
|
7916
10003
|
console.error(err.message);
|
|
10004
|
+
appendTranscriptEvent(opts, 'assistant-error', { prompt: line, error: err.message });
|
|
7917
10005
|
}
|
|
7918
10006
|
}
|
|
7919
10007
|
|
|
@@ -7975,6 +10063,10 @@ async function main() {
|
|
|
7975
10063
|
opts.sessionReused = false;
|
|
7976
10064
|
}
|
|
7977
10065
|
|
|
10066
|
+
if (opts.resume) {
|
|
10067
|
+
await prepareResumeContext(opts);
|
|
10068
|
+
}
|
|
10069
|
+
|
|
7978
10070
|
if (opts.remoteControlServerMode) {
|
|
7979
10071
|
let remoteState = null;
|
|
7980
10072
|
if (opts.remoteControlCloud) {
|
|
@@ -8020,6 +10112,11 @@ async function main() {
|
|
|
8020
10112
|
return;
|
|
8021
10113
|
}
|
|
8022
10114
|
|
|
10115
|
+
if (opts.execMode) {
|
|
10116
|
+
await runExecMode(opts);
|
|
10117
|
+
return;
|
|
10118
|
+
}
|
|
10119
|
+
|
|
8023
10120
|
if (opts.prompt) {
|
|
8024
10121
|
await runSinglePrompt(opts);
|
|
8025
10122
|
return;
|
|
@@ -8049,6 +10146,24 @@ if (require.main === module) {
|
|
|
8049
10146
|
isRiskyStructuredToolCall,
|
|
8050
10147
|
diffCliToolReports,
|
|
8051
10148
|
extractResumableCallsFromReportPayload,
|
|
8052
|
-
detectGitRepoOperationState
|
|
10149
|
+
detectGitRepoOperationState,
|
|
10150
|
+
loadCustomSlashCommands,
|
|
10151
|
+
getAllSlashCommandRows,
|
|
10152
|
+
loadMcpConfig,
|
|
10153
|
+
getMergedMcpServers,
|
|
10154
|
+
resolveMcpServer,
|
|
10155
|
+
runMcpRequest,
|
|
10156
|
+
appendTranscriptEvent,
|
|
10157
|
+
listTranscriptFiles,
|
|
10158
|
+
findTranscriptByRef,
|
|
10159
|
+
summarizeTranscriptForResume,
|
|
10160
|
+
loadHookEntries,
|
|
10161
|
+
annotateHookTrust,
|
|
10162
|
+
discoverPlugins,
|
|
10163
|
+
getEffectivePermissionProfile,
|
|
10164
|
+
checkLocalPermission,
|
|
10165
|
+
responseToFinalText,
|
|
10166
|
+
getAllSubagents,
|
|
10167
|
+
redactSensitiveText
|
|
8053
10168
|
};
|
|
8054
10169
|
}
|