bortexcode 1.6.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 +2149 -88
- 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 };
|
|
1090
2549
|
}
|
|
1091
2550
|
|
|
1092
|
-
function
|
|
1093
|
-
const
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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 };
|
|
1106
2570
|
}
|
|
1107
2571
|
|
|
1108
|
-
function
|
|
1109
|
-
const
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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 };
|
|
1129
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 };
|
|
1130
2662
|
}
|
|
1131
2663
|
|
|
1132
|
-
function
|
|
1133
|
-
|
|
1134
|
-
const
|
|
1135
|
-
if (
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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 };
|
|
1140
2735
|
}
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
2736
|
+
console.log('Usage: /subagents list|run [--agents a,b] <goal>');
|
|
2737
|
+
return { handled: true };
|
|
2738
|
+
}
|
|
2739
|
+
|
|
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 };
|
|
2746
|
+
}
|
|
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
|
-
|
|
1153
|
-
|
|
1154
|
-
if (
|
|
1155
|
-
|
|
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 };
|
|
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 };
|
|
1156
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();
|
|
@@ -6506,17 +8214,69 @@ async function handleLocalCommand(opts, line) {
|
|
|
6506
8214
|
const [cmd, ...rest] = parseWords(line);
|
|
6507
8215
|
if (!cmd) return { handled: false };
|
|
6508
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
|
+
}
|
|
6509
8233
|
if (lc === '/help') {
|
|
6510
8234
|
printLocalHelp();
|
|
6511
8235
|
return { handled: true };
|
|
6512
8236
|
}
|
|
6513
8237
|
if (lc === '/commands' || lc === '/menu') {
|
|
6514
8238
|
console.log('Commands');
|
|
6515
|
-
|
|
8239
|
+
getAllSlashCommandRows(opts).forEach(([command, description]) => {
|
|
6516
8240
|
console.log(` ${command.padEnd(28)} ${description}`);
|
|
6517
8241
|
});
|
|
6518
8242
|
return { handled: true };
|
|
6519
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
|
+
}
|
|
6520
8280
|
if (lc === '/agent') {
|
|
6521
8281
|
const v = String(rest[0] || '').toLowerCase();
|
|
6522
8282
|
if (!['on', 'off', 'true', 'false', '1', '0'].includes(v)) {
|
|
@@ -7529,6 +9289,11 @@ async function handleLocalCommand(opts, line) {
|
|
|
7529
9289
|
return { handled: true };
|
|
7530
9290
|
}
|
|
7531
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
|
+
}
|
|
7532
9297
|
let mode = 'all';
|
|
7533
9298
|
if (rest.includes('--staged')) mode = 'staged';
|
|
7534
9299
|
else if (rest.includes('--unstaged')) mode = 'unstaged';
|
|
@@ -7704,6 +9469,219 @@ function printResponse(opts, data) {
|
|
|
7704
9469
|
}
|
|
7705
9470
|
}
|
|
7706
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
|
+
|
|
7707
9685
|
const SLASH_COMMANDS = [
|
|
7708
9686
|
['/agent on', 'Enable agent mode'],
|
|
7709
9687
|
['/agent off', 'Disable agent mode'],
|
|
@@ -7714,6 +9692,23 @@ const SLASH_COMMANDS = [
|
|
|
7714
9692
|
['/remote-control --lan', 'Expose Remote Control on the local network'],
|
|
7715
9693
|
['/remote-control --cloud', 'Control this session through bortex.site relay'],
|
|
7716
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'],
|
|
7717
9712
|
['/llm-config show', 'Show cached LLM configuration'],
|
|
7718
9713
|
['/llm-config sync', 'Sync LLM configuration from Bortex'],
|
|
7719
9714
|
['/pwd', 'Show current working directory'],
|
|
@@ -7737,10 +9732,10 @@ const SLASH_COMMANDS = [
|
|
|
7737
9732
|
['/exit', 'Exit']
|
|
7738
9733
|
];
|
|
7739
9734
|
|
|
7740
|
-
function getSlashCompletions(line = '') {
|
|
9735
|
+
function getSlashCompletions(line = '', opts = {}) {
|
|
7741
9736
|
const prefix = String(line || '').trim();
|
|
7742
9737
|
if (!prefix.startsWith('/')) return [];
|
|
7743
|
-
return
|
|
9738
|
+
return getAllSlashCommandRows(opts)
|
|
7744
9739
|
.map(([command]) => command)
|
|
7745
9740
|
.filter((command) => command.startsWith(prefix));
|
|
7746
9741
|
}
|
|
@@ -7750,7 +9745,8 @@ function printSlashMenu(rl, opts) {
|
|
|
7750
9745
|
const prompt = opts._currentPrompt || `bortex:${opts.agent ? 'agent' : 'chat'}> `;
|
|
7751
9746
|
const line = String(rl.line || '');
|
|
7752
9747
|
const query = line.startsWith('/') ? line : '/';
|
|
7753
|
-
const
|
|
9748
|
+
const allRows = getAllSlashCommandRows(opts);
|
|
9749
|
+
const rows = allRows
|
|
7754
9750
|
.filter(([command]) => command.startsWith(query) || query === '/')
|
|
7755
9751
|
.slice(0, 12);
|
|
7756
9752
|
|
|
@@ -7759,7 +9755,7 @@ function printSlashMenu(rl, opts) {
|
|
|
7759
9755
|
rows.forEach(([command, description]) => {
|
|
7760
9756
|
console.log(` ${command.padEnd(28)} ${description}`);
|
|
7761
9757
|
});
|
|
7762
|
-
if (
|
|
9758
|
+
if (allRows.length > rows.length) {
|
|
7763
9759
|
console.log(` ${'...'.padEnd(28)} Keep typing to narrow results`);
|
|
7764
9760
|
}
|
|
7765
9761
|
process.stdout.write('\n');
|
|
@@ -7834,23 +9830,40 @@ function printWelcomeCard(opts) {
|
|
|
7834
9830
|
}
|
|
7835
9831
|
|
|
7836
9832
|
async function runSinglePrompt(opts) {
|
|
7837
|
-
|
|
7838
|
-
|
|
7839
|
-
|
|
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;
|
|
7840
9851
|
}
|
|
7841
|
-
const
|
|
7842
|
-
if (naturalFileAction.handled) return;
|
|
7843
|
-
const localIntent = classifyLocalPromptIntent(opts.prompt);
|
|
9852
|
+
const localIntent = classifyLocalPromptIntent(rawPrompt);
|
|
7844
9853
|
if (localIntent?.command) {
|
|
7845
9854
|
const localResult = await handleLocalCommand(opts, localIntent.command);
|
|
7846
|
-
if (localResult.handled)
|
|
9855
|
+
if (localResult.handled) {
|
|
9856
|
+
appendTranscriptEvent(opts, 'tool', { command: localIntent.command, data: localResult.data || null });
|
|
9857
|
+
return;
|
|
9858
|
+
}
|
|
7847
9859
|
}
|
|
7848
9860
|
if (opts.offline) {
|
|
7849
9861
|
console.error('Modalita --offline: usa REPL interattiva e comandi locali (/help).');
|
|
7850
9862
|
process.exit(1);
|
|
7851
9863
|
}
|
|
7852
|
-
const data = await askServer(opts, opts
|
|
9864
|
+
const data = await askServer(opts, maybeAugmentPromptWithResumeContext(opts, rawPrompt));
|
|
7853
9865
|
printResponse(opts, data);
|
|
9866
|
+
appendTranscriptEvent(opts, 'assistant', { text: data?.message || JSON.stringify(data || {}) });
|
|
7854
9867
|
}
|
|
7855
9868
|
|
|
7856
9869
|
async function runRepl(opts) {
|
|
@@ -7872,8 +9885,8 @@ async function runRepl(opts) {
|
|
|
7872
9885
|
terminal: true,
|
|
7873
9886
|
completer: (line) => {
|
|
7874
9887
|
if (!String(line || '').startsWith('/')) return [[], line];
|
|
7875
|
-
const hits = getSlashCompletions(line);
|
|
7876
|
-
return [hits.length ? hits :
|
|
9888
|
+
const hits = getSlashCompletions(line, opts);
|
|
9889
|
+
return [hits.length ? hits : getAllSlashCommandRows(opts).map(([command]) => command), line];
|
|
7877
9890
|
}
|
|
7878
9891
|
});
|
|
7879
9892
|
const uninstallSlashMenu = installSlashMenu(rl, opts);
|
|
@@ -7912,6 +9925,13 @@ async function runRepl(opts) {
|
|
|
7912
9925
|
while (true) {
|
|
7913
9926
|
const line = (await question()).trim();
|
|
7914
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
|
+
}
|
|
7915
9935
|
pushCliHistory(opts, line);
|
|
7916
9936
|
try { saveCliWorkspaceState(opts); } catch (_err) { }
|
|
7917
9937
|
if (line === '/exit' || line === '/quit') break;
|
|
@@ -7937,26 +9957,38 @@ async function runRepl(opts) {
|
|
|
7937
9957
|
}
|
|
7938
9958
|
try {
|
|
7939
9959
|
const localResult = await handleLocalCommand(opts, line);
|
|
7940
|
-
if (localResult.handled)
|
|
9960
|
+
if (localResult.handled) {
|
|
9961
|
+
appendTranscriptEvent(opts, 'tool', { command: line, data: localResult.data || null });
|
|
9962
|
+
continue;
|
|
9963
|
+
}
|
|
7941
9964
|
} catch (err) {
|
|
7942
9965
|
console.error(`Local tool error: ${err.message}`);
|
|
9966
|
+
appendTranscriptEvent(opts, 'tool-error', { command: line, error: err.message });
|
|
7943
9967
|
continue;
|
|
7944
9968
|
}
|
|
7945
9969
|
const localIntent = classifyLocalPromptIntent(line);
|
|
7946
9970
|
if (localIntent?.command) {
|
|
7947
9971
|
try {
|
|
7948
9972
|
const localResult = await handleLocalCommand(opts, localIntent.command);
|
|
7949
|
-
if (localResult.handled)
|
|
9973
|
+
if (localResult.handled) {
|
|
9974
|
+
appendTranscriptEvent(opts, 'tool', { command: localIntent.command, sourcePrompt: line, data: localResult.data || null });
|
|
9975
|
+
continue;
|
|
9976
|
+
}
|
|
7950
9977
|
} catch (err) {
|
|
7951
9978
|
console.error(`Local tool error: ${err.message}`);
|
|
9979
|
+
appendTranscriptEvent(opts, 'tool-error', { command: localIntent.command, sourcePrompt: line, error: err.message });
|
|
7952
9980
|
continue;
|
|
7953
9981
|
}
|
|
7954
9982
|
}
|
|
7955
9983
|
try {
|
|
7956
9984
|
const naturalFileAction = await runNaturalLocalFileAction(opts, line);
|
|
7957
|
-
if (naturalFileAction.handled)
|
|
9985
|
+
if (naturalFileAction.handled) {
|
|
9986
|
+
appendTranscriptEvent(opts, 'tool', { command: 'natural-file-action', prompt: line, data: naturalFileAction.data || null });
|
|
9987
|
+
continue;
|
|
9988
|
+
}
|
|
7958
9989
|
} catch (err) {
|
|
7959
9990
|
console.error(`Local tool error: ${err.message}`);
|
|
9991
|
+
appendTranscriptEvent(opts, 'tool-error', { command: 'natural-file-action', prompt: line, error: err.message });
|
|
7960
9992
|
continue;
|
|
7961
9993
|
}
|
|
7962
9994
|
if (opts.offline) {
|
|
@@ -7964,10 +9996,12 @@ async function runRepl(opts) {
|
|
|
7964
9996
|
continue;
|
|
7965
9997
|
}
|
|
7966
9998
|
try {
|
|
7967
|
-
const data = await askServer(opts, line);
|
|
9999
|
+
const data = await askServer(opts, maybeAugmentPromptWithResumeContext(opts, line));
|
|
7968
10000
|
printResponse(opts, data);
|
|
10001
|
+
appendTranscriptEvent(opts, 'assistant', { text: data?.message || JSON.stringify(data || {}) });
|
|
7969
10002
|
} catch (err) {
|
|
7970
10003
|
console.error(err.message);
|
|
10004
|
+
appendTranscriptEvent(opts, 'assistant-error', { prompt: line, error: err.message });
|
|
7971
10005
|
}
|
|
7972
10006
|
}
|
|
7973
10007
|
|
|
@@ -8029,6 +10063,10 @@ async function main() {
|
|
|
8029
10063
|
opts.sessionReused = false;
|
|
8030
10064
|
}
|
|
8031
10065
|
|
|
10066
|
+
if (opts.resume) {
|
|
10067
|
+
await prepareResumeContext(opts);
|
|
10068
|
+
}
|
|
10069
|
+
|
|
8032
10070
|
if (opts.remoteControlServerMode) {
|
|
8033
10071
|
let remoteState = null;
|
|
8034
10072
|
if (opts.remoteControlCloud) {
|
|
@@ -8074,6 +10112,11 @@ async function main() {
|
|
|
8074
10112
|
return;
|
|
8075
10113
|
}
|
|
8076
10114
|
|
|
10115
|
+
if (opts.execMode) {
|
|
10116
|
+
await runExecMode(opts);
|
|
10117
|
+
return;
|
|
10118
|
+
}
|
|
10119
|
+
|
|
8077
10120
|
if (opts.prompt) {
|
|
8078
10121
|
await runSinglePrompt(opts);
|
|
8079
10122
|
return;
|
|
@@ -8103,6 +10146,24 @@ if (require.main === module) {
|
|
|
8103
10146
|
isRiskyStructuredToolCall,
|
|
8104
10147
|
diffCliToolReports,
|
|
8105
10148
|
extractResumableCallsFromReportPayload,
|
|
8106
|
-
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
|
|
8107
10168
|
};
|
|
8108
10169
|
}
|