memroot 0.1.0-alpha.0 → 0.1.0-alpha.3
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 +50 -3
- package/dist/index.js +2831 -250
- package/dist/plugins/claude/.claude-plugin/plugin.json +2 -2
- package/dist/plugins/claude/README.md +8 -4
- package/dist/plugins/claude/hooks/hooks.json +10 -0
- package/dist/plugins/claude/scripts/capture-worker.mjs +19294 -0
- package/dist/plugins/claude/scripts/session-end.mjs +469 -0
- package/dist/plugins/claude/scripts/session-start.mjs +372 -53
- package/dist/plugins/claude/scripts/status.mjs +152 -12
- package/dist/plugins/claude/skills/memroot-memory/SKILL.md +48 -0
- package/dist/plugins/claude/skills/memroot-memory/references/save.md +76 -0
- package/dist/plugins/claude/skills/memroot-status/SKILL.md +19 -0
- package/dist/plugins/codex/.codex-plugin/plugin.json +5 -5
- package/dist/plugins/codex/README.md +13 -7
- package/dist/plugins/codex/hooks/hooks.json +11 -0
- package/dist/plugins/codex/scripts/capture-worker.mjs +19294 -0
- package/dist/plugins/codex/scripts/session-end.mjs +469 -0
- package/dist/plugins/codex/scripts/session-start.mjs +372 -53
- package/dist/plugins/codex/scripts/status.mjs +152 -12
- package/dist/plugins/codex/skills/memroot-memory/SKILL.md +48 -0
- package/dist/plugins/codex/skills/memroot-memory/references/save.md +76 -0
- package/dist/plugins/codex/skills/memroot-status/SKILL.md +20 -0
- package/dist/plugins/grok/.claude-plugin/plugin.json +2 -2
- package/dist/plugins/grok/README.md +10 -4
- package/dist/plugins/grok/hooks/hooks.json +24 -1
- package/dist/plugins/grok/scripts/capture-worker.mjs +19294 -0
- package/dist/plugins/grok/scripts/session-end.mjs +469 -0
- package/dist/plugins/grok/scripts/session-reminder.mjs +275 -0
- package/dist/plugins/grok/scripts/status.mjs +152 -12
- package/dist/plugins/grok/skills/memroot-memory/SKILL.md +48 -0
- package/dist/plugins/grok/skills/memroot-memory/references/save.md +76 -0
- package/dist/plugins/grok/skills/memroot-status/SKILL.md +20 -0
- package/package.json +13 -4
- package/dist/plugins/claude/skills/status/SKILL.md +0 -18
- package/dist/plugins/codex/skills/status/SKILL.md +0 -18
- package/dist/plugins/grok/skills/status/SKILL.md +0 -18
package/dist/index.js
CHANGED
|
@@ -1127,160 +1127,183 @@ var SELECT_INSTRUCTIONS = [
|
|
|
1127
1127
|
];
|
|
1128
1128
|
var i = `${styleText("gray", S_BAR)} `;
|
|
1129
1129
|
|
|
1130
|
-
// src/
|
|
1130
|
+
// src/auth.ts
|
|
1131
|
+
import { spawn } from "node:child_process";
|
|
1132
|
+
import { createHash as createHash2, randomBytes as randomBytes3 } from "node:crypto";
|
|
1133
|
+
import { createServer, request as httpRequest2 } from "node:http";
|
|
1134
|
+
import { request as httpsRequest2 } from "node:https";
|
|
1135
|
+
|
|
1136
|
+
// src/capture/status.ts
|
|
1137
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
1138
|
+
import { homedir as homedir3 } from "node:os";
|
|
1139
|
+
import { isAbsolute as isAbsolute4, join as join9 } from "node:path";
|
|
1140
|
+
|
|
1141
|
+
// src/credentials.ts
|
|
1131
1142
|
import { spawnSync } from "node:child_process";
|
|
1132
|
-
import {
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
};
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1143
|
+
import {
|
|
1144
|
+
chmod as chmod2,
|
|
1145
|
+
lstat as lstat3,
|
|
1146
|
+
readFile as readFile3,
|
|
1147
|
+
rename as rename3,
|
|
1148
|
+
rm as rm3,
|
|
1149
|
+
writeFile as writeFile3
|
|
1150
|
+
} from "node:fs/promises";
|
|
1151
|
+
import { join as join4 } from "node:path";
|
|
1152
|
+
|
|
1153
|
+
// src/config.ts
|
|
1154
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1155
|
+
import {
|
|
1156
|
+
chmod,
|
|
1157
|
+
lstat as lstat2,
|
|
1158
|
+
readFile as readFile2,
|
|
1159
|
+
rename as rename2,
|
|
1160
|
+
rm as rm2,
|
|
1161
|
+
writeFile as writeFile2
|
|
1162
|
+
} from "node:fs/promises";
|
|
1163
|
+
import { join as join3 } from "node:path";
|
|
1164
|
+
|
|
1165
|
+
// src/api-base.ts
|
|
1166
|
+
function isLoopbackHostname(hostname) {
|
|
1167
|
+
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1" || hostname === "[::1]";
|
|
1168
|
+
}
|
|
1169
|
+
function isAllowedApiBase(value) {
|
|
1170
|
+
try {
|
|
1171
|
+
const url = new URL(value);
|
|
1172
|
+
if (url.username || url.password || url.search || url.hash) return false;
|
|
1173
|
+
if (url.protocol === "https:") return true;
|
|
1174
|
+
return url.protocol === "http:" && isLoopbackHostname(url.hostname);
|
|
1175
|
+
} catch {
|
|
1176
|
+
return false;
|
|
1152
1177
|
}
|
|
1153
|
-
return void 0;
|
|
1154
1178
|
}
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1179
|
+
|
|
1180
|
+
// src/capture/paths.ts
|
|
1181
|
+
import { randomBytes } from "node:crypto";
|
|
1182
|
+
import {
|
|
1183
|
+
chmodSync,
|
|
1184
|
+
closeSync,
|
|
1185
|
+
constants,
|
|
1186
|
+
fstatSync,
|
|
1187
|
+
lstatSync,
|
|
1188
|
+
mkdirSync,
|
|
1189
|
+
openSync,
|
|
1190
|
+
readdirSync,
|
|
1191
|
+
readFileSync,
|
|
1192
|
+
renameSync,
|
|
1193
|
+
rmSync,
|
|
1194
|
+
writeFileSync
|
|
1195
|
+
} from "node:fs";
|
|
1196
|
+
import { homedir } from "node:os";
|
|
1197
|
+
import { dirname, isAbsolute, join, normalize } from "node:path";
|
|
1198
|
+
var UnsafePathError = class extends Error {
|
|
1199
|
+
constructor(code = "UNSAFE_PATH") {
|
|
1200
|
+
super(`${code}: refusing an unexpected file type or symbolic link.`);
|
|
1201
|
+
this.code = code;
|
|
1166
1202
|
}
|
|
1167
|
-
|
|
1203
|
+
code;
|
|
1204
|
+
};
|
|
1205
|
+
function absoluteEnv(name) {
|
|
1206
|
+
const value = process.env[name];
|
|
1207
|
+
if (!value) return void 0;
|
|
1208
|
+
if (!isAbsolute(value))
|
|
1209
|
+
throw new Error(`INVALID_STATE_DIRECTORY: ${name} must be absolute.`);
|
|
1210
|
+
return normalize(value);
|
|
1168
1211
|
}
|
|
1169
|
-
function
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
];
|
|
1175
|
-
if (agent === "codex")
|
|
1176
|
-
return [
|
|
1177
|
-
["plugin", "marketplace", "add", marketplace, "--json"],
|
|
1178
|
-
["plugin", "add", "memroot@memroot", "--json"]
|
|
1179
|
-
];
|
|
1180
|
-
return [
|
|
1181
|
-
["plugin", "install", join(marketplace, "plugins", "memroot"), "--trust"]
|
|
1182
|
-
];
|
|
1212
|
+
function stateBoundary() {
|
|
1213
|
+
const explicit = absoluteEnv("MEMROOT_STATE_DIR");
|
|
1214
|
+
if (explicit) return { boundary: explicit, dir: explicit };
|
|
1215
|
+
const base = absoluteEnv("XDG_STATE_HOME") ?? join(homedir(), ".local", "state");
|
|
1216
|
+
return { boundary: base, dir: join(base, "memroot", "capture") };
|
|
1183
1217
|
}
|
|
1184
|
-
function
|
|
1185
|
-
|
|
1186
|
-
if (agent !== "grok")
|
|
1187
|
-
native(path, agent, ["plugin", "marketplace", "add", "--help"]);
|
|
1188
|
-
native(path, agent, [
|
|
1189
|
-
"plugin",
|
|
1190
|
-
agent === "codex" ? "add" : "install",
|
|
1191
|
-
"--help"
|
|
1192
|
-
]);
|
|
1218
|
+
function stateDir() {
|
|
1219
|
+
return stateBoundary().dir;
|
|
1193
1220
|
}
|
|
1194
|
-
function
|
|
1195
|
-
|
|
1221
|
+
function memrootConfigDir() {
|
|
1222
|
+
const override = process.env.MEMROOT_TEST_CONFIG_DIR;
|
|
1223
|
+
if (process.env.NODE_ENV === "test" && override && isAbsolute(override))
|
|
1224
|
+
return normalize(override);
|
|
1225
|
+
return join(homedir(), ".memroot");
|
|
1226
|
+
}
|
|
1227
|
+
function consentPath() {
|
|
1228
|
+
return join(memrootConfigDir(), "capture.json");
|
|
1229
|
+
}
|
|
1230
|
+
function assertRealDirectory(path) {
|
|
1231
|
+
const info = lstatSync(path);
|
|
1232
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new UnsafePathError();
|
|
1233
|
+
}
|
|
1234
|
+
function ensurePrivateDir(path, boundary, checkRoot = false) {
|
|
1235
|
+
const target = normalize(path);
|
|
1236
|
+
const root = normalize(boundary);
|
|
1237
|
+
if (target !== root && !target.startsWith(`${root}/`))
|
|
1238
|
+
throw new UnsafePathError();
|
|
1239
|
+
mkdirSync(target, { recursive: true, mode: 448 });
|
|
1240
|
+
let current = target;
|
|
1241
|
+
for (; ; ) {
|
|
1242
|
+
if (current === root) {
|
|
1243
|
+
if (checkRoot) assertRealDirectory(current);
|
|
1244
|
+
break;
|
|
1245
|
+
}
|
|
1246
|
+
assertRealDirectory(current);
|
|
1247
|
+
current = dirname(current);
|
|
1248
|
+
}
|
|
1249
|
+
const info = lstatSync(target);
|
|
1250
|
+
if ((info.mode & 511) !== 448) chmodSync(target, 448);
|
|
1251
|
+
}
|
|
1252
|
+
function stateSubdir(...parts) {
|
|
1253
|
+
const { boundary, dir } = stateBoundary();
|
|
1254
|
+
const path = join(dir, ...parts);
|
|
1255
|
+
ensurePrivateDir(path, boundary, boundary === dir);
|
|
1256
|
+
return path;
|
|
1257
|
+
}
|
|
1258
|
+
function readPrivateFile(path, maxBytes = 16 * 1024 * 1024) {
|
|
1259
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
1196
1260
|
try {
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
throw new
|
|
1200
|
-
|
|
1201
|
-
|
|
1261
|
+
const info = fstatSync(fd);
|
|
1262
|
+
if (!info.isFile()) throw new UnsafePathError();
|
|
1263
|
+
if (info.size > maxBytes) throw new UnsafePathError("FILE_TOO_LARGE");
|
|
1264
|
+
return readFileSync(fd, "utf8");
|
|
1265
|
+
} finally {
|
|
1266
|
+
closeSync(fd);
|
|
1202
1267
|
}
|
|
1203
|
-
if (field)
|
|
1204
|
-
value = value && typeof value === "object" ? value[field] : void 0;
|
|
1205
|
-
if (!Array.isArray(value) || value.some((row) => !row || typeof row !== "object" || Array.isArray(row)))
|
|
1206
|
-
throw new Error(
|
|
1207
|
-
"NATIVE_STATUS_UNSUPPORTED: Unrecognized agent plugin status; refusing to change it."
|
|
1208
|
-
);
|
|
1209
|
-
return value;
|
|
1210
1268
|
}
|
|
1211
|
-
function
|
|
1212
|
-
if (typeof left !== "string") return false;
|
|
1269
|
+
function readPrivateFileIfExists(path, maxBytes) {
|
|
1213
1270
|
try {
|
|
1214
|
-
return
|
|
1215
|
-
} catch {
|
|
1216
|
-
|
|
1271
|
+
return readPrivateFile(path, maxBytes);
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
if (error.code === "ENOENT") return void 0;
|
|
1274
|
+
if (error.code === "ELOOP")
|
|
1275
|
+
throw new UnsafePathError();
|
|
1276
|
+
throw error;
|
|
1217
1277
|
}
|
|
1218
1278
|
}
|
|
1219
|
-
function
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
);
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
if (!empty && (!lines.length || lines.some(
|
|
1227
|
-
(line) => !/^\s*\S+:\s+[^[]+\[.+\](?:\s+\(disabled\))?\s*$/.test(line)
|
|
1228
|
-
))) {
|
|
1229
|
-
throw new Error(
|
|
1230
|
-
"NATIVE_STATUS_UNSUPPORTED: Unrecognized Grok plugin list format. Update Grok and inspect its native plugin manager before retrying."
|
|
1231
|
-
);
|
|
1232
|
-
}
|
|
1233
|
-
const matching = output.split("\n").filter((line) => /:\s*memroot(?:\s|$)/.test(line));
|
|
1234
|
-
for (const line of matching) {
|
|
1235
|
-
const source = line.match(/\[local: (.*)\]/)?.[1];
|
|
1236
|
-
if (!samePath(source, join(marketplace, "plugins", "memroot")) && !(previousMarketplace && samePath(source, join(previousMarketplace, "plugins", "memroot"))))
|
|
1237
|
-
throw new Error(
|
|
1238
|
-
"PLUGIN_COLLISION: Grok already has a different Memroot plugin. Resolve it with the native plugin manager first."
|
|
1239
|
-
);
|
|
1240
|
-
if (/disabled/i.test(line))
|
|
1241
|
-
throw new Error(
|
|
1242
|
-
"PLUGIN_DISABLED: Memroot is disabled in Grok. Enable it in the native plugin manager when ready."
|
|
1243
|
-
);
|
|
1244
|
-
}
|
|
1245
|
-
return { registered: false, installed: matching.length > 0 };
|
|
1279
|
+
function writePrivateFileAtomic(path, content) {
|
|
1280
|
+
const temporary = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
1281
|
+
try {
|
|
1282
|
+
writeFileSync(temporary, content, { flag: "wx", mode: 384 });
|
|
1283
|
+
renameSync(temporary, path);
|
|
1284
|
+
} finally {
|
|
1285
|
+
rmSync(temporary, { force: true });
|
|
1246
1286
|
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
if (!samePath(source, marketplace) && !(previousMarketplace && samePath(source, previousMarketplace)))
|
|
1259
|
-
throw new Error(
|
|
1260
|
-
"MARKETPLACE_COLLISION: memroot is already registered from a different source. Resolve it with the native plugin manager first."
|
|
1261
|
-
);
|
|
1287
|
+
}
|
|
1288
|
+
function removeFile(path) {
|
|
1289
|
+
rmSync(path, { force: true });
|
|
1290
|
+
}
|
|
1291
|
+
function listFiles(path) {
|
|
1292
|
+
try {
|
|
1293
|
+
assertRealDirectory(path);
|
|
1294
|
+
return readdirSync(path).sort();
|
|
1295
|
+
} catch (error) {
|
|
1296
|
+
if (error.code === "ENOENT") return [];
|
|
1297
|
+
throw error;
|
|
1262
1298
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
for (const plugin of matchingPlugins) {
|
|
1271
|
-
if (plugin.enabled === false)
|
|
1272
|
-
throw new Error(
|
|
1273
|
-
`PLUGIN_DISABLED: Memroot is disabled in ${labels[agent]}. Enable it in the native plugin manager when ready; setup preserves your choice.`
|
|
1274
|
-
);
|
|
1275
|
-
if (plugin.enabled !== true || agent === "claude" && plugin.scope !== "user")
|
|
1276
|
-
throw new Error(
|
|
1277
|
-
"PLUGIN_COLLISION: Existing Memroot plugin status or scope cannot be safely managed. Check the native plugin manager."
|
|
1278
|
-
);
|
|
1299
|
+
}
|
|
1300
|
+
function pathExists(path) {
|
|
1301
|
+
try {
|
|
1302
|
+
lstatSync(path);
|
|
1303
|
+
return true;
|
|
1304
|
+
} catch {
|
|
1305
|
+
return false;
|
|
1279
1306
|
}
|
|
1280
|
-
return {
|
|
1281
|
-
registered: matchingMarkets.length > 0,
|
|
1282
|
-
installed: matchingPlugins.length > 0
|
|
1283
|
-
};
|
|
1284
1307
|
}
|
|
1285
1308
|
|
|
1286
1309
|
// src/storage.ts
|
|
@@ -1295,12 +1318,12 @@ import {
|
|
|
1295
1318
|
rm,
|
|
1296
1319
|
writeFile
|
|
1297
1320
|
} from "node:fs/promises";
|
|
1298
|
-
import { homedir } from "node:os";
|
|
1299
|
-
import { dirname, isAbsolute as isAbsolute2, join as join2 } from "node:path";
|
|
1321
|
+
import { homedir as homedir2 } from "node:os";
|
|
1322
|
+
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2 } from "node:path";
|
|
1300
1323
|
import { fileURLToPath } from "node:url";
|
|
1301
|
-
var version = "0.1.0-alpha.
|
|
1324
|
+
var version = "0.1.0-alpha.3";
|
|
1302
1325
|
function dataDirectory() {
|
|
1303
|
-
const base = process.platform === "win32" ? process.env.LOCALAPPDATA ?? join2(
|
|
1326
|
+
const base = process.platform === "win32" ? process.env.LOCALAPPDATA ?? join2(homedir2(), "AppData", "Local") : process.env.XDG_DATA_HOME ?? join2(homedir2(), ".local", "share");
|
|
1304
1327
|
if (!isAbsolute2(base))
|
|
1305
1328
|
throw new Error(
|
|
1306
1329
|
"INVALID_DATA_DIRECTORY: XDG_DATA_HOME or LOCALAPPDATA must be absolute."
|
|
@@ -1319,7 +1342,7 @@ async function exists(path) {
|
|
|
1319
1342
|
async function safeDirectory(path) {
|
|
1320
1343
|
const boundary = dataDirectory();
|
|
1321
1344
|
if (path !== boundary && path.startsWith(`${boundary}/`))
|
|
1322
|
-
await safeDirectory(
|
|
1345
|
+
await safeDirectory(dirname2(path));
|
|
1323
1346
|
await mkdir(path, { recursive: true, mode: 448 });
|
|
1324
1347
|
const info = await lstat(path);
|
|
1325
1348
|
if (!info.isDirectory() || info.isSymbolicLink())
|
|
@@ -1436,7 +1459,7 @@ async function stage(data, agent) {
|
|
|
1436
1459
|
});
|
|
1437
1460
|
if (agent !== "grok") {
|
|
1438
1461
|
const metadata = marketplaceMetadata(agent);
|
|
1439
|
-
await mkdir(
|
|
1462
|
+
await mkdir(dirname2(join2(temporary, metadata.path)), { recursive: true });
|
|
1440
1463
|
await writeFile(
|
|
1441
1464
|
join2(temporary, metadata.path),
|
|
1442
1465
|
`${JSON.stringify(metadata.value, null, 2)}
|
|
@@ -1483,111 +1506,2508 @@ async function acquireLock(data) {
|
|
|
1483
1506
|
};
|
|
1484
1507
|
}
|
|
1485
1508
|
|
|
1486
|
-
// src/
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
doctor Check installer receipts and plugin file integrity
|
|
1505
|
-
|
|
1506
|
-
Options:
|
|
1507
|
-
--agent claude, codex, or grok; repeat to select more than one
|
|
1508
|
-
--yes Use the explicitly selected agents without interactive prompts
|
|
1509
|
-
--dry-run Show planned changes without running agent CLIs or writing files
|
|
1510
|
-
--help Show help
|
|
1511
|
-
--version Show version
|
|
1512
|
-
|
|
1513
|
-
${cloudStatus}
|
|
1514
|
-
Grok setup trusts the selected local plugin via its native --trust option.
|
|
1515
|
-
Hook execution trust remains controlled by each agent.
|
|
1516
|
-
`;
|
|
1517
|
-
function parse(args) {
|
|
1518
|
-
const command = args.shift();
|
|
1519
|
-
if (command !== "setup" && command !== "doctor")
|
|
1509
|
+
// src/config.ts
|
|
1510
|
+
function configDirectory() {
|
|
1511
|
+
return memrootConfigDir();
|
|
1512
|
+
}
|
|
1513
|
+
function configPath() {
|
|
1514
|
+
return join3(configDirectory(), "config.json");
|
|
1515
|
+
}
|
|
1516
|
+
function optionalString(value) {
|
|
1517
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
1518
|
+
}
|
|
1519
|
+
function containsCredential(value) {
|
|
1520
|
+
if (!value || typeof value !== "object") return false;
|
|
1521
|
+
return Object.entries(value).some(
|
|
1522
|
+
([key, entry]) => /token|secret|password|credential|api[_-]?key/i.test(key) || containsCredential(entry)
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
async function migrateLegacy(record, raw) {
|
|
1526
|
+
if (!(typeof record.user_id === "string" || typeof record.email === "string") || containsCredential(record) || record.apiBase !== void 0 && (typeof record.apiBase !== "string" || !isAllowedApiBase(record.apiBase)))
|
|
1520
1527
|
throw new Error(
|
|
1521
|
-
"
|
|
1528
|
+
"INVALID_CONFIG: Legacy config cannot be safely migrated. No credentials were imported."
|
|
1522
1529
|
);
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
throw new Error("INVALID_AGENT: Use claude, codex, or grok.");
|
|
1532
|
-
if (!options.selected.includes(value))
|
|
1533
|
-
options.selected.push(value);
|
|
1534
|
-
} else
|
|
1530
|
+
await safeDirectory(configDirectory());
|
|
1531
|
+
const backup = `${configPath()}.legacy-backup`;
|
|
1532
|
+
try {
|
|
1533
|
+
await writeFile2(backup, raw, { flag: "wx", mode: 384 });
|
|
1534
|
+
} catch (error) {
|
|
1535
|
+
if (error.code !== "EEXIST") throw error;
|
|
1536
|
+
const info = await lstat2(backup);
|
|
1537
|
+
if (!info.isFile() || info.isSymbolicLink() || await readFile2(backup, "utf8") !== raw)
|
|
1535
1538
|
throw new Error(
|
|
1536
|
-
|
|
1539
|
+
"INVALID_CONFIG: Legacy backup differs or is unsafe. Original config was preserved."
|
|
1537
1540
|
);
|
|
1541
|
+
await chmod(backup, 384);
|
|
1538
1542
|
}
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
return
|
|
1543
|
+
const config = { schemaVersion: 1 };
|
|
1544
|
+
await writeConfig(config);
|
|
1545
|
+
return config;
|
|
1542
1546
|
}
|
|
1543
|
-
async function
|
|
1544
|
-
const
|
|
1545
|
-
let
|
|
1546
|
-
|
|
1547
|
-
const
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
}
|
|
1556
|
-
await verifyReceipt(receipt);
|
|
1557
|
-
if (path) {
|
|
1558
|
-
const state = inspectNative(path, agent, receipt.marketplace);
|
|
1559
|
-
if (!state.installed)
|
|
1560
|
-
throw new Error(
|
|
1561
|
-
"PLUGIN_MISSING: Native plugin installation is absent. Run memroot setup again."
|
|
1562
|
-
);
|
|
1563
|
-
}
|
|
1564
|
-
console.log(
|
|
1565
|
-
`${labels[agent]}: plugin files verified (${receipt.version}); CLI ${path ? "detected" : "not found"}. ${path ? "Native plugin present" : "Native presence not verified"}; hook execution trust remains controlled by the agent.`
|
|
1566
|
-
);
|
|
1567
|
-
if (!path) failures++;
|
|
1568
|
-
} catch (error) {
|
|
1569
|
-
failures++;
|
|
1570
|
-
console.error(
|
|
1571
|
-
`${labels[agent]}: ${error instanceof Error ? error.message : "check failed"}`
|
|
1572
|
-
);
|
|
1573
|
-
}
|
|
1547
|
+
async function readConfig() {
|
|
1548
|
+
const path = configPath();
|
|
1549
|
+
let raw;
|
|
1550
|
+
try {
|
|
1551
|
+
const info = await lstat2(path);
|
|
1552
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
1553
|
+
throw new Error("INVALID_CONFIG: Expected a regular config file.");
|
|
1554
|
+
raw = await readFile2(path, "utf8");
|
|
1555
|
+
} catch (error) {
|
|
1556
|
+
if (error.code === "ENOENT")
|
|
1557
|
+
return { schemaVersion: 1 };
|
|
1558
|
+
throw error;
|
|
1574
1559
|
}
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
if (process.platform !== "darwin" && process.platform !== "linux")
|
|
1560
|
+
let value;
|
|
1561
|
+
try {
|
|
1562
|
+
value = JSON.parse(raw);
|
|
1563
|
+
} catch {
|
|
1580
1564
|
throw new Error(
|
|
1581
|
-
"
|
|
1565
|
+
"INVALID_CONFIG: ~/.memroot/config.json is not valid JSON. Fix or remove it."
|
|
1582
1566
|
);
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
if (
|
|
1567
|
+
}
|
|
1568
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1569
|
+
throw new Error("INVALID_CONFIG: Cannot safely read CLI config.");
|
|
1570
|
+
const record = value;
|
|
1571
|
+
if (record.schemaVersion === void 0) return migrateLegacy(record, raw);
|
|
1572
|
+
const apiBase = optionalString(record.apiBase);
|
|
1573
|
+
if (record.schemaVersion !== 1 || apiBase && !isAllowedApiBase(apiBase))
|
|
1574
|
+
throw new Error("INVALID_CONFIG: Cannot safely read CLI config.");
|
|
1575
|
+
const config = { schemaVersion: 1 };
|
|
1576
|
+
if (apiBase) config.apiBase = apiBase;
|
|
1577
|
+
const clientId = optionalString(record.clientId);
|
|
1578
|
+
if (clientId) config.clientId = clientId;
|
|
1579
|
+
const clientIdApiBase = optionalString(record.clientIdApiBase);
|
|
1580
|
+
if (clientIdApiBase) config.clientIdApiBase = clientIdApiBase;
|
|
1581
|
+
const userId = optionalString(record.userId);
|
|
1582
|
+
if (userId) config.userId = userId;
|
|
1583
|
+
const mcp = record.mcpRegistered;
|
|
1584
|
+
if (mcp && typeof mcp === "object" && !Array.isArray(mcp)) {
|
|
1585
|
+
const registered = {};
|
|
1586
|
+
if (mcp.claude === true)
|
|
1587
|
+
registered.claude = true;
|
|
1588
|
+
if (mcp.codex === true)
|
|
1589
|
+
registered.codex = true;
|
|
1590
|
+
if (mcp.grok === true) registered.grok = true;
|
|
1591
|
+
if (registered.claude || registered.codex || registered.grok)
|
|
1592
|
+
config.mcpRegistered = registered;
|
|
1593
|
+
}
|
|
1594
|
+
return config;
|
|
1595
|
+
}
|
|
1596
|
+
async function writeConfig(config) {
|
|
1597
|
+
const directory = configDirectory();
|
|
1598
|
+
await safeDirectory(directory);
|
|
1599
|
+
const path = configPath();
|
|
1600
|
+
const temporary = `${path}.${randomUUID2()}.tmp`;
|
|
1601
|
+
try {
|
|
1602
|
+
await writeFile2(temporary, `${JSON.stringify(config, null, 2)}
|
|
1603
|
+
`, {
|
|
1604
|
+
flag: "wx",
|
|
1605
|
+
mode: 384
|
|
1606
|
+
});
|
|
1607
|
+
await rename2(temporary, path);
|
|
1608
|
+
await chmod(path, 384);
|
|
1609
|
+
} finally {
|
|
1610
|
+
await rm2(temporary, { force: true });
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// src/credentials.ts
|
|
1615
|
+
var SERVICE = "memroot";
|
|
1616
|
+
var TOOL_TIMEOUT_MS = 15e3;
|
|
1617
|
+
function credentialStoreKind() {
|
|
1618
|
+
const override = process.env.MEMROOT_TEST_CREDENTIAL_STORE;
|
|
1619
|
+
if (override === "file" && process.env.NODE_ENV === "test")
|
|
1620
|
+
return "test-file";
|
|
1621
|
+
if (override)
|
|
1622
|
+
throw new Error(
|
|
1623
|
+
"INVALID_CREDENTIAL_STORE: MEMROOT_TEST_CREDENTIAL_STORE=file is honored only when NODE_ENV=test; refusing to file-back credentials."
|
|
1624
|
+
);
|
|
1625
|
+
return process.platform === "darwin" ? "macos-keychain" : "libsecret";
|
|
1626
|
+
}
|
|
1627
|
+
function unavailable(tool) {
|
|
1628
|
+
return new Error(
|
|
1629
|
+
`AUTH_KEYCHAIN_UNAVAILABLE: ${tool} is not installed, so there is no OS credential store for the Memroot refresh token. Tokens are never written to files; install ${tool === "security" ? "the macOS security tool" : "libsecret's secret-tool"} or sign in on a supported system.`
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
function run(tool, args, input, allowNotFound = false) {
|
|
1633
|
+
const result = spawnSync(tool, args, {
|
|
1634
|
+
encoding: "utf8",
|
|
1635
|
+
input,
|
|
1636
|
+
timeout: TOOL_TIMEOUT_MS,
|
|
1637
|
+
shell: false,
|
|
1638
|
+
maxBuffer: 1024 * 1024
|
|
1639
|
+
});
|
|
1640
|
+
if (result.error) {
|
|
1641
|
+
if (result.error.code === "ENOENT")
|
|
1642
|
+
throw unavailable(tool);
|
|
1643
|
+
throw new Error(
|
|
1644
|
+
`AUTH_KEYCHAIN_FAILED: ${tool} could not run (${result.error.code ?? "error"}).`
|
|
1645
|
+
);
|
|
1646
|
+
}
|
|
1647
|
+
if (allowNotFound) return result;
|
|
1648
|
+
if (result.status !== 0)
|
|
1649
|
+
throw new Error(
|
|
1650
|
+
`AUTH_KEYCHAIN_FAILED: ${tool} ${args[0] ?? ""} failed (${result.status ?? "signal"}). No credential was read or written.`
|
|
1651
|
+
);
|
|
1652
|
+
return result;
|
|
1653
|
+
}
|
|
1654
|
+
function quote(value) {
|
|
1655
|
+
return JSON.stringify(value);
|
|
1656
|
+
}
|
|
1657
|
+
function testStorePath() {
|
|
1658
|
+
return join4(configDirectory(), "test-credentials.json");
|
|
1659
|
+
}
|
|
1660
|
+
async function readTestStore() {
|
|
1661
|
+
try {
|
|
1662
|
+
const info = await lstat3(testStorePath());
|
|
1663
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
1664
|
+
throw new Error("INVALID_CREDENTIAL_STORE: Expected a regular file.");
|
|
1665
|
+
const value = JSON.parse(await readFile3(testStorePath(), "utf8"));
|
|
1666
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
1667
|
+
return value;
|
|
1668
|
+
} catch (error) {
|
|
1669
|
+
if (error.code === "ENOENT") return {};
|
|
1670
|
+
throw error;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
async function writeTestStore(entries) {
|
|
1674
|
+
const directory = configDirectory();
|
|
1675
|
+
await safeDirectory(directory);
|
|
1676
|
+
const path = testStorePath();
|
|
1677
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
1678
|
+
try {
|
|
1679
|
+
await writeFile3(temporary, `${JSON.stringify(entries, null, 2)}
|
|
1680
|
+
`, {
|
|
1681
|
+
flag: "wx",
|
|
1682
|
+
mode: 384
|
|
1683
|
+
});
|
|
1684
|
+
await rename3(temporary, path);
|
|
1685
|
+
await chmod2(path, 384);
|
|
1686
|
+
} finally {
|
|
1687
|
+
await rm3(temporary, { force: true });
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
function assertCredentialStoreAvailable() {
|
|
1691
|
+
const kind = credentialStoreKind();
|
|
1692
|
+
if (kind === "test-file") return;
|
|
1693
|
+
if (kind === "macos-keychain")
|
|
1694
|
+
run(
|
|
1695
|
+
"security",
|
|
1696
|
+
["find-generic-password", "-s", SERVICE, "-a", "memroot:probe"],
|
|
1697
|
+
void 0,
|
|
1698
|
+
true
|
|
1699
|
+
);
|
|
1700
|
+
else
|
|
1701
|
+
run(
|
|
1702
|
+
"secret-tool",
|
|
1703
|
+
["lookup", "service", SERVICE, "account", "memroot:probe"],
|
|
1704
|
+
void 0,
|
|
1705
|
+
true
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
async function storeSecret(account, secret) {
|
|
1709
|
+
const kind = credentialStoreKind();
|
|
1710
|
+
if (kind === "test-file") {
|
|
1711
|
+
const entries = await readTestStore();
|
|
1712
|
+
entries[account] = secret;
|
|
1713
|
+
await writeTestStore(entries);
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
if (kind === "macos-keychain") {
|
|
1717
|
+
run(
|
|
1718
|
+
"security",
|
|
1719
|
+
["-i"],
|
|
1720
|
+
`add-generic-password -U -s ${quote(SERVICE)} -a ${quote(account)} -w ${quote(secret)}
|
|
1721
|
+
`
|
|
1722
|
+
);
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
run(
|
|
1726
|
+
"secret-tool",
|
|
1727
|
+
["store", "--label", "Memroot CLI", "service", SERVICE, "account", account],
|
|
1728
|
+
secret
|
|
1729
|
+
);
|
|
1730
|
+
}
|
|
1731
|
+
async function readSecret(account) {
|
|
1732
|
+
const kind = credentialStoreKind();
|
|
1733
|
+
if (kind === "test-file") return (await readTestStore())[account];
|
|
1734
|
+
if (kind === "macos-keychain") {
|
|
1735
|
+
const result2 = run(
|
|
1736
|
+
"security",
|
|
1737
|
+
["find-generic-password", "-s", SERVICE, "-a", account, "-w"],
|
|
1738
|
+
void 0,
|
|
1739
|
+
true
|
|
1740
|
+
);
|
|
1741
|
+
if (result2.status !== 0) {
|
|
1742
|
+
if (/could not be found|item not found/i.test(result2.stderr))
|
|
1743
|
+
return void 0;
|
|
1744
|
+
throw new Error(
|
|
1745
|
+
`AUTH_KEYCHAIN_FAILED: security find-generic-password failed (${result2.status ?? "signal"}).`
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
const secret2 = result2.stdout.replace(/\n$/, "");
|
|
1749
|
+
return secret2.length ? secret2 : void 0;
|
|
1750
|
+
}
|
|
1751
|
+
const result = run(
|
|
1752
|
+
"secret-tool",
|
|
1753
|
+
["lookup", "service", SERVICE, "account", account],
|
|
1754
|
+
void 0,
|
|
1755
|
+
true
|
|
1756
|
+
);
|
|
1757
|
+
if (result.status !== 0) return void 0;
|
|
1758
|
+
const secret = result.stdout.replace(/\n$/, "");
|
|
1759
|
+
return secret.length ? secret : void 0;
|
|
1760
|
+
}
|
|
1761
|
+
async function deleteSecret(account) {
|
|
1762
|
+
const kind = credentialStoreKind();
|
|
1763
|
+
if (kind === "test-file") {
|
|
1764
|
+
const entries = await readTestStore();
|
|
1765
|
+
if (account in entries) {
|
|
1766
|
+
delete entries[account];
|
|
1767
|
+
await writeTestStore(entries);
|
|
1768
|
+
}
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
if (kind === "macos-keychain") {
|
|
1772
|
+
const result = run(
|
|
1773
|
+
"security",
|
|
1774
|
+
["delete-generic-password", "-s", SERVICE, "-a", account],
|
|
1775
|
+
void 0,
|
|
1776
|
+
true
|
|
1777
|
+
);
|
|
1778
|
+
if (result.status !== 0 && !/could not be found|item not found/i.test(result.stderr))
|
|
1779
|
+
throw new Error(
|
|
1780
|
+
`AUTH_KEYCHAIN_FAILED: security delete-generic-password failed (${result.status ?? "signal"}).`
|
|
1781
|
+
);
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
run(
|
|
1785
|
+
"secret-tool",
|
|
1786
|
+
["clear", "service", SERVICE, "account", account],
|
|
1787
|
+
void 0,
|
|
1788
|
+
true
|
|
1789
|
+
);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// src/capture/consent.ts
|
|
1793
|
+
import { dirname as dirname3 } from "node:path";
|
|
1794
|
+
var CAPTURE_AGENTS = ["claude", "codex", "grok"];
|
|
1795
|
+
var CONSENT_TEXT_VERSION = "capture-consent-v2";
|
|
1796
|
+
var DEFAULT_MAX_ITEMS_PER_SESSION = 3;
|
|
1797
|
+
var DEFAULT_MAX_ITEMS_PER_DAY = 10;
|
|
1798
|
+
var KEYS = [
|
|
1799
|
+
"schemaVersion",
|
|
1800
|
+
"enabled",
|
|
1801
|
+
"enabledAt",
|
|
1802
|
+
"consentTextVersion",
|
|
1803
|
+
"apiBase",
|
|
1804
|
+
"agents",
|
|
1805
|
+
"crossAgentExtraction",
|
|
1806
|
+
"includeHeadlessSessions",
|
|
1807
|
+
"maxItemsPerSession",
|
|
1808
|
+
"maxItemsPerDay",
|
|
1809
|
+
"models"
|
|
1810
|
+
];
|
|
1811
|
+
var MODEL = /^[A-Za-z0-9._:/@+-]{1,128}$/;
|
|
1812
|
+
function isInt(value, min, max) {
|
|
1813
|
+
return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
|
|
1814
|
+
}
|
|
1815
|
+
function parseConsent(value) {
|
|
1816
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1817
|
+
return void 0;
|
|
1818
|
+
const record = value;
|
|
1819
|
+
const keys = Object.keys(record);
|
|
1820
|
+
if (keys.some((key) => !KEYS.includes(key)) || keys.length !== KEYS.length)
|
|
1821
|
+
return void 0;
|
|
1822
|
+
const agents2 = record.agents;
|
|
1823
|
+
const models = record.models;
|
|
1824
|
+
if (record.schemaVersion !== 1 || typeof record.enabled !== "boolean" || typeof record.enabledAt !== "string" || Number.isNaN(Date.parse(record.enabledAt)) || record.consentTextVersion !== CONSENT_TEXT_VERSION || typeof record.apiBase !== "string" || !isAllowedApiBase(record.apiBase) || record.apiBase.endsWith("/") || !Array.isArray(agents2) || agents2.length === 0 || agents2.length > CAPTURE_AGENTS.length || new Set(agents2).size !== agents2.length || agents2.some((agent) => !CAPTURE_AGENTS.includes(agent)) || typeof record.crossAgentExtraction !== "boolean" || typeof record.includeHeadlessSessions !== "boolean" || !isInt(record.maxItemsPerSession, 1, 5) || !isInt(record.maxItemsPerDay, 1, 20) || !models || typeof models !== "object" || Array.isArray(models))
|
|
1825
|
+
return void 0;
|
|
1826
|
+
const modelRecord = models;
|
|
1827
|
+
const modelKeys = Object.keys(modelRecord);
|
|
1828
|
+
if (modelKeys.length !== CAPTURE_AGENTS.length || modelKeys.some((key) => !CAPTURE_AGENTS.includes(key)) || Object.values(modelRecord).some(
|
|
1829
|
+
(model) => model !== null && (typeof model !== "string" || !MODEL.test(model))
|
|
1830
|
+
))
|
|
1831
|
+
return void 0;
|
|
1832
|
+
return record;
|
|
1833
|
+
}
|
|
1834
|
+
function readConsent() {
|
|
1835
|
+
let raw;
|
|
1836
|
+
try {
|
|
1837
|
+
raw = readPrivateFileIfExists(consentPath(), 64 * 1024);
|
|
1838
|
+
} catch {
|
|
1839
|
+
return { kind: "invalid" };
|
|
1840
|
+
}
|
|
1841
|
+
if (raw === void 0) return { kind: "absent" };
|
|
1842
|
+
try {
|
|
1843
|
+
const consent = parseConsent(JSON.parse(raw));
|
|
1844
|
+
return consent ? { kind: "ok", consent } : { kind: "invalid" };
|
|
1845
|
+
} catch {
|
|
1846
|
+
return { kind: "invalid" };
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
function writeConsent(consent) {
|
|
1850
|
+
if (!parseConsent(consent))
|
|
1851
|
+
throw new Error("INVALID_CONSENT: Refusing to write invalid consent.");
|
|
1852
|
+
ensurePrivateDir(memrootConfigDir(), dirname3(memrootConfigDir()), false);
|
|
1853
|
+
writePrivateFileAtomic(
|
|
1854
|
+
consentPath(),
|
|
1855
|
+
`${JSON.stringify(consent, null, 2)}
|
|
1856
|
+
`
|
|
1857
|
+
);
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// src/capture/extract/run.ts
|
|
1861
|
+
import { accessSync, constants as constants2, statSync } from "node:fs";
|
|
1862
|
+
import { delimiter, isAbsolute as isAbsolute3, join as join5 } from "node:path";
|
|
1863
|
+
var MAX_OUTPUT_BYTES = 256 * 1024;
|
|
1864
|
+
var MAX_STDERR_BYTES = 8 * 1024;
|
|
1865
|
+
function findExecutable(name) {
|
|
1866
|
+
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
|
1867
|
+
if (!isAbsolute3(directory)) continue;
|
|
1868
|
+
const path = join5(directory, name);
|
|
1869
|
+
try {
|
|
1870
|
+
accessSync(path, constants2.X_OK);
|
|
1871
|
+
if (statSync(path).isFile()) return path;
|
|
1872
|
+
} catch {
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
return void 0;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// src/capture/queue/job.ts
|
|
1879
|
+
import { join as join6 } from "node:path";
|
|
1880
|
+
var JOB_STATES = [
|
|
1881
|
+
"pending",
|
|
1882
|
+
"extracted",
|
|
1883
|
+
"quota_blocked",
|
|
1884
|
+
"auth_required",
|
|
1885
|
+
"server_unsupported",
|
|
1886
|
+
"runner_unavailable"
|
|
1887
|
+
];
|
|
1888
|
+
var JOB_ID = /^(claude|codex|grok)-[a-f0-9]{32}$/;
|
|
1889
|
+
var HEX64 = /^[a-f0-9]{64}$/;
|
|
1890
|
+
function queueDir() {
|
|
1891
|
+
return stateSubdir("queue");
|
|
1892
|
+
}
|
|
1893
|
+
function jobPath(jobId) {
|
|
1894
|
+
return join6(queueDir(), `${jobId}.json`);
|
|
1895
|
+
}
|
|
1896
|
+
function parseJob(value) {
|
|
1897
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1898
|
+
return void 0;
|
|
1899
|
+
const job = value;
|
|
1900
|
+
const optionalTime = (field) => field === null || typeof field === "string" && !Number.isNaN(Date.parse(field));
|
|
1901
|
+
if (job.schemaVersion !== 1 || typeof job.jobId !== "string" || !JOB_ID.test(job.jobId) || !CAPTURE_AGENTS.includes(job.agent) || !job.jobId.startsWith(`${job.agent}-`) || typeof job.sessionId !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(job.sessionId) || typeof job.sessionRef !== "string" || !HEX64.test(job.sessionRef) || job.jobId !== `${job.agent}-${job.sessionRef.slice(0, 32)}` || !(job.transcriptPath === null || typeof job.transcriptPath === "string") || typeof job.cwd !== "string" || !job.cwd.startsWith("/") || typeof job.reason !== "string" || !optionalTime(job.endedAt) || job.endedAt === null || !optionalTime(job.createdAt) || job.createdAt === null || !JOB_STATES.includes(job.state) || typeof job.attempts !== "number" || !Number.isInteger(job.attempts) || job.attempts < 0 || !optionalTime(job.nextAttemptAt) || !(job.lastError === null || typeof job.lastError === "string"))
|
|
1902
|
+
return void 0;
|
|
1903
|
+
return job;
|
|
1904
|
+
}
|
|
1905
|
+
function readJob(jobId) {
|
|
1906
|
+
const raw = readPrivateFileIfExists(jobPath(jobId), 64 * 1024);
|
|
1907
|
+
if (raw === void 0) return void 0;
|
|
1908
|
+
try {
|
|
1909
|
+
return parseJob(JSON.parse(raw));
|
|
1910
|
+
} catch {
|
|
1911
|
+
return void 0;
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
function deleteJob(jobId) {
|
|
1915
|
+
removeFile(jobPath(jobId));
|
|
1916
|
+
}
|
|
1917
|
+
function listJobIds() {
|
|
1918
|
+
return listFiles(queueDir()).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -5)).filter((id) => JOB_ID.test(id));
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
// src/capture/queue/ledger.ts
|
|
1922
|
+
import { join as join7 } from "node:path";
|
|
1923
|
+
var MAX_TITLE_HASHES = 200;
|
|
1924
|
+
var UPLOAD_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
1925
|
+
var HEX642 = /^[a-f0-9]{64}$/;
|
|
1926
|
+
function empty() {
|
|
1927
|
+
return {
|
|
1928
|
+
schemaVersion: 1,
|
|
1929
|
+
cursors: {},
|
|
1930
|
+
uploads: [],
|
|
1931
|
+
titleHashes: [],
|
|
1932
|
+
project: null,
|
|
1933
|
+
lastRun: null
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
function ledgerPath() {
|
|
1937
|
+
return join7(stateSubdir(), "ledger.json");
|
|
1938
|
+
}
|
|
1939
|
+
function sanitize(value) {
|
|
1940
|
+
const ledger = empty();
|
|
1941
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1942
|
+
return ledger;
|
|
1943
|
+
const record = value;
|
|
1944
|
+
if (record.schemaVersion !== 1) return ledger;
|
|
1945
|
+
if (record.cursors && typeof record.cursors === "object")
|
|
1946
|
+
for (const [key, entry] of Object.entries(
|
|
1947
|
+
record.cursors
|
|
1948
|
+
)) {
|
|
1949
|
+
const cursor3 = entry;
|
|
1950
|
+
if (HEX642.test(key) && typeof cursor3?.turns === "number" && Number.isInteger(cursor3.turns) && cursor3.turns >= 0 && typeof cursor3.updatedAt === "string")
|
|
1951
|
+
ledger.cursors[key] = {
|
|
1952
|
+
turns: cursor3.turns,
|
|
1953
|
+
updatedAt: cursor3.updatedAt
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1956
|
+
if (Array.isArray(record.uploads))
|
|
1957
|
+
for (const entry of record.uploads) {
|
|
1958
|
+
const upload = entry;
|
|
1959
|
+
if (typeof upload?.at === "string" && !Number.isNaN(Date.parse(upload.at)) && typeof upload.titleHash === "string" && HEX642.test(upload.titleHash))
|
|
1960
|
+
ledger.uploads.push({
|
|
1961
|
+
at: upload.at,
|
|
1962
|
+
titleHash: upload.titleHash,
|
|
1963
|
+
memoryId: typeof upload.memoryId === "string" && /^[A-Za-z0-9._:-]{1,256}$/.test(upload.memoryId) ? upload.memoryId : null
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
if (Array.isArray(record.titleHashes))
|
|
1967
|
+
ledger.titleHashes = record.titleHashes.filter(
|
|
1968
|
+
(hash) => typeof hash === "string" && HEX642.test(hash)
|
|
1969
|
+
).slice(-MAX_TITLE_HASHES);
|
|
1970
|
+
const project = record.project;
|
|
1971
|
+
if (project && typeof project.apiBase === "string" && typeof project.projectId === "string" && /^[A-Za-z0-9-]{1,128}$/.test(project.projectId))
|
|
1972
|
+
ledger.project = { apiBase: project.apiBase, projectId: project.projectId };
|
|
1973
|
+
const lastRun = record.lastRun;
|
|
1974
|
+
if (lastRun && typeof lastRun.startedAt === "string" && typeof lastRun.finishedAt === "string" && lastRun.outcomes && typeof lastRun.outcomes === "object") {
|
|
1975
|
+
const outcomes = {};
|
|
1976
|
+
for (const [key, count] of Object.entries(lastRun.outcomes))
|
|
1977
|
+
if (/^[a-z_]{1,40}$/.test(key) && typeof count === "number")
|
|
1978
|
+
outcomes[key] = count;
|
|
1979
|
+
ledger.lastRun = {
|
|
1980
|
+
startedAt: lastRun.startedAt,
|
|
1981
|
+
finishedAt: lastRun.finishedAt,
|
|
1982
|
+
outcomes
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
return ledger;
|
|
1986
|
+
}
|
|
1987
|
+
function readLedger() {
|
|
1988
|
+
try {
|
|
1989
|
+
const raw = readPrivateFileIfExists(ledgerPath(), 4 * 1024 * 1024);
|
|
1990
|
+
return raw === void 0 ? empty() : sanitize(JSON.parse(raw));
|
|
1991
|
+
} catch {
|
|
1992
|
+
return empty();
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
function uploadsInLastDay(ledger, now = Date.now()) {
|
|
1996
|
+
return ledger.uploads.filter(
|
|
1997
|
+
(upload) => now - Date.parse(upload.at) < 24 * 60 * 60 * 1e3
|
|
1998
|
+
).length;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// src/capture/upload/http.ts
|
|
2002
|
+
import { request as httpRequest } from "node:http";
|
|
2003
|
+
import { request as httpsRequest } from "node:https";
|
|
2004
|
+
var HTTP_TIMEOUT_MS = 3e4;
|
|
2005
|
+
var MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
2006
|
+
var TransportError = class extends Error {
|
|
2007
|
+
constructor(code) {
|
|
2008
|
+
super(code);
|
|
2009
|
+
this.code = code;
|
|
2010
|
+
}
|
|
2011
|
+
code;
|
|
2012
|
+
};
|
|
2013
|
+
function httpPost(target, headers, payload, timeoutMs = HTTP_TIMEOUT_MS) {
|
|
2014
|
+
const transport = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
2015
|
+
return new Promise((resolve, reject) => {
|
|
2016
|
+
const req = transport(
|
|
2017
|
+
target,
|
|
2018
|
+
{
|
|
2019
|
+
method: "POST",
|
|
2020
|
+
agent: false,
|
|
2021
|
+
timeout: timeoutMs,
|
|
2022
|
+
headers: { ...headers, "content-length": Buffer.byteLength(payload) }
|
|
2023
|
+
},
|
|
2024
|
+
(response) => {
|
|
2025
|
+
const chunks = [];
|
|
2026
|
+
let size = 0;
|
|
2027
|
+
response.on("data", (chunk) => {
|
|
2028
|
+
size += chunk.length;
|
|
2029
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
2030
|
+
req.destroy(new TransportError("response_too_large"));
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
chunks.push(chunk);
|
|
2034
|
+
});
|
|
2035
|
+
response.on(
|
|
2036
|
+
"end",
|
|
2037
|
+
() => resolve({
|
|
2038
|
+
status: response.statusCode ?? 0,
|
|
2039
|
+
headers: response.headers,
|
|
2040
|
+
body: Buffer.concat(chunks).toString("utf8")
|
|
2041
|
+
})
|
|
2042
|
+
);
|
|
2043
|
+
response.on("error", () => reject(new TransportError("network_error")));
|
|
2044
|
+
}
|
|
2045
|
+
);
|
|
2046
|
+
req.on("timeout", () => req.destroy(new TransportError("timeout")));
|
|
2047
|
+
req.on(
|
|
2048
|
+
"error",
|
|
2049
|
+
(error) => reject(
|
|
2050
|
+
error instanceof TransportError ? error : new TransportError("network_error")
|
|
2051
|
+
)
|
|
2052
|
+
);
|
|
2053
|
+
req.end(payload);
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
// src/capture/queue/lock.ts
|
|
2058
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
2059
|
+
import {
|
|
2060
|
+
lstatSync as lstatSync2,
|
|
2061
|
+
mkdirSync as mkdirSync2,
|
|
2062
|
+
readFileSync as readFileSync2,
|
|
2063
|
+
renameSync as renameSync2,
|
|
2064
|
+
rmSync as rmSync2,
|
|
2065
|
+
writeFileSync as writeFileSync2
|
|
2066
|
+
} from "node:fs";
|
|
2067
|
+
import { join as join8 } from "node:path";
|
|
2068
|
+
function lockPath(name) {
|
|
2069
|
+
return join8(stateSubdir("locks"), name);
|
|
2070
|
+
}
|
|
2071
|
+
function processAlive(pid) {
|
|
2072
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
2073
|
+
try {
|
|
2074
|
+
process.kill(pid, 0);
|
|
2075
|
+
return true;
|
|
2076
|
+
} catch (error) {
|
|
2077
|
+
return error.code === "EPERM";
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
function readOwner(path) {
|
|
2081
|
+
try {
|
|
2082
|
+
const value = JSON.parse(
|
|
2083
|
+
readFileSync2(join8(path, "owner.json"), "utf8")
|
|
2084
|
+
);
|
|
2085
|
+
const createdAt = typeof value.createdAt === "string" ? Date.parse(value.createdAt) : NaN;
|
|
2086
|
+
if (typeof value.pid !== "number" || Number.isNaN(createdAt))
|
|
2087
|
+
return void 0;
|
|
2088
|
+
const heartbeat = typeof value.heartbeatAt === "string" ? Date.parse(value.heartbeatAt) : NaN;
|
|
2089
|
+
return {
|
|
2090
|
+
pid: value.pid,
|
|
2091
|
+
createdAt,
|
|
2092
|
+
heartbeatAt: Number.isNaN(heartbeat) ? createdAt : heartbeat
|
|
2093
|
+
};
|
|
2094
|
+
} catch {
|
|
2095
|
+
return void 0;
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
function isStale(path, staleMs) {
|
|
2099
|
+
let info;
|
|
2100
|
+
try {
|
|
2101
|
+
info = lstatSync2(path);
|
|
2102
|
+
} catch {
|
|
2103
|
+
return false;
|
|
2104
|
+
}
|
|
2105
|
+
if (info.isSymbolicLink() || !info.isDirectory()) return true;
|
|
2106
|
+
const owner = readOwner(path);
|
|
2107
|
+
if (!owner) return Date.now() - info.mtimeMs > staleMs;
|
|
2108
|
+
if (!processAlive(owner.pid)) return true;
|
|
2109
|
+
return Date.now() - owner.heartbeatAt > staleMs;
|
|
2110
|
+
}
|
|
2111
|
+
var held = /* @__PURE__ */ new Map();
|
|
2112
|
+
function tryMkdir(path) {
|
|
2113
|
+
try {
|
|
2114
|
+
mkdirSync2(path, { mode: 448 });
|
|
2115
|
+
} catch (error) {
|
|
2116
|
+
if (error.code === "EEXIST") return false;
|
|
2117
|
+
throw error;
|
|
2118
|
+
}
|
|
2119
|
+
const token = randomBytes2(8).toString("hex");
|
|
2120
|
+
held.set(path, { ino: lstatSync2(path).ino, token });
|
|
2121
|
+
writeOwner(path, (/* @__PURE__ */ new Date()).toISOString(), token);
|
|
2122
|
+
return true;
|
|
2123
|
+
}
|
|
2124
|
+
function writeOwner(path, createdAt, token) {
|
|
2125
|
+
const temporary = join8(path, `owner.${process.pid}.${token}.tmp`);
|
|
2126
|
+
writeFileSync2(
|
|
2127
|
+
temporary,
|
|
2128
|
+
JSON.stringify({
|
|
2129
|
+
pid: process.pid,
|
|
2130
|
+
createdAt,
|
|
2131
|
+
heartbeatAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2132
|
+
token
|
|
2133
|
+
}),
|
|
2134
|
+
{ mode: 384 }
|
|
2135
|
+
);
|
|
2136
|
+
renameSync2(temporary, join8(path, "owner.json"));
|
|
2137
|
+
}
|
|
2138
|
+
function readToken(path) {
|
|
2139
|
+
try {
|
|
2140
|
+
const value = JSON.parse(
|
|
2141
|
+
readFileSync2(join8(path, "owner.json"), "utf8")
|
|
2142
|
+
);
|
|
2143
|
+
return typeof value.token === "string" ? value.token : void 0;
|
|
2144
|
+
} catch {
|
|
2145
|
+
return void 0;
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
function stillOurs(path) {
|
|
2149
|
+
const mine = held.get(path);
|
|
2150
|
+
if (!mine) return false;
|
|
2151
|
+
try {
|
|
2152
|
+
return lstatSync2(path).ino === mine.ino && readToken(path) === mine.token;
|
|
2153
|
+
} catch {
|
|
2154
|
+
return false;
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
function clearStale(path, staleMs) {
|
|
2158
|
+
const tombstone = `${path}.stale-${process.pid}-${randomBytes2(4).toString("hex")}`;
|
|
2159
|
+
try {
|
|
2160
|
+
renameSync2(path, tombstone);
|
|
2161
|
+
} catch {
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
if (!isStale(tombstone, staleMs)) {
|
|
2165
|
+
try {
|
|
2166
|
+
renameSync2(tombstone, path);
|
|
2167
|
+
return;
|
|
2168
|
+
} catch {
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
rmSync2(tombstone, { recursive: true, force: true });
|
|
2172
|
+
}
|
|
2173
|
+
async function acquireLock2(name, options) {
|
|
2174
|
+
const path = lockPath(name);
|
|
2175
|
+
const deadline = Date.now() + (options.waitMs ?? 0);
|
|
2176
|
+
let cleanups = 0;
|
|
2177
|
+
for (; ; ) {
|
|
2178
|
+
if (tryMkdir(path)) {
|
|
2179
|
+
let released = false;
|
|
2180
|
+
return () => {
|
|
2181
|
+
if (released) return;
|
|
2182
|
+
released = true;
|
|
2183
|
+
if (stillOurs(path)) rmSync2(path, { recursive: true, force: true });
|
|
2184
|
+
held.delete(path);
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
if (cleanups < 3 && isStale(path, options.staleMs)) {
|
|
2188
|
+
cleanups++;
|
|
2189
|
+
clearStale(path, options.staleMs);
|
|
2190
|
+
continue;
|
|
2191
|
+
}
|
|
2192
|
+
if (Date.now() >= deadline) return void 0;
|
|
2193
|
+
await new Promise((resolve) => setTimeout(resolve, options.pollMs ?? 100));
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
// src/capture/upload/token-broker.ts
|
|
2198
|
+
var MCP_CLIENT_ID = "memroot-cli";
|
|
2199
|
+
var REFRESH_SKEW_MS = 3e4;
|
|
2200
|
+
var AuthRequiredError = class extends Error {
|
|
2201
|
+
constructor() {
|
|
2202
|
+
super("AUTH_REQUIRED");
|
|
2203
|
+
}
|
|
2204
|
+
};
|
|
2205
|
+
function mcpAccount(apiBase) {
|
|
2206
|
+
return `${apiBase}#mcp`;
|
|
2207
|
+
}
|
|
2208
|
+
function mcpResource(apiBase) {
|
|
2209
|
+
return `${apiBase}/mcp`;
|
|
2210
|
+
}
|
|
2211
|
+
var TokenBroker = class {
|
|
2212
|
+
constructor(apiBase) {
|
|
2213
|
+
this.apiBase = apiBase;
|
|
2214
|
+
}
|
|
2215
|
+
apiBase;
|
|
2216
|
+
cached;
|
|
2217
|
+
invalidate() {
|
|
2218
|
+
this.cached = void 0;
|
|
2219
|
+
}
|
|
2220
|
+
async accessToken() {
|
|
2221
|
+
if (this.cached && this.cached.expiresAt - Date.now() > REFRESH_SKEW_MS)
|
|
2222
|
+
return this.cached.token;
|
|
2223
|
+
const release = await acquireLock2("auth.lock", {
|
|
2224
|
+
staleMs: 6e4,
|
|
2225
|
+
waitMs: 2e4,
|
|
2226
|
+
pollMs: 100
|
|
2227
|
+
});
|
|
2228
|
+
if (!release) throw new TransportError("auth_lock_busy");
|
|
2229
|
+
try {
|
|
2230
|
+
const refreshToken = await readSecret(mcpAccount(this.apiBase));
|
|
2231
|
+
if (!refreshToken) throw new AuthRequiredError();
|
|
2232
|
+
const response = await httpPost(
|
|
2233
|
+
new URL(`${this.apiBase}/oauth/token`),
|
|
2234
|
+
{
|
|
2235
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
2236
|
+
accept: "application/json"
|
|
2237
|
+
},
|
|
2238
|
+
new URLSearchParams({
|
|
2239
|
+
grant_type: "refresh_token",
|
|
2240
|
+
refresh_token: refreshToken,
|
|
2241
|
+
client_id: MCP_CLIENT_ID,
|
|
2242
|
+
resource: mcpResource(this.apiBase)
|
|
2243
|
+
}).toString()
|
|
2244
|
+
);
|
|
2245
|
+
let body = {};
|
|
2246
|
+
try {
|
|
2247
|
+
const parsed = JSON.parse(response.body || "{}");
|
|
2248
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
2249
|
+
body = parsed;
|
|
2250
|
+
} catch {
|
|
2251
|
+
body = {};
|
|
2252
|
+
}
|
|
2253
|
+
if (response.status >= 400) {
|
|
2254
|
+
if (response.status === 400 && (body.error === "invalid_grant" || body.error === "invalid_client" || body.error === "invalid_target"))
|
|
2255
|
+
throw new AuthRequiredError();
|
|
2256
|
+
throw new TransportError(`token_http_${response.status}`);
|
|
2257
|
+
}
|
|
2258
|
+
if (typeof body.access_token !== "string" || !body.access_token)
|
|
2259
|
+
throw new TransportError("token_response_invalid");
|
|
2260
|
+
if (typeof body.refresh_token === "string" && body.refresh_token && body.refresh_token !== refreshToken)
|
|
2261
|
+
await storeSecret(mcpAccount(this.apiBase), body.refresh_token);
|
|
2262
|
+
const lifetime = typeof body.expires_in === "number" && body.expires_in > 0 ? body.expires_in : 300;
|
|
2263
|
+
this.cached = {
|
|
2264
|
+
token: body.access_token,
|
|
2265
|
+
expiresAt: Date.now() + lifetime * 1e3
|
|
2266
|
+
};
|
|
2267
|
+
return body.access_token;
|
|
2268
|
+
} finally {
|
|
2269
|
+
release();
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
};
|
|
2273
|
+
|
|
2274
|
+
// src/capture/upload/mcp-client.ts
|
|
2275
|
+
var MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
2276
|
+
var McpInputRejected = class extends Error {
|
|
2277
|
+
constructor(rules) {
|
|
2278
|
+
super("MCP_INPUT_REJECTED");
|
|
2279
|
+
this.rules = rules;
|
|
2280
|
+
}
|
|
2281
|
+
rules;
|
|
2282
|
+
};
|
|
2283
|
+
var McpToolError = class extends Error {
|
|
2284
|
+
constructor(code) {
|
|
2285
|
+
super(`MCP_TOOL_ERROR:${code}`);
|
|
2286
|
+
this.code = code;
|
|
2287
|
+
}
|
|
2288
|
+
code;
|
|
2289
|
+
};
|
|
2290
|
+
var McpRateLimited = class extends Error {
|
|
2291
|
+
constructor(retryAfterMs) {
|
|
2292
|
+
super("MCP_RATE_LIMITED");
|
|
2293
|
+
this.retryAfterMs = retryAfterMs;
|
|
2294
|
+
}
|
|
2295
|
+
retryAfterMs;
|
|
2296
|
+
};
|
|
2297
|
+
var MIN_RETRY_MS = 6e4;
|
|
2298
|
+
var MAX_RETRY_MS = 36e5;
|
|
2299
|
+
function retryAfter(value) {
|
|
2300
|
+
const header = Array.isArray(value) ? value[0] : value;
|
|
2301
|
+
const seconds = header ? Number(header) : Number.NaN;
|
|
2302
|
+
let ms = MIN_RETRY_MS;
|
|
2303
|
+
if (Number.isFinite(seconds) && seconds >= 0) ms = seconds * 1e3;
|
|
2304
|
+
else {
|
|
2305
|
+
const date = header ? Date.parse(header) : Number.NaN;
|
|
2306
|
+
if (Number.isFinite(date)) ms = date - Date.now();
|
|
2307
|
+
}
|
|
2308
|
+
return Math.min(Math.max(ms, MIN_RETRY_MS), MAX_RETRY_MS);
|
|
2309
|
+
}
|
|
2310
|
+
function parseRpc(contentType, body) {
|
|
2311
|
+
let text = body;
|
|
2312
|
+
if (contentType.includes("text/event-stream")) {
|
|
2313
|
+
const data = body.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim());
|
|
2314
|
+
text = data.at(-1) ?? "";
|
|
2315
|
+
}
|
|
2316
|
+
const value = JSON.parse(text);
|
|
2317
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
2318
|
+
throw new TransportError("rpc_invalid");
|
|
2319
|
+
return value;
|
|
2320
|
+
}
|
|
2321
|
+
var McpClient = class {
|
|
2322
|
+
constructor(apiBase, broker, userAgent) {
|
|
2323
|
+
this.apiBase = apiBase;
|
|
2324
|
+
this.broker = broker;
|
|
2325
|
+
this.userAgent = userAgent;
|
|
2326
|
+
}
|
|
2327
|
+
apiBase;
|
|
2328
|
+
broker;
|
|
2329
|
+
userAgent;
|
|
2330
|
+
id = 0;
|
|
2331
|
+
async post(token, payload) {
|
|
2332
|
+
return httpPost(
|
|
2333
|
+
new URL(`${this.apiBase}/mcp`),
|
|
2334
|
+
{
|
|
2335
|
+
"content-type": "application/json",
|
|
2336
|
+
accept: "application/json, text/event-stream",
|
|
2337
|
+
"mcp-protocol-version": MCP_PROTOCOL_VERSION,
|
|
2338
|
+
authorization: `Bearer ${token}`,
|
|
2339
|
+
"user-agent": this.userAgent
|
|
2340
|
+
},
|
|
2341
|
+
payload
|
|
2342
|
+
);
|
|
2343
|
+
}
|
|
2344
|
+
async call(name, args) {
|
|
2345
|
+
const payload = JSON.stringify({
|
|
2346
|
+
jsonrpc: "2.0",
|
|
2347
|
+
id: ++this.id,
|
|
2348
|
+
method: "tools/call",
|
|
2349
|
+
params: { name, arguments: args }
|
|
2350
|
+
});
|
|
2351
|
+
let response = await this.post(await this.broker.accessToken(), payload);
|
|
2352
|
+
if (response.status === 401) {
|
|
2353
|
+
this.broker.invalidate();
|
|
2354
|
+
response = await this.post(await this.broker.accessToken(), payload);
|
|
2355
|
+
if (response.status === 401) throw new AuthRequiredError();
|
|
2356
|
+
}
|
|
2357
|
+
if (response.status === 403) throw new AuthRequiredError();
|
|
2358
|
+
if (response.status === 429)
|
|
2359
|
+
throw new McpRateLimited(retryAfter(response.headers["retry-after"]));
|
|
2360
|
+
if (response.status !== 200)
|
|
2361
|
+
throw new TransportError(`mcp_http_${response.status}`);
|
|
2362
|
+
let message;
|
|
2363
|
+
try {
|
|
2364
|
+
message = parseRpc(
|
|
2365
|
+
String(response.headers["content-type"] ?? ""),
|
|
2366
|
+
response.body
|
|
2367
|
+
);
|
|
2368
|
+
} catch {
|
|
2369
|
+
throw new TransportError("rpc_invalid");
|
|
2370
|
+
}
|
|
2371
|
+
const error = message.error;
|
|
2372
|
+
if (error) {
|
|
2373
|
+
if (error.code === -32602) {
|
|
2374
|
+
const issues = error.data?.issues;
|
|
2375
|
+
const rules = Array.isArray(issues) ? issues.map((issue) => issue.rule).filter((rule) => typeof rule === "string") : [];
|
|
2376
|
+
throw new McpInputRejected(rules);
|
|
2377
|
+
}
|
|
2378
|
+
throw new TransportError(
|
|
2379
|
+
`rpc_error_${typeof error.code === "number" ? error.code : "unknown"}`
|
|
2380
|
+
);
|
|
2381
|
+
}
|
|
2382
|
+
const result = message.result;
|
|
2383
|
+
const text = result?.content?.find((item) => item.type === "text")?.text;
|
|
2384
|
+
let value;
|
|
2385
|
+
try {
|
|
2386
|
+
value = typeof text === "string" ? JSON.parse(text) : void 0;
|
|
2387
|
+
} catch {
|
|
2388
|
+
value = void 0;
|
|
2389
|
+
}
|
|
2390
|
+
if (result?.isError === true) {
|
|
2391
|
+
const error2 = value?.error;
|
|
2392
|
+
if (error2?.code === "invalid_request" && Array.isArray(error2.issues))
|
|
2393
|
+
throw new McpInputRejected(
|
|
2394
|
+
error2.issues.map((issue) => issue.rule).filter((rule) => typeof rule === "string")
|
|
2395
|
+
);
|
|
2396
|
+
const code = error2?.code;
|
|
2397
|
+
const safe = typeof code === "string" && /^[a-z_]{1,64}$/.test(code) ? code : "unknown";
|
|
2398
|
+
if (safe === "rate_limited") throw new McpRateLimited(6e4);
|
|
2399
|
+
throw new McpToolError(safe);
|
|
2400
|
+
}
|
|
2401
|
+
if (value === void 0) throw new TransportError("rpc_result_invalid");
|
|
2402
|
+
return value;
|
|
2403
|
+
}
|
|
2404
|
+
};
|
|
2405
|
+
|
|
2406
|
+
// src/capture/status.ts
|
|
2407
|
+
function readText(path) {
|
|
2408
|
+
try {
|
|
2409
|
+
return readFileSync3(path, "utf8");
|
|
2410
|
+
} catch {
|
|
2411
|
+
return void 0;
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
function absoluteEnv2(name) {
|
|
2415
|
+
const value = process.env[name];
|
|
2416
|
+
return value && isAbsolute4(value) ? value : void 0;
|
|
2417
|
+
}
|
|
2418
|
+
function codexHookTrust() {
|
|
2419
|
+
const home = absoluteEnv2("CODEX_HOME") ?? join9(homedir3(), ".codex");
|
|
2420
|
+
const text = readText(join9(home, "config.toml"));
|
|
2421
|
+
if (text === void 0) return "not checked (no Codex config.toml)";
|
|
2422
|
+
const trusted = /* @__PURE__ */ new Set();
|
|
2423
|
+
let section;
|
|
2424
|
+
for (const line of text.split("\n")) {
|
|
2425
|
+
const header = line.match(
|
|
2426
|
+
/^\s*\[hooks\.state\."(memroot@[^"]*:hooks\/hooks\.json:[^"]*)"\]\s*$/
|
|
2427
|
+
);
|
|
2428
|
+
if (header) {
|
|
2429
|
+
section = header[1];
|
|
2430
|
+
continue;
|
|
2431
|
+
}
|
|
2432
|
+
if (/^\s*\[/.test(line)) section = void 0;
|
|
2433
|
+
else if (section && /^\s*trusted_hash\s*=\s*"sha256:[a-f0-9]+"/.test(line))
|
|
2434
|
+
trusted.add(section);
|
|
2435
|
+
}
|
|
2436
|
+
const end = [...trusted].some((key) => /:session_end:/.test(key));
|
|
2437
|
+
const start = [...trusted].some((key) => /:session_start:/.test(key));
|
|
2438
|
+
if (!end && !start)
|
|
2439
|
+
return "SessionEnd hook not trusted yet; open /hooks in Codex and trust the Memroot hooks";
|
|
2440
|
+
return `SessionStart ${start ? "trusted" : "not trusted"}, SessionEnd ${end ? "trusted" : "not trusted (open /hooks in Codex)"}`;
|
|
2441
|
+
}
|
|
2442
|
+
function claudeHooksDisabled() {
|
|
2443
|
+
const config = absoluteEnv2("CLAUDE_CONFIG_DIR") ?? join9(homedir3(), ".claude");
|
|
2444
|
+
const text = readText(join9(config, "settings.json"));
|
|
2445
|
+
if (!text) return false;
|
|
2446
|
+
try {
|
|
2447
|
+
return JSON.parse(text).disableAllHooks === true;
|
|
2448
|
+
} catch {
|
|
2449
|
+
return false;
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
async function captureReport(options) {
|
|
2453
|
+
const lines = [];
|
|
2454
|
+
const consent = readConsent();
|
|
2455
|
+
if (consent.kind === "absent") {
|
|
2456
|
+
lines.push(
|
|
2457
|
+
"Session capture: off (not enabled). Run memroot capture on to opt in."
|
|
2458
|
+
);
|
|
2459
|
+
return lines;
|
|
2460
|
+
}
|
|
2461
|
+
if (consent.kind === "invalid") {
|
|
2462
|
+
lines.push(
|
|
2463
|
+
"Session capture: consent file is invalid; capture is disabled. Run memroot capture on to repair it."
|
|
2464
|
+
);
|
|
2465
|
+
return lines;
|
|
2466
|
+
}
|
|
2467
|
+
const value = consent.consent;
|
|
2468
|
+
lines.push(
|
|
2469
|
+
`Session capture: ${value.enabled ? "on" : "off"} (agents: ${value.agents.join(", ")}; cross-agent extraction ${value.crossAgentExtraction ? "on" : "off"}; headless sessions ${value.includeHeadlessSessions ? "included" : "excluded"})`
|
|
2470
|
+
);
|
|
2471
|
+
lines.push(
|
|
2472
|
+
` Caps: ${value.maxItemsPerSession} per session, ${value.maxItemsPerDay} per 24 h; API ${value.apiBase}`
|
|
2473
|
+
);
|
|
2474
|
+
if (pathExists(stateDir())) {
|
|
2475
|
+
const states = {};
|
|
2476
|
+
for (const jobId of listJobIds()) {
|
|
2477
|
+
const job = readJob(jobId);
|
|
2478
|
+
if (job) states[job.state] = (states[job.state] ?? 0) + 1;
|
|
2479
|
+
}
|
|
2480
|
+
const pending = Object.entries(states).map(([state, count]) => `${state} ${count}`).join(", ");
|
|
2481
|
+
lines.push(` Queue: ${pending || "empty"}`);
|
|
2482
|
+
const ledger = readLedger();
|
|
2483
|
+
lines.push(` Uploaded in the last 24 h: ${uploadsInLastDay(ledger)}`);
|
|
2484
|
+
if (ledger.lastRun) {
|
|
2485
|
+
const outcomes = Object.entries(ledger.lastRun.outcomes).map(([key, count]) => `${key} ${count}`).join(", ");
|
|
2486
|
+
lines.push(
|
|
2487
|
+
` Last worker run: ${ledger.lastRun.finishedAt} (${outcomes || "no jobs"})`
|
|
2488
|
+
);
|
|
2489
|
+
} else lines.push(" Last worker run: never");
|
|
2490
|
+
} else lines.push(" Queue: empty; the worker has not run yet");
|
|
2491
|
+
let credential = "missing (run memroot capture on)";
|
|
2492
|
+
try {
|
|
2493
|
+
if (await readSecret(mcpAccount(value.apiBase))) {
|
|
2494
|
+
credential = "present";
|
|
2495
|
+
if (options.probe) {
|
|
2496
|
+
const broker = new TokenBroker(value.apiBase);
|
|
2497
|
+
try {
|
|
2498
|
+
await broker.accessToken();
|
|
2499
|
+
const status2 = await new McpClient(
|
|
2500
|
+
value.apiBase,
|
|
2501
|
+
broker,
|
|
2502
|
+
`memroot-cli/${"0.1.0-alpha.3"}`
|
|
2503
|
+
).call("connection_status", {});
|
|
2504
|
+
credential = status2?.connected === true ? "present; connection verified" : "present; connection not verified";
|
|
2505
|
+
} catch (error) {
|
|
2506
|
+
credential = error instanceof AuthRequiredError ? "rejected or expired (run memroot capture on)" : `present; check failed (${error instanceof Error ? error.message : "error"})`;
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
} catch (error) {
|
|
2511
|
+
credential = `unavailable (${error instanceof Error ? error.message.split(":")[0] : "error"})`;
|
|
2512
|
+
}
|
|
2513
|
+
lines.push(` Capture connection: ${credential}`);
|
|
2514
|
+
if (value.agents.includes("codex"))
|
|
2515
|
+
lines.push(` Codex hook trust: ${codexHookTrust()}`);
|
|
2516
|
+
if (value.agents.includes("claude") && claudeHooksDisabled())
|
|
2517
|
+
lines.push(
|
|
2518
|
+
" Claude Code: disableAllHooks is set in settings.json; capture cannot run."
|
|
2519
|
+
);
|
|
2520
|
+
const runners = ["claude", "codex", "grok"].map((agent) => `${agent} ${findExecutable(agent) ? "found" : "not found"}`).join(", ");
|
|
2521
|
+
lines.push(` Headless extractor CLIs on PATH: ${runners}`);
|
|
2522
|
+
return lines;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// src/codex-mcp.ts
|
|
2526
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
2527
|
+
|
|
2528
|
+
// src/native.ts
|
|
2529
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
2530
|
+
import { accessSync as accessSync2, constants as constants3, realpathSync, statSync as statSync2 } from "node:fs";
|
|
2531
|
+
import { delimiter as delimiter2, isAbsolute as isAbsolute5, join as join10 } from "node:path";
|
|
2532
|
+
import { stripVTControlCharacters as stripVTControlCharacters2 } from "node:util";
|
|
2533
|
+
var agents = ["claude", "codex", "grok"];
|
|
2534
|
+
var labels = {
|
|
2535
|
+
claude: "Claude Code",
|
|
2536
|
+
codex: "Codex",
|
|
2537
|
+
grok: "Grok"
|
|
2538
|
+
};
|
|
2539
|
+
function executable(agent) {
|
|
2540
|
+
for (const directory of (process.env.PATH ?? "").split(delimiter2)) {
|
|
2541
|
+
if (!isAbsolute5(directory)) continue;
|
|
2542
|
+
for (const extension of [""]) {
|
|
2543
|
+
const path = join10(directory, agent + extension);
|
|
2544
|
+
try {
|
|
2545
|
+
accessSync2(path, constants3.X_OK);
|
|
2546
|
+
if (statSync2(path).isFile()) return path;
|
|
2547
|
+
} catch {
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
return void 0;
|
|
2552
|
+
}
|
|
2553
|
+
function native(path, agent, args) {
|
|
2554
|
+
const result = spawnSync2(path, args, {
|
|
2555
|
+
encoding: "utf8",
|
|
2556
|
+
timeout: 6e4,
|
|
2557
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
2558
|
+
shell: false
|
|
2559
|
+
});
|
|
2560
|
+
if (result.error || result.status !== 0) {
|
|
2561
|
+
throw new Error(
|
|
2562
|
+
`NATIVE_COMMAND_FAILED: ${agent} ${args.slice(0, 3).join(" ")} failed (${result.status ?? result.error?.code ?? "signal"}). Check '${agent} plugin --help', update the agent CLI, and retry. Its plugin manager may have partially registered Memroot; no success receipt was written.`
|
|
2563
|
+
);
|
|
2564
|
+
}
|
|
2565
|
+
return result.stdout;
|
|
2566
|
+
}
|
|
2567
|
+
function installCommands(agent, marketplace) {
|
|
2568
|
+
if (agent === "claude")
|
|
2569
|
+
return [
|
|
2570
|
+
["plugin", "marketplace", "add", marketplace, "--scope", "user"],
|
|
2571
|
+
["plugin", "install", "memroot@memroot", "--scope", "user"]
|
|
2572
|
+
];
|
|
2573
|
+
if (agent === "codex")
|
|
2574
|
+
return [
|
|
2575
|
+
["plugin", "marketplace", "add", marketplace, "--json"],
|
|
2576
|
+
["plugin", "add", "memroot@memroot", "--json"]
|
|
2577
|
+
];
|
|
2578
|
+
return [
|
|
2579
|
+
["plugin", "install", join10(marketplace, "plugins", "memroot"), "--trust"]
|
|
2580
|
+
];
|
|
2581
|
+
}
|
|
2582
|
+
function preflight(path, agent) {
|
|
2583
|
+
native(path, agent, ["plugin", "--help"]);
|
|
2584
|
+
if (agent !== "grok")
|
|
2585
|
+
native(path, agent, ["plugin", "marketplace", "add", "--help"]);
|
|
2586
|
+
native(path, agent, [
|
|
2587
|
+
"plugin",
|
|
2588
|
+
agent === "codex" ? "add" : "install",
|
|
2589
|
+
"--help"
|
|
2590
|
+
]);
|
|
2591
|
+
}
|
|
2592
|
+
function rows(output, field) {
|
|
2593
|
+
let value;
|
|
2594
|
+
try {
|
|
2595
|
+
value = JSON.parse(output);
|
|
2596
|
+
} catch {
|
|
2597
|
+
throw new Error(
|
|
2598
|
+
"NATIVE_STATUS_UNSUPPORTED: Agent plugin status is not valid JSON. Update the agent CLI and retry."
|
|
2599
|
+
);
|
|
2600
|
+
}
|
|
2601
|
+
if (field)
|
|
2602
|
+
value = value && typeof value === "object" ? value[field] : void 0;
|
|
2603
|
+
if (!Array.isArray(value) || value.some((row) => !row || typeof row !== "object" || Array.isArray(row)))
|
|
2604
|
+
throw new Error(
|
|
2605
|
+
"NATIVE_STATUS_UNSUPPORTED: Unrecognized agent plugin status; refusing to change it."
|
|
2606
|
+
);
|
|
2607
|
+
return value;
|
|
2608
|
+
}
|
|
2609
|
+
function samePath(left, right) {
|
|
2610
|
+
if (typeof left !== "string") return false;
|
|
2611
|
+
try {
|
|
2612
|
+
return realpathSync(left) === realpathSync(right);
|
|
2613
|
+
} catch {
|
|
2614
|
+
return left === right;
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
function inspectNative(path, agent, marketplace, previousMarketplace) {
|
|
2618
|
+
if (agent === "grok") {
|
|
2619
|
+
const output = stripVTControlCharacters2(
|
|
2620
|
+
native(path, agent, ["plugin", "list"])
|
|
2621
|
+
);
|
|
2622
|
+
const lines = output.split("\n").filter((line) => line.trim());
|
|
2623
|
+
const empty2 = output.trim() === "No plugins installed. Run `grok plugin install --help` to get started.";
|
|
2624
|
+
if (!empty2 && (!lines.length || lines.some(
|
|
2625
|
+
(line) => !/^\s*\S+:\s+[^[]+\[.+\](?:\s+\(disabled\))?\s*$/.test(line)
|
|
2626
|
+
))) {
|
|
2627
|
+
throw new Error(
|
|
2628
|
+
"NATIVE_STATUS_UNSUPPORTED: Unrecognized Grok plugin list format. Update Grok and inspect its native plugin manager before retrying."
|
|
2629
|
+
);
|
|
2630
|
+
}
|
|
2631
|
+
const matching = output.split("\n").filter((line) => /:\s*memroot(?:\s|$)/.test(line));
|
|
2632
|
+
for (const line of matching) {
|
|
2633
|
+
const source = line.match(/\[local: (.*)\]/)?.[1];
|
|
2634
|
+
if (!samePath(source, join10(marketplace, "plugins", "memroot")) && !(previousMarketplace && samePath(source, join10(previousMarketplace, "plugins", "memroot"))))
|
|
2635
|
+
throw new Error(
|
|
2636
|
+
"PLUGIN_COLLISION: Grok already has a different Memroot plugin. Resolve it with the native plugin manager first."
|
|
2637
|
+
);
|
|
2638
|
+
if (/disabled/i.test(line))
|
|
2639
|
+
throw new Error(
|
|
2640
|
+
"PLUGIN_DISABLED: Memroot is disabled in Grok. Enable it in the native plugin manager when ready."
|
|
2641
|
+
);
|
|
2642
|
+
}
|
|
2643
|
+
return {
|
|
2644
|
+
registered: false,
|
|
2645
|
+
currentMarketplace: false,
|
|
2646
|
+
installed: matching.length > 0
|
|
2647
|
+
};
|
|
2648
|
+
}
|
|
2649
|
+
const markets = rows(
|
|
2650
|
+
native(path, agent, ["plugin", "marketplace", "list", "--json"]),
|
|
2651
|
+
agent === "codex" ? "marketplaces" : void 0
|
|
2652
|
+
);
|
|
2653
|
+
const plugins = rows(
|
|
2654
|
+
native(path, agent, ["plugin", "list", "--json"]),
|
|
2655
|
+
agent === "codex" ? "installed" : void 0
|
|
2656
|
+
);
|
|
2657
|
+
const matchingMarkets = markets.filter((row) => row.name === "memroot");
|
|
2658
|
+
if (matchingMarkets.length > 1)
|
|
2659
|
+
throw new Error(
|
|
2660
|
+
"MARKETPLACE_COLLISION: Multiple Memroot marketplaces were reported; refusing to change them."
|
|
2661
|
+
);
|
|
2662
|
+
for (const market of matchingMarkets) {
|
|
2663
|
+
const source = agent === "codex" ? market.root : market.path;
|
|
2664
|
+
if (!samePath(source, marketplace) && !(previousMarketplace && samePath(source, previousMarketplace)))
|
|
2665
|
+
throw new Error(
|
|
2666
|
+
"MARKETPLACE_COLLISION: memroot is already registered from a different source. Resolve it with the native plugin manager first."
|
|
2667
|
+
);
|
|
2668
|
+
}
|
|
2669
|
+
const matchingPlugins = plugins.filter(
|
|
2670
|
+
(row) => row.id === "memroot@memroot" || row.pluginId === "memroot@memroot" || row.name === "memroot"
|
|
2671
|
+
);
|
|
2672
|
+
if (matchingPlugins.length && !matchingMarkets.length)
|
|
2673
|
+
throw new Error(
|
|
2674
|
+
"PLUGIN_COLLISION: An existing Memroot plugin has no matching managed marketplace. Resolve it with the native plugin manager first."
|
|
2675
|
+
);
|
|
2676
|
+
for (const plugin of matchingPlugins) {
|
|
2677
|
+
if (plugin.enabled === false)
|
|
2678
|
+
throw new Error(
|
|
2679
|
+
`PLUGIN_DISABLED: Memroot is disabled in ${labels[agent]}. Enable it in the native plugin manager when ready; setup preserves your choice.`
|
|
2680
|
+
);
|
|
2681
|
+
if (plugin.enabled !== true || agent === "claude" && plugin.scope !== "user")
|
|
2682
|
+
throw new Error(
|
|
2683
|
+
"PLUGIN_COLLISION: Existing Memroot plugin status or scope cannot be safely managed. Check the native plugin manager."
|
|
2684
|
+
);
|
|
2685
|
+
}
|
|
2686
|
+
const currentMarketplace = matchingMarkets.some(
|
|
2687
|
+
(market) => samePath(agent === "codex" ? market.root : market.path, marketplace)
|
|
2688
|
+
);
|
|
2689
|
+
if (agent === "codex" && matchingMarkets.length > 0 && !currentMarketplace && plugins.some(
|
|
2690
|
+
(plugin) => plugin.marketplaceName === "memroot" && plugin.pluginId !== "memroot@memroot"
|
|
2691
|
+
))
|
|
2692
|
+
throw new Error(
|
|
2693
|
+
"MARKETPLACE_COLLISION: The previous Memroot marketplace contains another installed plugin; refusing to remove it."
|
|
2694
|
+
);
|
|
2695
|
+
return {
|
|
2696
|
+
registered: matchingMarkets.length > 0,
|
|
2697
|
+
currentMarketplace,
|
|
2698
|
+
installed: matchingPlugins.length > 0
|
|
2699
|
+
};
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
// src/codex-mcp.ts
|
|
2703
|
+
var codexMcpUrl = "https://api.memroot.dev/mcp";
|
|
2704
|
+
function command(path, args, interactive = false) {
|
|
2705
|
+
const result = spawnSync3(path, ["mcp", ...args], {
|
|
2706
|
+
shell: false,
|
|
2707
|
+
encoding: "utf8",
|
|
2708
|
+
timeout: interactive ? 3e5 : 3e4,
|
|
2709
|
+
maxBuffer: 1024 * 1024,
|
|
2710
|
+
// Let Codex present browser authentication directly; never capture credentials.
|
|
2711
|
+
stdio: interactive ? "inherit" : "pipe"
|
|
2712
|
+
});
|
|
2713
|
+
if (result.error || result.status !== 0)
|
|
2714
|
+
throw new Error(
|
|
2715
|
+
"MCP_COMMAND_FAILED: Codex MCP command did not complete. Your configuration is preserved; retry memroot setup --agent codex --yes."
|
|
2716
|
+
);
|
|
2717
|
+
return result.stdout ?? "";
|
|
2718
|
+
}
|
|
2719
|
+
function inspectCodexMcp(path) {
|
|
2720
|
+
let rows2;
|
|
2721
|
+
try {
|
|
2722
|
+
rows2 = JSON.parse(command(path, ["list", "--json"]));
|
|
2723
|
+
} catch {
|
|
2724
|
+
throw new Error(
|
|
2725
|
+
"MCP_STATUS_UNAVAILABLE: Cannot inspect Codex MCP state. Retry later; no new authorization was requested."
|
|
2726
|
+
);
|
|
2727
|
+
}
|
|
2728
|
+
if (!Array.isArray(rows2) || rows2.some((row2) => !row2 || typeof row2 !== "object"))
|
|
2729
|
+
throw new Error(
|
|
2730
|
+
"MCP_STATUS_UNAVAILABLE: Unsupported Codex MCP status format."
|
|
2731
|
+
);
|
|
2732
|
+
const matching = rows2.filter((row2) => row2.name === "memroot");
|
|
2733
|
+
if (!matching.length)
|
|
2734
|
+
return { configured: false, authorized: false, verified: false };
|
|
2735
|
+
const row = matching[0];
|
|
2736
|
+
if (matching.length !== 1 || row.enabled !== true || row.transport?.type !== "streamable_http" || row.transport?.url !== codexMcpUrl || row.transport?.bearer_token_env_var || row.transport?.http_headers || row.transport?.env_http_headers || row.transport?.http_headers_helper)
|
|
2737
|
+
throw new Error(
|
|
2738
|
+
"MCP_CONFLICT: Existing memroot MCP configuration differs from production OAuth or is disabled. Inspect it in Codex; setup preserves your settings."
|
|
2739
|
+
);
|
|
2740
|
+
if (row.auth_status !== "oauth" && row.auth_status !== "not_logged_in")
|
|
2741
|
+
throw new Error(
|
|
2742
|
+
"MCP_AUTH_UNKNOWN: Codex could not establish OAuth status. Resolve it in Codex before retrying; no replacement grant was requested."
|
|
2743
|
+
);
|
|
2744
|
+
return {
|
|
2745
|
+
configured: true,
|
|
2746
|
+
authorized: row.auth_status === "oauth",
|
|
2747
|
+
verified: false
|
|
2748
|
+
};
|
|
2749
|
+
}
|
|
2750
|
+
function codexMcpPreflight(path) {
|
|
2751
|
+
for (const [args, flags] of [
|
|
2752
|
+
[["list", "--help"], ["--json"]],
|
|
2753
|
+
[
|
|
2754
|
+
["add", "--help"],
|
|
2755
|
+
["--url", "--oauth-resource"]
|
|
2756
|
+
],
|
|
2757
|
+
[["login", "--help"], []]
|
|
2758
|
+
]) {
|
|
2759
|
+
const help2 = command(path, [...args]);
|
|
2760
|
+
if (flags.some((flag) => !help2.includes(flag)))
|
|
2761
|
+
throw new Error(
|
|
2762
|
+
"MCP_UNSUPPORTED: Update Codex to a version supporting native remote OAuth MCP setup."
|
|
2763
|
+
);
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
async function ensureCodexMcp(options) {
|
|
2767
|
+
if (options.dryRun)
|
|
2768
|
+
return { configured: false, authorized: false, verified: false };
|
|
2769
|
+
const path = executable("codex");
|
|
2770
|
+
if (!path)
|
|
2771
|
+
throw new Error("AGENT_NOT_FOUND: Install Codex before linking Memroot.");
|
|
2772
|
+
codexMcpPreflight(path);
|
|
2773
|
+
let state = inspectCodexMcp(path);
|
|
2774
|
+
if (!state.configured) {
|
|
2775
|
+
command(
|
|
2776
|
+
path,
|
|
2777
|
+
["add", "memroot", "--url", codexMcpUrl, "--oauth-resource", codexMcpUrl],
|
|
2778
|
+
true
|
|
2779
|
+
);
|
|
2780
|
+
state = inspectCodexMcp(path);
|
|
2781
|
+
if (!state.authorized)
|
|
2782
|
+
throw new Error(
|
|
2783
|
+
"MCP_AUTH_PENDING: Codex MCP is configured; authorization has not completed. Retry setup to resume."
|
|
2784
|
+
);
|
|
2785
|
+
} else if (!state.authorized) {
|
|
2786
|
+
command(path, ["login", "memroot"], true);
|
|
2787
|
+
state = inspectCodexMcp(path);
|
|
2788
|
+
}
|
|
2789
|
+
if (!state.authorized)
|
|
2790
|
+
throw new Error(
|
|
2791
|
+
"MCP_AUTH_PENDING: Codex has not confirmed OAuth authorization."
|
|
2792
|
+
);
|
|
2793
|
+
return state;
|
|
2794
|
+
}
|
|
2795
|
+
function describeCodexMcp(state) {
|
|
2796
|
+
return `Codex MCP: ${state.configured ? "configured" : "not configured"}; OAuth: ${state.authorized ? "authorized" : "not authorized"}; memory verification: not verified. Ask Codex to call connection_status, then save and retrieve a test memory.`;
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
// src/grok-mcp.ts
|
|
2800
|
+
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
2801
|
+
import { homedir as homedir4 } from "node:os";
|
|
2802
|
+
var grokMcpUrl = "https://api.memroot.dev/mcp";
|
|
2803
|
+
var MANAGED_KEYS = /* @__PURE__ */ new Set([
|
|
2804
|
+
"name",
|
|
2805
|
+
"scope",
|
|
2806
|
+
"enabled",
|
|
2807
|
+
"url",
|
|
2808
|
+
"type",
|
|
2809
|
+
"startup_timeout_sec",
|
|
2810
|
+
"tool_timeout_sec",
|
|
2811
|
+
"tool_timeouts",
|
|
2812
|
+
"expose_image_base64"
|
|
2813
|
+
]);
|
|
2814
|
+
function command2(path, args) {
|
|
2815
|
+
const result = spawnSync4(path, ["mcp", ...args], {
|
|
2816
|
+
shell: false,
|
|
2817
|
+
encoding: "utf8",
|
|
2818
|
+
timeout: 3e4,
|
|
2819
|
+
maxBuffer: 1024 * 1024,
|
|
2820
|
+
stdio: "pipe",
|
|
2821
|
+
// `grok mcp list` merges project-scoped servers from the working
|
|
2822
|
+
// directory. Setup manages the user scope, so it never runs in a repo.
|
|
2823
|
+
cwd: homedir4()
|
|
2824
|
+
});
|
|
2825
|
+
if (result.error || result.status !== 0)
|
|
2826
|
+
throw new Error(
|
|
2827
|
+
"MCP_COMMAND_FAILED: Grok MCP command did not complete. Your configuration is preserved; retry memroot setup --agent grok --yes."
|
|
2828
|
+
);
|
|
2829
|
+
return result.stdout ?? "";
|
|
2830
|
+
}
|
|
2831
|
+
function inspectGrokMcp(path) {
|
|
2832
|
+
let rows2;
|
|
2833
|
+
try {
|
|
2834
|
+
rows2 = JSON.parse(command2(path, ["list", "--json"]));
|
|
2835
|
+
} catch {
|
|
2836
|
+
throw new Error(
|
|
2837
|
+
"MCP_STATUS_UNAVAILABLE: Cannot inspect Grok MCP state. Retry later; your configuration was not changed."
|
|
2838
|
+
);
|
|
2839
|
+
}
|
|
2840
|
+
if (!Array.isArray(rows2) || rows2.some((row2) => !row2 || typeof row2 !== "object" || Array.isArray(row2)))
|
|
2841
|
+
throw new Error(
|
|
2842
|
+
"MCP_STATUS_UNAVAILABLE: Unsupported Grok MCP status format."
|
|
2843
|
+
);
|
|
2844
|
+
const matching = rows2.filter(
|
|
2845
|
+
(row2) => row2.name === "memroot"
|
|
2846
|
+
);
|
|
2847
|
+
const row = matching[0];
|
|
2848
|
+
if (!row) return { configured: false, verified: false };
|
|
2849
|
+
if (matching.length !== 1 || row.scope !== "user" || row.enabled !== true || row.url !== grokMcpUrl || row.type !== void 0 && row.type !== "http" || Object.keys(row).some((key) => !MANAGED_KEYS.has(key)))
|
|
2850
|
+
throw new Error(
|
|
2851
|
+
"MCP_CONFLICT: Existing memroot MCP configuration in Grok differs from production OAuth, is disabled or blocked, or is not user-scoped. Inspect it with grok mcp list; setup preserves your settings."
|
|
2852
|
+
);
|
|
2853
|
+
return { configured: true, verified: false };
|
|
2854
|
+
}
|
|
2855
|
+
function grokMcpPreflight(path) {
|
|
2856
|
+
for (const [args, flags] of [
|
|
2857
|
+
[["list", "--help"], ["--json"]],
|
|
2858
|
+
[
|
|
2859
|
+
["add", "--help"],
|
|
2860
|
+
["--transport", "--scope"]
|
|
2861
|
+
]
|
|
2862
|
+
]) {
|
|
2863
|
+
const help2 = command2(path, [...args]);
|
|
2864
|
+
if (flags.some((flag) => !help2.includes(flag)))
|
|
2865
|
+
throw new Error(
|
|
2866
|
+
"MCP_UNSUPPORTED: Update Grok to a version supporting remote HTTP MCP setup."
|
|
2867
|
+
);
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
function ensureGrokMcp(options) {
|
|
2871
|
+
if (options.dryRun) return { configured: false, verified: false };
|
|
2872
|
+
const path = executable("grok");
|
|
2873
|
+
if (!path)
|
|
2874
|
+
throw new Error("AGENT_NOT_FOUND: Install Grok before linking Memroot.");
|
|
2875
|
+
grokMcpPreflight(path);
|
|
2876
|
+
let state = inspectGrokMcp(path);
|
|
2877
|
+
if (!state.configured) {
|
|
2878
|
+
command2(path, [
|
|
2879
|
+
"add",
|
|
2880
|
+
"--scope",
|
|
2881
|
+
"user",
|
|
2882
|
+
"--transport",
|
|
2883
|
+
"http",
|
|
2884
|
+
"memroot",
|
|
2885
|
+
grokMcpUrl
|
|
2886
|
+
]);
|
|
2887
|
+
try {
|
|
2888
|
+
state = inspectGrokMcp(path);
|
|
2889
|
+
} catch {
|
|
2890
|
+
throw new Error(
|
|
2891
|
+
"MCP_COMMAND_FAILED: Setup added the memroot MCP server to Grok but could not verify the entry. Inspect it with grok mcp list; remove it with grok mcp remove memroot --scope user if it looks wrong, then retry memroot setup --agent grok --yes."
|
|
2892
|
+
);
|
|
2893
|
+
}
|
|
2894
|
+
if (!state.configured)
|
|
2895
|
+
throw new Error(
|
|
2896
|
+
"MCP_COMMAND_FAILED: Grok did not record the memroot MCP server. Retry memroot setup --agent grok --yes."
|
|
2897
|
+
);
|
|
2898
|
+
}
|
|
2899
|
+
return state;
|
|
2900
|
+
}
|
|
2901
|
+
function describeGrokMcp(state) {
|
|
2902
|
+
if (!state.configured)
|
|
2903
|
+
return "Grok MCP: not configured. Run memroot setup --agent grok --yes to register the Memroot MCP server.";
|
|
2904
|
+
return "Grok MCP: configured; OAuth: not checked, Grok keeps the credentials in its own store; memory verification: not verified. Ask Grok to call connection_status, then save and retrieve a test memory.";
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
// src/auth.ts
|
|
2908
|
+
var PROD_API_BASE = "https://api.memroot.dev";
|
|
2909
|
+
var DEV_API_BASE = "http://localhost:8787";
|
|
2910
|
+
var STATIC_CLIENT_ID = "memroot-cli";
|
|
2911
|
+
var CALLBACK_PATH = "/callback";
|
|
2912
|
+
var LOGIN_TIMEOUT_MS = 18e4;
|
|
2913
|
+
var REFRESH_SKEW_MS2 = 3e4;
|
|
2914
|
+
var REQUESTED_SCOPE = "memory.read memory.write";
|
|
2915
|
+
var OAuthError = class extends Error {
|
|
2916
|
+
constructor(oauth, message) {
|
|
2917
|
+
super(message);
|
|
2918
|
+
this.oauth = oauth;
|
|
2919
|
+
}
|
|
2920
|
+
oauth;
|
|
2921
|
+
};
|
|
2922
|
+
function resolveApiBase(options, config) {
|
|
2923
|
+
const base = options.apiBase ?? process.env.MEMROOT_API_BASE ?? (options.env ? options.env === "dev" ? DEV_API_BASE : PROD_API_BASE : void 0) ?? config.apiBase ?? PROD_API_BASE;
|
|
2924
|
+
const normalized = base.replace(/\/+$/, "");
|
|
2925
|
+
if (!isAllowedApiBase(normalized))
|
|
2926
|
+
throw new Error(
|
|
2927
|
+
"INSECURE_API_BASE: The API base must use https, or http only on a loopback address for local development."
|
|
2928
|
+
);
|
|
2929
|
+
return normalized;
|
|
2930
|
+
}
|
|
2931
|
+
function decodeClaims(token) {
|
|
2932
|
+
const parts = token.split(".");
|
|
2933
|
+
if (parts.length !== 3) return void 0;
|
|
2934
|
+
try {
|
|
2935
|
+
const claims = JSON.parse(
|
|
2936
|
+
Buffer.from(parts[1] ?? "", "base64url").toString("utf8")
|
|
2937
|
+
);
|
|
2938
|
+
if (!claims || typeof claims !== "object" || Array.isArray(claims))
|
|
2939
|
+
return void 0;
|
|
2940
|
+
return claims;
|
|
2941
|
+
} catch {
|
|
2942
|
+
return void 0;
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
function claimsExpiry(claims) {
|
|
2946
|
+
return typeof claims?.exp === "number" ? claims.exp * 1e3 : 0;
|
|
2947
|
+
}
|
|
2948
|
+
function parseTokenSet(body) {
|
|
2949
|
+
if (typeof body.access_token !== "string" || !body.access_token)
|
|
2950
|
+
throw new Error(
|
|
2951
|
+
"AUTH_RESPONSE_INVALID: The token endpoint returned no access token."
|
|
2952
|
+
);
|
|
2953
|
+
const tokens = { accessToken: body.access_token };
|
|
2954
|
+
if (typeof body.refresh_token === "string" && body.refresh_token)
|
|
2955
|
+
tokens.refreshToken = body.refresh_token;
|
|
2956
|
+
if (typeof body.expires_in === "number") tokens.expiresIn = body.expires_in;
|
|
2957
|
+
return tokens;
|
|
2958
|
+
}
|
|
2959
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
2960
|
+
function request(target, method, contentType, payload) {
|
|
2961
|
+
const transport = target.protocol === "https:" ? httpsRequest2 : httpRequest2;
|
|
2962
|
+
const headers = {
|
|
2963
|
+
accept: "application/json"
|
|
2964
|
+
};
|
|
2965
|
+
if (contentType) headers["content-type"] = contentType;
|
|
2966
|
+
if (payload !== void 0)
|
|
2967
|
+
headers["content-length"] = Buffer.byteLength(payload);
|
|
2968
|
+
return new Promise((resolve, reject) => {
|
|
2969
|
+
const request2 = transport(
|
|
2970
|
+
target,
|
|
2971
|
+
{
|
|
2972
|
+
method,
|
|
2973
|
+
agent: false,
|
|
2974
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
2975
|
+
headers
|
|
2976
|
+
},
|
|
2977
|
+
(response) => {
|
|
2978
|
+
let body = "";
|
|
2979
|
+
response.setEncoding("utf8");
|
|
2980
|
+
response.on("data", (chunk) => {
|
|
2981
|
+
body += chunk;
|
|
2982
|
+
});
|
|
2983
|
+
response.on(
|
|
2984
|
+
"end",
|
|
2985
|
+
() => resolve({ status: response.statusCode ?? 0, body })
|
|
2986
|
+
);
|
|
2987
|
+
}
|
|
2988
|
+
);
|
|
2989
|
+
request2.on("timeout", () => request2.destroy(new Error("request timeout")));
|
|
2990
|
+
request2.on(
|
|
2991
|
+
"error",
|
|
2992
|
+
() => reject(
|
|
2993
|
+
new Error(
|
|
2994
|
+
`AUTH_UNREACHABLE: Cannot reach ${target.origin}. Check the network connection and API base URL.`
|
|
2995
|
+
)
|
|
2996
|
+
)
|
|
2997
|
+
);
|
|
2998
|
+
request2.end(payload);
|
|
2999
|
+
});
|
|
3000
|
+
}
|
|
3001
|
+
async function getJson(url) {
|
|
3002
|
+
const response = await request(new URL(url), "GET");
|
|
3003
|
+
if (response.status >= 400)
|
|
3004
|
+
throw new Error(
|
|
3005
|
+
`AUTH_METADATA_FAILED: ${url} returned ${response.status}.`
|
|
3006
|
+
);
|
|
3007
|
+
return parseJsonBody(response.body);
|
|
3008
|
+
}
|
|
3009
|
+
function parseJsonBody(text) {
|
|
3010
|
+
let body;
|
|
3011
|
+
try {
|
|
3012
|
+
body = text ? JSON.parse(text) : {};
|
|
3013
|
+
} catch {
|
|
3014
|
+
body = void 0;
|
|
3015
|
+
}
|
|
3016
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
3017
|
+
throw new Error(
|
|
3018
|
+
"AUTH_RESPONSE_INVALID: The server returned a malformed response."
|
|
3019
|
+
);
|
|
3020
|
+
return body;
|
|
3021
|
+
}
|
|
3022
|
+
function oauthFailure(record, status2) {
|
|
3023
|
+
const code = typeof record.error === "string" ? record.error : `http_${status2}`;
|
|
3024
|
+
const description = typeof record.error_description === "string" ? ` ${record.error_description}` : "";
|
|
3025
|
+
return new OAuthError(code, `AUTH_TOKEN_REJECTED: ${code}.${description}`);
|
|
3026
|
+
}
|
|
3027
|
+
async function postForm(apiBase, path, params) {
|
|
3028
|
+
const response = await request(
|
|
3029
|
+
new URL(`${apiBase}${path}`),
|
|
3030
|
+
"POST",
|
|
3031
|
+
"application/x-www-form-urlencoded",
|
|
3032
|
+
new URLSearchParams(params).toString()
|
|
3033
|
+
);
|
|
3034
|
+
let record = {};
|
|
3035
|
+
try {
|
|
3036
|
+
record = response.body ? parseJsonBody(response.body) : {};
|
|
3037
|
+
} catch (error) {
|
|
3038
|
+
if (response.status < 400) throw error;
|
|
3039
|
+
}
|
|
3040
|
+
if (response.status >= 400) throw oauthFailure(record, response.status);
|
|
3041
|
+
return parseJsonBody(response.body);
|
|
3042
|
+
}
|
|
3043
|
+
async function registerClient(apiBase, redirectUri) {
|
|
3044
|
+
const response = await request(
|
|
3045
|
+
new URL(`${apiBase}/oauth/register`),
|
|
3046
|
+
"POST",
|
|
3047
|
+
"application/json",
|
|
3048
|
+
JSON.stringify({
|
|
3049
|
+
client_name: "Memroot CLI",
|
|
3050
|
+
redirect_uris: [redirectUri],
|
|
3051
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
3052
|
+
response_types: ["code"],
|
|
3053
|
+
token_endpoint_auth_method: "none",
|
|
3054
|
+
scope: REQUESTED_SCOPE
|
|
3055
|
+
})
|
|
3056
|
+
);
|
|
3057
|
+
if (response.status === 404 || response.status === 405 || response.status === 501)
|
|
3058
|
+
return void 0;
|
|
3059
|
+
let record = {};
|
|
3060
|
+
try {
|
|
3061
|
+
record = parseJsonBody(response.body);
|
|
3062
|
+
} catch {
|
|
3063
|
+
record = {};
|
|
3064
|
+
}
|
|
3065
|
+
if (response.status >= 400) {
|
|
3066
|
+
const code = typeof record.error === "string" ? record.error : `http_${response.status}`;
|
|
3067
|
+
throw new OAuthError(
|
|
3068
|
+
code,
|
|
3069
|
+
`AUTH_REGISTRATION_FAILED: Client registration failed (${code}).`
|
|
3070
|
+
);
|
|
3071
|
+
}
|
|
3072
|
+
if (typeof record.client_id !== "string" || !record.client_id)
|
|
3073
|
+
throw new Error(
|
|
3074
|
+
"AUTH_RESPONSE_INVALID: Client registration returned no client_id."
|
|
3075
|
+
);
|
|
3076
|
+
return record.client_id;
|
|
3077
|
+
}
|
|
3078
|
+
async function exchangeCode(apiBase, clientId, redirectUri, code, verifier, resource) {
|
|
3079
|
+
return parseTokenSet(
|
|
3080
|
+
await postForm(apiBase, "/oauth/token", {
|
|
3081
|
+
grant_type: "authorization_code",
|
|
3082
|
+
code,
|
|
3083
|
+
redirect_uri: redirectUri,
|
|
3084
|
+
client_id: clientId,
|
|
3085
|
+
code_verifier: verifier,
|
|
3086
|
+
...resource ? { resource } : {}
|
|
3087
|
+
})
|
|
3088
|
+
);
|
|
3089
|
+
}
|
|
3090
|
+
async function revokeRefreshToken(apiBase, refreshToken, clientId) {
|
|
3091
|
+
const metadata = await getJson(
|
|
3092
|
+
`${apiBase}/.well-known/oauth-authorization-server`
|
|
3093
|
+
);
|
|
3094
|
+
const endpoint = metadata.revocation_endpoint;
|
|
3095
|
+
if (typeof endpoint !== "string" || !endpoint || !isAllowedApiBase(endpoint))
|
|
3096
|
+
throw new Error(
|
|
3097
|
+
"AUTH_REVOKE_UNSUPPORTED: The server does not advertise a usable revocation endpoint."
|
|
3098
|
+
);
|
|
3099
|
+
const response = await request(
|
|
3100
|
+
new URL(endpoint),
|
|
3101
|
+
"POST",
|
|
3102
|
+
"application/x-www-form-urlencoded",
|
|
3103
|
+
new URLSearchParams({
|
|
3104
|
+
token: refreshToken,
|
|
3105
|
+
token_type_hint: "refresh_token",
|
|
3106
|
+
client_id: clientId
|
|
3107
|
+
}).toString()
|
|
3108
|
+
);
|
|
3109
|
+
if (response.status >= 400)
|
|
3110
|
+
throw new Error(
|
|
3111
|
+
`AUTH_REVOKE_FAILED: Revocation returned ${response.status}.`
|
|
3112
|
+
);
|
|
3113
|
+
}
|
|
3114
|
+
async function refreshTokens(apiBase, refreshToken, clientId) {
|
|
3115
|
+
try {
|
|
3116
|
+
return parseTokenSet(
|
|
3117
|
+
await postForm(apiBase, "/oauth/token", {
|
|
3118
|
+
grant_type: "refresh_token",
|
|
3119
|
+
refresh_token: refreshToken,
|
|
3120
|
+
client_id: clientId
|
|
3121
|
+
})
|
|
3122
|
+
);
|
|
3123
|
+
} catch (error) {
|
|
3124
|
+
if (error instanceof OAuthError && error.oauth === "invalid_grant")
|
|
3125
|
+
throw new OAuthError(
|
|
3126
|
+
"invalid_grant",
|
|
3127
|
+
"AUTH_EXPIRED: Stored credentials were rejected or expired. Run memroot login."
|
|
3128
|
+
);
|
|
3129
|
+
if (error instanceof OAuthError && error.oauth === "invalid_client")
|
|
3130
|
+
throw new OAuthError(
|
|
3131
|
+
"invalid_client",
|
|
3132
|
+
"AUTH_INVALID_CLIENT: The stored client registration was rejected. Run memroot login."
|
|
3133
|
+
);
|
|
3134
|
+
throw error;
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
function page(title, detail) {
|
|
3138
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>Memroot \u2014 ${title}</title></head><body><h1>${title}</h1><p>${detail}</p></body></html>`;
|
|
3139
|
+
}
|
|
3140
|
+
function startCallbackServer(expectedState, apiBase) {
|
|
3141
|
+
let failure;
|
|
3142
|
+
let timer;
|
|
3143
|
+
const pending = [];
|
|
3144
|
+
const waiters = [];
|
|
3145
|
+
const deliver = (result) => {
|
|
3146
|
+
const waiter = waiters.shift();
|
|
3147
|
+
if (waiter) waiter.resolve(result);
|
|
3148
|
+
else pending.push(result);
|
|
3149
|
+
};
|
|
3150
|
+
const server = createServer((request2, response) => {
|
|
3151
|
+
const url = new URL(request2.url ?? "/", "http://127.0.0.1");
|
|
3152
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
3153
|
+
response.writeHead(404).end("Not found");
|
|
3154
|
+
return;
|
|
3155
|
+
}
|
|
3156
|
+
const errorParam = url.searchParams.get("error");
|
|
3157
|
+
if (errorParam) {
|
|
3158
|
+
const description = url.searchParams.get("error_description");
|
|
3159
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(page("Sign-in failed", "Return to the terminal for details."));
|
|
3160
|
+
deliver({
|
|
3161
|
+
kind: "error",
|
|
3162
|
+
error: errorParam,
|
|
3163
|
+
...typeof description === "string" ? { description } : {}
|
|
3164
|
+
});
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
const code = url.searchParams.get("code");
|
|
3168
|
+
const state = url.searchParams.get("state");
|
|
3169
|
+
if (!code || !state) {
|
|
3170
|
+
response.writeHead(400).end("Missing code or state.");
|
|
3171
|
+
return;
|
|
3172
|
+
}
|
|
3173
|
+
if (state !== expectedState) {
|
|
3174
|
+
response.writeHead(400).end("State mismatch; rejected.");
|
|
3175
|
+
return;
|
|
3176
|
+
}
|
|
3177
|
+
const issuer = url.searchParams.get("iss");
|
|
3178
|
+
const issuerOrigin = (() => {
|
|
3179
|
+
try {
|
|
3180
|
+
return issuer ? new URL(issuer).origin : void 0;
|
|
3181
|
+
} catch {
|
|
3182
|
+
return void 0;
|
|
3183
|
+
}
|
|
3184
|
+
})();
|
|
3185
|
+
if (!issuer || !issuerOrigin) {
|
|
3186
|
+
response.writeHead(400).end("Missing or malformed issuer; rejected.");
|
|
3187
|
+
deliver({ kind: "error", error: "iss_missing" });
|
|
3188
|
+
return;
|
|
3189
|
+
}
|
|
3190
|
+
if (issuerOrigin !== new URL(apiBase).origin) {
|
|
3191
|
+
response.writeHead(400).end("Issuer mismatch; rejected.");
|
|
3192
|
+
deliver({ kind: "error", error: "iss_mismatch" });
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(
|
|
3196
|
+
page(
|
|
3197
|
+
"Sign-in complete",
|
|
3198
|
+
"You can close this tab and return to the terminal."
|
|
3199
|
+
)
|
|
3200
|
+
);
|
|
3201
|
+
deliver({ kind: "code", code });
|
|
3202
|
+
});
|
|
3203
|
+
const fail = (error) => {
|
|
3204
|
+
if (failure) return;
|
|
3205
|
+
failure = error;
|
|
3206
|
+
if (timer) clearTimeout(timer);
|
|
3207
|
+
server.close();
|
|
3208
|
+
server.closeAllConnections();
|
|
3209
|
+
for (const waiter of waiters.splice(0)) waiter.reject(error);
|
|
3210
|
+
};
|
|
3211
|
+
timer = setTimeout(
|
|
3212
|
+
() => fail(
|
|
3213
|
+
new Error(
|
|
3214
|
+
"AUTH_TIMEOUT: Timed out waiting for the browser sign-in callback. Retry memroot login."
|
|
3215
|
+
)
|
|
3216
|
+
),
|
|
3217
|
+
LOGIN_TIMEOUT_MS
|
|
3218
|
+
);
|
|
3219
|
+
return new Promise((resolve, reject) => {
|
|
3220
|
+
server.once("error", (error) => {
|
|
3221
|
+
fail(error);
|
|
3222
|
+
reject(error);
|
|
3223
|
+
});
|
|
3224
|
+
server.listen(0, "127.0.0.1", () => {
|
|
3225
|
+
const address = server.address();
|
|
3226
|
+
resolve({
|
|
3227
|
+
port: address.port,
|
|
3228
|
+
next: () => {
|
|
3229
|
+
if (failure) return Promise.reject(failure);
|
|
3230
|
+
const queued = pending.shift();
|
|
3231
|
+
if (queued) return Promise.resolve(queued);
|
|
3232
|
+
const result = new Promise(
|
|
3233
|
+
(resolve2, rejectPromise) => waiters.push({ resolve: resolve2, reject: rejectPromise })
|
|
3234
|
+
);
|
|
3235
|
+
result.catch(() => {
|
|
3236
|
+
});
|
|
3237
|
+
return result;
|
|
3238
|
+
},
|
|
3239
|
+
close: () => fail(new Error("AUTH_ABORTED: Sign-in was aborted."))
|
|
3240
|
+
});
|
|
3241
|
+
});
|
|
3242
|
+
});
|
|
3243
|
+
}
|
|
3244
|
+
function openBrowser(url) {
|
|
3245
|
+
const tool = process.platform === "darwin" ? "open" : "xdg-open";
|
|
3246
|
+
try {
|
|
3247
|
+
const child = spawn(tool, [url], {
|
|
3248
|
+
detached: true,
|
|
3249
|
+
stdio: "ignore",
|
|
3250
|
+
shell: false
|
|
3251
|
+
});
|
|
3252
|
+
child.on("error", () => {
|
|
3253
|
+
});
|
|
3254
|
+
child.unref();
|
|
3255
|
+
} catch {
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
var cachedAccessToken;
|
|
3259
|
+
async function getAccessToken(options = {}) {
|
|
3260
|
+
if (cachedAccessToken && cachedAccessToken.expiresAt - Date.now() > REFRESH_SKEW_MS2)
|
|
3261
|
+
return cachedAccessToken.token;
|
|
3262
|
+
const config = await readConfig();
|
|
3263
|
+
const apiBase = resolveApiBase(options, config);
|
|
3264
|
+
const stored = await readSecret(apiBase);
|
|
3265
|
+
if (!stored)
|
|
3266
|
+
throw new Error(
|
|
3267
|
+
"AUTH_REQUIRED: Not signed in. Run memroot login to connect your account."
|
|
3268
|
+
);
|
|
3269
|
+
const clientId = config.clientId && config.clientIdApiBase === apiBase ? config.clientId : STATIC_CLIENT_ID;
|
|
3270
|
+
const tokens = await refreshTokens(apiBase, stored, clientId);
|
|
3271
|
+
if (tokens.refreshToken && tokens.refreshToken !== stored)
|
|
3272
|
+
await storeSecret(apiBase, tokens.refreshToken);
|
|
3273
|
+
const claims = decodeClaims(tokens.accessToken);
|
|
3274
|
+
cachedAccessToken = {
|
|
3275
|
+
token: tokens.accessToken,
|
|
3276
|
+
expiresAt: claimsExpiry(claims) || Date.now() + (tokens.expiresIn ?? 0) * 1e3
|
|
3277
|
+
};
|
|
3278
|
+
if (typeof claims?.sub === "string" && claims.sub !== config.userId) {
|
|
3279
|
+
config.userId = claims.sub;
|
|
3280
|
+
await writeConfig(config);
|
|
3281
|
+
}
|
|
3282
|
+
return tokens.accessToken;
|
|
3283
|
+
}
|
|
3284
|
+
async function tryExistingSession(apiBase, config) {
|
|
3285
|
+
const stored = await readSecret(apiBase);
|
|
3286
|
+
if (!stored) return false;
|
|
3287
|
+
const clientId = config.clientId && config.clientIdApiBase === apiBase ? config.clientId : STATIC_CLIENT_ID;
|
|
3288
|
+
try {
|
|
3289
|
+
const tokens = await refreshTokens(apiBase, stored, clientId);
|
|
3290
|
+
if (tokens.refreshToken && tokens.refreshToken !== stored)
|
|
3291
|
+
await storeSecret(apiBase, tokens.refreshToken);
|
|
3292
|
+
const sub = decodeClaims(tokens.accessToken)?.sub;
|
|
3293
|
+
console.log(
|
|
3294
|
+
`Already signed in to ${apiBase}${typeof sub === "string" ? ` as ${sub}` : ""}. Use --force to re-authenticate.`
|
|
3295
|
+
);
|
|
3296
|
+
return true;
|
|
3297
|
+
} catch (error) {
|
|
3298
|
+
if (error instanceof OAuthError && (error.oauth === "invalid_grant" || error.oauth === "invalid_client")) {
|
|
3299
|
+
await deleteSecret(apiBase).catch(() => {
|
|
3300
|
+
});
|
|
3301
|
+
return false;
|
|
3302
|
+
}
|
|
3303
|
+
throw error;
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
async function authorizePkce(requestOptions) {
|
|
3307
|
+
const { apiBase, resource } = requestOptions;
|
|
3308
|
+
const state = randomBytes3(24).toString("base64url");
|
|
3309
|
+
const verifier = randomBytes3(32).toString("base64url");
|
|
3310
|
+
const challenge = createHash2("sha256").update(verifier).digest("base64url");
|
|
3311
|
+
const waiter = await startCallbackServer(state, apiBase);
|
|
3312
|
+
try {
|
|
3313
|
+
const redirectUri = `http://127.0.0.1:${waiter.port}${CALLBACK_PATH}`;
|
|
3314
|
+
let clientId = requestOptions.clientId;
|
|
3315
|
+
let retriedWithRegistration = false;
|
|
3316
|
+
for (; ; ) {
|
|
3317
|
+
const authorize = new URL(`${apiBase}/authorize`);
|
|
3318
|
+
authorize.searchParams.set("response_type", "code");
|
|
3319
|
+
authorize.searchParams.set("client_id", clientId);
|
|
3320
|
+
authorize.searchParams.set("redirect_uri", redirectUri);
|
|
3321
|
+
authorize.searchParams.set("state", state);
|
|
3322
|
+
authorize.searchParams.set("code_challenge", challenge);
|
|
3323
|
+
authorize.searchParams.set("code_challenge_method", "S256");
|
|
3324
|
+
authorize.searchParams.set("scope", REQUESTED_SCOPE);
|
|
3325
|
+
if (resource) authorize.searchParams.set("resource", resource);
|
|
3326
|
+
const url = authorize.toString();
|
|
3327
|
+
if (requestOptions.noBrowser)
|
|
3328
|
+
console.log(`Open this URL to sign in:
|
|
3329
|
+
${url}`);
|
|
3330
|
+
else {
|
|
3331
|
+
openBrowser(url);
|
|
3332
|
+
console.log(
|
|
3333
|
+
`Opening ${apiBase} for sign-in. If the browser did not open, visit:
|
|
3334
|
+
${url}`
|
|
3335
|
+
);
|
|
3336
|
+
}
|
|
3337
|
+
console.log("Waiting for the browser sign-in callback\u2026");
|
|
3338
|
+
const result = await waiter.next();
|
|
3339
|
+
if (result.kind === "code") {
|
|
3340
|
+
const tokens = await exchangeCode(
|
|
3341
|
+
apiBase,
|
|
3342
|
+
clientId,
|
|
3343
|
+
redirectUri,
|
|
3344
|
+
result.code,
|
|
3345
|
+
verifier,
|
|
3346
|
+
resource
|
|
3347
|
+
);
|
|
3348
|
+
if (!tokens.refreshToken)
|
|
3349
|
+
throw new Error(
|
|
3350
|
+
"AUTH_RESPONSE_INVALID: The token endpoint returned no refresh token."
|
|
3351
|
+
);
|
|
3352
|
+
return { tokens, clientId };
|
|
3353
|
+
}
|
|
3354
|
+
const rejectedClient = result.error === "invalid_client" || result.error === "unauthorized_client";
|
|
3355
|
+
if (rejectedClient && !requestOptions.allowDcr)
|
|
3356
|
+
throw new OAuthError(
|
|
3357
|
+
result.error,
|
|
3358
|
+
"CAPTURE_CLIENT_REJECTED: The server did not accept the Memroot CLI capture client. Update memroot and retry."
|
|
3359
|
+
);
|
|
3360
|
+
if (rejectedClient && !retriedWithRegistration) {
|
|
3361
|
+
retriedWithRegistration = true;
|
|
3362
|
+
const registered = await registerClient(apiBase, redirectUri);
|
|
3363
|
+
if (registered && registered !== clientId) {
|
|
3364
|
+
clientId = registered;
|
|
3365
|
+
console.log(
|
|
3366
|
+
"Client was not recognized; registered a fresh client and retrying sign-in."
|
|
3367
|
+
);
|
|
3368
|
+
continue;
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
if (result.error === "iss_missing" || result.error === "iss_mismatch")
|
|
3372
|
+
throw new Error(
|
|
3373
|
+
"AUTH_ISS_MISMATCH: The callback issuer is missing or does not match the API base. Retry memroot login."
|
|
3374
|
+
);
|
|
3375
|
+
throw new OAuthError(
|
|
3376
|
+
result.error,
|
|
3377
|
+
`AUTH_AUTHORIZATION_FAILED: ${result.error}${result.description ? ` \u2014 ${result.description}` : ""}`
|
|
3378
|
+
);
|
|
3379
|
+
}
|
|
3380
|
+
} finally {
|
|
3381
|
+
waiter.close();
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
async function login(options) {
|
|
3385
|
+
if (process.platform !== "darwin" && process.platform !== "linux")
|
|
3386
|
+
throw new Error(
|
|
3387
|
+
"UNSUPPORTED_PLATFORM: memroot login supports macOS and Linux."
|
|
3388
|
+
);
|
|
3389
|
+
assertCredentialStoreAvailable();
|
|
3390
|
+
const config = await readConfig();
|
|
3391
|
+
const apiBase = resolveApiBase(options, config);
|
|
3392
|
+
if (!options.force && await tryExistingSession(apiBase, config)) return;
|
|
3393
|
+
const { tokens, clientId } = await authorizePkce({
|
|
3394
|
+
apiBase,
|
|
3395
|
+
clientId: config.clientId && config.clientIdApiBase === apiBase ? config.clientId : STATIC_CLIENT_ID,
|
|
3396
|
+
noBrowser: Boolean(options.noBrowser),
|
|
3397
|
+
allowDcr: true
|
|
3398
|
+
});
|
|
3399
|
+
await storeSecret(apiBase, tokens.refreshToken);
|
|
3400
|
+
const claims = decodeClaims(tokens.accessToken);
|
|
3401
|
+
const next = {
|
|
3402
|
+
schemaVersion: 1,
|
|
3403
|
+
apiBase,
|
|
3404
|
+
clientId,
|
|
3405
|
+
clientIdApiBase: apiBase
|
|
3406
|
+
};
|
|
3407
|
+
if (typeof claims?.sub === "string") next.userId = claims.sub;
|
|
3408
|
+
if (config.mcpRegistered) next.mcpRegistered = config.mcpRegistered;
|
|
3409
|
+
await writeConfig(next);
|
|
3410
|
+
console.log(
|
|
3411
|
+
`Signed in to ${apiBase}${next.userId ? ` as ${next.userId}` : ""}.`
|
|
3412
|
+
);
|
|
3413
|
+
console.log(
|
|
3414
|
+
"Refresh token stored in the OS credential store; no tokens were written to files."
|
|
3415
|
+
);
|
|
3416
|
+
}
|
|
3417
|
+
function mcpCredentialAccount(apiBase) {
|
|
3418
|
+
return `${apiBase}#mcp`;
|
|
3419
|
+
}
|
|
3420
|
+
async function loginMcp(apiBase, options = {}) {
|
|
3421
|
+
assertCredentialStoreAvailable();
|
|
3422
|
+
const { tokens } = await authorizePkce({
|
|
3423
|
+
apiBase,
|
|
3424
|
+
clientId: STATIC_CLIENT_ID,
|
|
3425
|
+
noBrowser: Boolean(options.noBrowser),
|
|
3426
|
+
resource: `${apiBase}/mcp`,
|
|
3427
|
+
allowDcr: false
|
|
3428
|
+
});
|
|
3429
|
+
await storeSecret(
|
|
3430
|
+
mcpCredentialAccount(apiBase),
|
|
3431
|
+
tokens.refreshToken
|
|
3432
|
+
);
|
|
3433
|
+
console.log(
|
|
3434
|
+
"Capture connection authorized; its refresh token is stored in the OS credential store."
|
|
3435
|
+
);
|
|
3436
|
+
}
|
|
3437
|
+
async function logout(options) {
|
|
3438
|
+
const config = await readConfig();
|
|
3439
|
+
const apiBase = resolveApiBase(options, config);
|
|
3440
|
+
const stored = await readSecret(apiBase).catch(() => void 0);
|
|
3441
|
+
if (stored) {
|
|
3442
|
+
const clientId = config.clientId && config.clientIdApiBase === apiBase ? config.clientId : STATIC_CLIENT_ID;
|
|
3443
|
+
try {
|
|
3444
|
+
await revokeRefreshToken(apiBase, stored, clientId);
|
|
3445
|
+
} catch (error) {
|
|
3446
|
+
console.error(
|
|
3447
|
+
`AUTH_REVOKE_FAILED: ${error instanceof Error ? error.message : "revocation failed"} Removing local credentials anyway.`
|
|
3448
|
+
);
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
const mcpStored = await readSecret(mcpCredentialAccount(apiBase)).catch(
|
|
3452
|
+
() => void 0
|
|
3453
|
+
);
|
|
3454
|
+
if (mcpStored) {
|
|
3455
|
+
try {
|
|
3456
|
+
await revokeRefreshToken(apiBase, mcpStored, STATIC_CLIENT_ID);
|
|
3457
|
+
} catch (error) {
|
|
3458
|
+
console.error(
|
|
3459
|
+
`AUTH_REVOKE_FAILED: ${error instanceof Error ? error.message : "revocation failed"} Removing the local capture credential anyway.`
|
|
3460
|
+
);
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
for (const account of [apiBase, mcpCredentialAccount(apiBase)]) {
|
|
3464
|
+
try {
|
|
3465
|
+
await deleteSecret(account);
|
|
3466
|
+
} catch (error) {
|
|
3467
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3468
|
+
if (!message.startsWith("AUTH_KEYCHAIN_UNAVAILABLE")) throw error;
|
|
3469
|
+
console.error(message);
|
|
3470
|
+
break;
|
|
3471
|
+
}
|
|
3472
|
+
}
|
|
3473
|
+
cachedAccessToken = void 0;
|
|
3474
|
+
const next = { schemaVersion: 1 };
|
|
3475
|
+
if (config.apiBase) next.apiBase = config.apiBase;
|
|
3476
|
+
if (config.mcpRegistered) next.mcpRegistered = config.mcpRegistered;
|
|
3477
|
+
await writeConfig(next);
|
|
3478
|
+
console.log("Signed out. Stored credentials and client state were removed.");
|
|
3479
|
+
}
|
|
3480
|
+
async function whoami(options) {
|
|
3481
|
+
const config = await readConfig();
|
|
3482
|
+
const apiBase = resolveApiBase(options, config);
|
|
3483
|
+
const token = await getAccessToken({ ...options, apiBase });
|
|
3484
|
+
const claims = decodeClaims(token);
|
|
3485
|
+
if (!claims)
|
|
3486
|
+
throw new Error(
|
|
3487
|
+
"AUTH_RESPONSE_INVALID: Could not decode the access token claims."
|
|
3488
|
+
);
|
|
3489
|
+
console.log(`Signed in to ${apiBase}`);
|
|
3490
|
+
console.log(
|
|
3491
|
+
` sub: ${typeof claims.sub === "string" ? claims.sub : "unknown"}`
|
|
3492
|
+
);
|
|
3493
|
+
console.log(
|
|
3494
|
+
` iss: ${typeof claims.iss === "string" ? claims.iss : "unknown"}`
|
|
3495
|
+
);
|
|
3496
|
+
console.log(
|
|
3497
|
+
` exp: ${claimsExpiry(claims) ? new Date(claimsExpiry(claims)).toISOString() : "unknown"}`
|
|
3498
|
+
);
|
|
3499
|
+
}
|
|
3500
|
+
async function printAccessToken(options) {
|
|
3501
|
+
console.log(await getAccessToken(options));
|
|
3502
|
+
}
|
|
3503
|
+
async function status(options) {
|
|
3504
|
+
const config = await readConfig();
|
|
3505
|
+
const apiBase = resolveApiBase(options, config);
|
|
3506
|
+
console.log(`API base: ${apiBase}`);
|
|
3507
|
+
console.log(`Credential store: ${credentialStoreKind()}`);
|
|
3508
|
+
try {
|
|
3509
|
+
const token = await getAccessToken({ ...options, apiBase });
|
|
3510
|
+
const claims = decodeClaims(token);
|
|
3511
|
+
const subject = typeof claims?.sub === "string" ? claims.sub : config.userId ?? "unknown";
|
|
3512
|
+
const expiry = claimsExpiry(claims) ? `; access token valid until ${new Date(claimsExpiry(claims)).toISOString()}` : "";
|
|
3513
|
+
console.log(`Cloud auth: signed in as ${subject}${expiry}`);
|
|
3514
|
+
} catch (error) {
|
|
3515
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3516
|
+
if (message.startsWith("AUTH_REQUIRED"))
|
|
3517
|
+
console.log("Cloud auth: not signed in. Run memroot login.");
|
|
3518
|
+
else if (message.startsWith("AUTH_EXPIRED"))
|
|
3519
|
+
console.log(
|
|
3520
|
+
"Cloud auth: stored credentials were rejected. Run memroot login."
|
|
3521
|
+
);
|
|
3522
|
+
else console.log(`Cloud auth: ${message}`);
|
|
3523
|
+
}
|
|
3524
|
+
for (const line of await captureReport({ probe: false })) console.log(line);
|
|
3525
|
+
const data = dataDirectory();
|
|
3526
|
+
console.log("Agents:");
|
|
3527
|
+
for (const agent of agents) {
|
|
3528
|
+
const path = executable(agent);
|
|
3529
|
+
let detail = "no plugin receipt";
|
|
3530
|
+
try {
|
|
3531
|
+
const receipt = await readReceipt(data, agent);
|
|
3532
|
+
if (receipt) detail = `plugin installed (${receipt.version})`;
|
|
3533
|
+
} catch {
|
|
3534
|
+
detail = "plugin receipt unreadable";
|
|
3535
|
+
}
|
|
3536
|
+
console.log(
|
|
3537
|
+
` ${labels[agent]}: ${path ? "CLI detected" : "CLI not found"}; ${detail}`
|
|
3538
|
+
);
|
|
3539
|
+
if (agent === "codex" && path) {
|
|
3540
|
+
try {
|
|
3541
|
+
console.log(` ${describeCodexMcp(inspectCodexMcp(path))}`);
|
|
3542
|
+
} catch {
|
|
3543
|
+
console.log(
|
|
3544
|
+
" Codex MCP: status unavailable; memory verification: not verified."
|
|
3545
|
+
);
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3548
|
+
if (agent === "grok" && path) {
|
|
3549
|
+
try {
|
|
3550
|
+
console.log(` ${describeGrokMcp(inspectGrokMcp(path))}`);
|
|
3551
|
+
} catch {
|
|
3552
|
+
console.log(
|
|
3553
|
+
" Grok MCP: status unavailable; memory verification: not verified."
|
|
3554
|
+
);
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
// src/capture/queue/purge.ts
|
|
3561
|
+
import { join as join11 } from "node:path";
|
|
3562
|
+
function removeJobFiles(jobId) {
|
|
3563
|
+
deleteJob(jobId);
|
|
3564
|
+
const work = stateSubdir("work");
|
|
3565
|
+
for (const name of listFiles(work))
|
|
3566
|
+
if (name.startsWith(`${jobId}.`)) removeFile(join11(work, name));
|
|
3567
|
+
}
|
|
3568
|
+
function purgeQueue() {
|
|
3569
|
+
const jobs = listJobIds();
|
|
3570
|
+
for (const jobId of jobs) removeJobFiles(jobId);
|
|
3571
|
+
const work = stateSubdir("work");
|
|
3572
|
+
for (const name of listFiles(work)) removeFile(join11(work, name));
|
|
3573
|
+
return jobs.length;
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
// src/capture/command.ts
|
|
3577
|
+
var CAPTURE_CONSENT_TEXT_V1 = `Memroot session capture (opt-in)
|
|
3578
|
+
|
|
3579
|
+
What runs locally, after a coding session ends:
|
|
3580
|
+
- The plugin's SessionEnd hook only records a small job (session id, transcript path, working directory).
|
|
3581
|
+
- A bundled background worker reads only that session's transcript (user and assistant text, tool names and
|
|
3582
|
+
file paths; never tool output), removes secrets, email addresses and home paths locally, and runs your own
|
|
3583
|
+
agent CLI (Claude Code, Codex or Grok) headless, with no tools, to propose memories. This uses your agent
|
|
3584
|
+
subscription or API quota and sends the redacted excerpt (at most 48,000 characters) to that agent's model
|
|
3585
|
+
provider. Other agents' CLIs are never used unless you pass --allow-cross-agent-extraction.
|
|
3586
|
+
|
|
3587
|
+
What leaves your machine for Memroot (https://api.memroot.dev):
|
|
3588
|
+
- Typed memories (title, summary, assertions, entities such as repo-relative file paths and symbols), labeled
|
|
3589
|
+
session-extracted, not user-attested, with confidence <= 0.7.
|
|
3590
|
+
- Up to 3 short evidence quotes per memory (<= 200 characters each) copied from the redacted transcript.
|
|
3591
|
+
- Your repository identity (normalized git remote such as github.com/org/repo), a salted hash of the session
|
|
3592
|
+
id, salted hashes of the quoted transcript segments, and the extractor's name, prompt version and model.
|
|
3593
|
+
- One retrieval query per session containing only the repository name, to avoid duplicating saved memories.
|
|
3594
|
+
- Raw transcripts are never uploaded. At most 3 memories per session and 10 per rolling 24 hours.
|
|
3595
|
+
|
|
3596
|
+
How to stop:
|
|
3597
|
+
- memroot capture off (stops capture and deletes queued work, including work already in progress),
|
|
3598
|
+
or revoke "Memroot CLI" in the console, or memroot logout.`;
|
|
3599
|
+
async function verifyConnection(apiBase) {
|
|
3600
|
+
const broker = new TokenBroker(apiBase);
|
|
3601
|
+
const status2 = await new McpClient(
|
|
3602
|
+
apiBase,
|
|
3603
|
+
broker,
|
|
3604
|
+
`memroot-cli/${"0.1.0-alpha.3"}`
|
|
3605
|
+
).call("connection_status", {});
|
|
3606
|
+
if (status2?.connected !== true)
|
|
3607
|
+
throw new Error(
|
|
3608
|
+
"CAPTURE_CONNECTION_UNVERIFIED: connection_status did not confirm the connection."
|
|
3609
|
+
);
|
|
3610
|
+
}
|
|
3611
|
+
async function ensureCaptureConnection(apiBase, noBrowser) {
|
|
3612
|
+
const stored = await readSecret(mcpAccount(apiBase));
|
|
3613
|
+
if (stored) {
|
|
3614
|
+
try {
|
|
3615
|
+
await verifyConnection(apiBase);
|
|
3616
|
+
return;
|
|
3617
|
+
} catch (error) {
|
|
3618
|
+
if (!(error instanceof AuthRequiredError)) {
|
|
3619
|
+
if (error instanceof TransportError)
|
|
3620
|
+
throw new Error(
|
|
3621
|
+
`CAPTURE_CONNECTION_FAILED: ${error.code}. Check the network and retry.`
|
|
3622
|
+
);
|
|
3623
|
+
throw error;
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
}
|
|
3627
|
+
await loginMcp(apiBase, { noBrowser });
|
|
3628
|
+
await verifyConnection(apiBase);
|
|
3629
|
+
}
|
|
3630
|
+
async function captureOn(options) {
|
|
3631
|
+
const config = await readConfig();
|
|
3632
|
+
const apiBase = resolveApiBase(options.auth, config);
|
|
3633
|
+
const agents2 = options.agents.length ? options.agents : [...CAPTURE_AGENTS];
|
|
3634
|
+
console.log(CAPTURE_CONSENT_TEXT_V1);
|
|
3635
|
+
console.log(`
|
|
3636
|
+
Agents: ${agents2.join(", ")}
|
|
3637
|
+
`);
|
|
3638
|
+
if (!options.yes) {
|
|
3639
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
3640
|
+
throw new Error(
|
|
3641
|
+
"CONFIRMATION_REQUIRED: Noninteractive capture on requires --yes after reading the disclosure above."
|
|
3642
|
+
);
|
|
3643
|
+
const confirmed = await confirm({
|
|
3644
|
+
message: "Enable automatic session capture for these agents?",
|
|
3645
|
+
initialValue: false
|
|
3646
|
+
});
|
|
3647
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
3648
|
+
console.log("Session capture was not enabled. No changes made.");
|
|
3649
|
+
return;
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
await ensureCaptureConnection(apiBase, options.noBrowser);
|
|
3653
|
+
const previous = readConsent();
|
|
3654
|
+
const base = previous.kind === "ok" ? previous.consent : void 0;
|
|
3655
|
+
const consent = {
|
|
3656
|
+
schemaVersion: 1,
|
|
3657
|
+
enabled: true,
|
|
3658
|
+
enabledAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3659
|
+
consentTextVersion: CONSENT_TEXT_VERSION,
|
|
3660
|
+
apiBase,
|
|
3661
|
+
agents: agents2,
|
|
3662
|
+
crossAgentExtraction: options.allowCrossAgent,
|
|
3663
|
+
includeHeadlessSessions: base?.includeHeadlessSessions ?? false,
|
|
3664
|
+
maxItemsPerSession: base?.maxItemsPerSession ?? DEFAULT_MAX_ITEMS_PER_SESSION,
|
|
3665
|
+
maxItemsPerDay: base?.maxItemsPerDay ?? DEFAULT_MAX_ITEMS_PER_DAY,
|
|
3666
|
+
models: base?.models ?? { claude: null, codex: null, grok: null }
|
|
3667
|
+
};
|
|
3668
|
+
writeConsent(consent);
|
|
3669
|
+
console.log(`Session capture enabled for ${agents2.join(", ")}.`);
|
|
3670
|
+
if (agents2.includes("codex"))
|
|
3671
|
+
console.log(
|
|
3672
|
+
"Codex: open /hooks in Codex and trust the Memroot SessionStart and SessionEnd hooks; until then Codex skips them."
|
|
3673
|
+
);
|
|
3674
|
+
if (agents2.includes("claude"))
|
|
3675
|
+
console.log(
|
|
3676
|
+
"Claude Code: restart running sessions to load the SessionEnd hook."
|
|
3677
|
+
);
|
|
3678
|
+
}
|
|
3679
|
+
function captureOff() {
|
|
3680
|
+
const previous = readConsent();
|
|
3681
|
+
if (previous.kind === "ok")
|
|
3682
|
+
writeConsent({ ...previous.consent, enabled: false });
|
|
3683
|
+
const purged = pathExists(stateDir()) ? purgeQueue() : 0;
|
|
3684
|
+
console.log(
|
|
3685
|
+
`Session capture disabled. Removed ${purged} queued job${purged === 1 ? "" : "s"} and pending candidates.`
|
|
3686
|
+
);
|
|
3687
|
+
console.log(
|
|
3688
|
+
'The server-side "Memroot CLI" connection stays authorized until memroot logout or revocation in the console.'
|
|
3689
|
+
);
|
|
3690
|
+
}
|
|
3691
|
+
async function captureStatus() {
|
|
3692
|
+
for (const line of await captureReport({ probe: true })) console.log(line);
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3695
|
+
// src/claude-mcp.ts
|
|
3696
|
+
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
3697
|
+
var claudeMcpUrl = "https://api.memroot.dev/mcp";
|
|
3698
|
+
function command3(path, args, allowFailure = false) {
|
|
3699
|
+
const result = spawnSync5(path, ["mcp", ...args], {
|
|
3700
|
+
shell: false,
|
|
3701
|
+
encoding: "utf8",
|
|
3702
|
+
timeout: 3e4,
|
|
3703
|
+
maxBuffer: 1024 * 1024,
|
|
3704
|
+
stdio: "pipe"
|
|
3705
|
+
});
|
|
3706
|
+
if (result.error || result.status !== 0 && !allowFailure)
|
|
3707
|
+
throw new Error(
|
|
3708
|
+
"MCP_COMMAND_FAILED: Claude Code MCP command did not complete. Your configuration is preserved; retry memroot setup --agent claude --yes."
|
|
3709
|
+
);
|
|
3710
|
+
return result.status === 0 ? result.stdout ?? "" : "";
|
|
3711
|
+
}
|
|
3712
|
+
function inspectClaudeMcp(path) {
|
|
3713
|
+
const output = command3(path, ["get", "memroot"], true);
|
|
3714
|
+
if (!output) return { configured: false, authorized: false, verified: false };
|
|
3715
|
+
const field = (name) => output.match(new RegExp(`^\\s*${name}:\\s*(.+)$`, "mi"))?.[1]?.trim();
|
|
3716
|
+
if (field("Type") !== "http" || field("URL") !== claudeMcpUrl)
|
|
3717
|
+
throw new Error(
|
|
3718
|
+
"MCP_CONFLICT: Existing memroot MCP configuration differs from production. Inspect it with claude mcp get memroot; setup preserves your settings."
|
|
3719
|
+
);
|
|
3720
|
+
const status2 = field("Status") ?? "";
|
|
3721
|
+
if (/needs authentication/i.test(status2))
|
|
3722
|
+
return { configured: true, authorized: false, verified: false };
|
|
3723
|
+
if (/connected/i.test(status2))
|
|
3724
|
+
return { configured: true, authorized: true, verified: false };
|
|
3725
|
+
throw new Error(
|
|
3726
|
+
"MCP_AUTH_UNKNOWN: Claude Code could not establish memroot MCP status. Resolve it with /mcp in Claude Code; no replacement grant was requested."
|
|
3727
|
+
);
|
|
3728
|
+
}
|
|
3729
|
+
function claudeMcpPreflight(path) {
|
|
3730
|
+
const help2 = command3(path, ["add", "--help"]);
|
|
3731
|
+
if (!help2.includes("--transport") || !help2.includes("--scope"))
|
|
3732
|
+
throw new Error(
|
|
3733
|
+
"MCP_UNSUPPORTED: Update Claude Code to a version supporting remote HTTP MCP setup."
|
|
3734
|
+
);
|
|
3735
|
+
}
|
|
3736
|
+
function ensureClaudeMcp(options) {
|
|
3737
|
+
if (options.dryRun)
|
|
3738
|
+
return { configured: false, authorized: false, verified: false };
|
|
3739
|
+
const path = executable("claude");
|
|
3740
|
+
if (!path)
|
|
3741
|
+
throw new Error(
|
|
3742
|
+
"AGENT_NOT_FOUND: Install Claude Code before linking Memroot."
|
|
3743
|
+
);
|
|
3744
|
+
claudeMcpPreflight(path);
|
|
3745
|
+
let state = inspectClaudeMcp(path);
|
|
3746
|
+
if (!state.configured) {
|
|
3747
|
+
command3(path, [
|
|
3748
|
+
"add",
|
|
3749
|
+
"--scope",
|
|
3750
|
+
"user",
|
|
3751
|
+
"--transport",
|
|
3752
|
+
"http",
|
|
3753
|
+
"memroot",
|
|
3754
|
+
claudeMcpUrl
|
|
3755
|
+
]);
|
|
3756
|
+
state = inspectClaudeMcp(path);
|
|
3757
|
+
}
|
|
3758
|
+
return state;
|
|
3759
|
+
}
|
|
3760
|
+
function describeClaudeMcp(state) {
|
|
3761
|
+
const auth = state.authorized ? "OAuth: authorized" : "OAuth: not authorized. Run /mcp in Claude Code and authenticate memroot";
|
|
3762
|
+
return `Claude Code MCP: ${state.configured ? "configured" : "not configured"}; ${auth}; memory verification: not verified. Ask Claude Code to save and retrieve a test memory.`;
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
// src/messages.ts
|
|
3766
|
+
function setupComplete(selected) {
|
|
3767
|
+
const parts = [];
|
|
3768
|
+
if (selected.includes("claude"))
|
|
3769
|
+
parts.push("Claude Code authorizes its MCP server through /mcp");
|
|
3770
|
+
if (selected.includes("codex")) parts.push("Codex uses native MCP OAuth");
|
|
3771
|
+
if (selected.includes("grok"))
|
|
3772
|
+
parts.push("Grok authorizes its MCP server through /mcps");
|
|
3773
|
+
return `Plugins ready. ${parts.join("; ")}.`;
|
|
3774
|
+
}
|
|
3775
|
+
function cloudNote(selected) {
|
|
3776
|
+
const parts = [];
|
|
3777
|
+
if (selected.includes("claude"))
|
|
3778
|
+
parts.push(
|
|
3779
|
+
"Claude Code setup registers the production MCP server; authorize it with /mcp in Claude Code"
|
|
3780
|
+
);
|
|
3781
|
+
if (selected.includes("codex"))
|
|
3782
|
+
parts.push("Codex setup links production MCP through its browser sign-in");
|
|
3783
|
+
if (selected.includes("grok"))
|
|
3784
|
+
parts.push(
|
|
3785
|
+
"Grok setup registers the production MCP server; authorize it with /mcps in Grok"
|
|
3786
|
+
);
|
|
3787
|
+
return `Cloud memory: ${parts.join(". ")}. Session capture: opt-in (memroot capture on).`;
|
|
3788
|
+
}
|
|
3789
|
+
function installationSummary(selected, data) {
|
|
3790
|
+
return `${selected.map((agent) => labels[agent]).join(", ")}
|
|
3791
|
+
User scope \xB7 ${data}
|
|
3792
|
+
${selected.includes("grok") ? "Grok: trust this local Memroot plugin using native --trust.\n" : ""}${cloudNote(selected)}`;
|
|
3793
|
+
}
|
|
3794
|
+
var codexHookTrustStep = "Next step for Codex: open /hooks in Codex and trust the Memroot SessionStart and SessionEnd hooks. Until you do, Codex skips them, so sessions get no Memroot reminder.";
|
|
3795
|
+
var grokMcpAuthStep = "Next step for Grok: open Grok, run /mcps, select memroot and press i to sign in in the browser. Until you do, Grok has no Memroot memory tools. Skip this if memroot already shows as connected there.";
|
|
3796
|
+
|
|
3797
|
+
// src/index.ts
|
|
3798
|
+
var installationDocs = {
|
|
3799
|
+
claude: "https://code.claude.com/docs/en/quickstart",
|
|
3800
|
+
codex: "https://developers.openai.com/codex/cli",
|
|
3801
|
+
grok: "https://docs.x.ai/build/overview"
|
|
3802
|
+
};
|
|
3803
|
+
var cloudStatus = cloudNote(["claude", "codex", "grok"]);
|
|
3804
|
+
var help = `Memroot ${version} \u2014 native agent plugin installer, cloud sign-in and opt-in session capture
|
|
3805
|
+
|
|
3806
|
+
Usage:
|
|
3807
|
+
npx memroot setup
|
|
3808
|
+
memroot setup --agent codex --yes
|
|
3809
|
+
memroot setup --agent claude --agent grok --yes --dry-run
|
|
3810
|
+
memroot login
|
|
3811
|
+
memroot capture on
|
|
3812
|
+
memroot capture status
|
|
3813
|
+
memroot doctor
|
|
3814
|
+
memroot status
|
|
3815
|
+
|
|
3816
|
+
Commands:
|
|
3817
|
+
setup Install native plugins for your user account
|
|
3818
|
+
doctor Check installer receipts and plugin file integrity
|
|
3819
|
+
login Connect your Memroot account via browser sign-in
|
|
3820
|
+
logout Remove stored cloud and capture credentials
|
|
3821
|
+
whoami Show the signed-in account identity
|
|
3822
|
+
status Show cloud auth, capture and agent install state
|
|
3823
|
+
token Print a fresh access token for agent adapters
|
|
3824
|
+
capture on | off | status \u2014 opt-in session-end memory capture
|
|
3825
|
+
|
|
3826
|
+
Options:
|
|
3827
|
+
--agent claude, codex, or grok; repeat to select more than one (setup, doctor, capture on)
|
|
3828
|
+
--yes Use the explicitly selected agents without prompts (setup, capture on)
|
|
3829
|
+
--dry-run Show planned changes without running agent CLIs or writing files (setup)
|
|
3830
|
+
--capture Enable session capture after setup (runs the capture on flow)
|
|
3831
|
+
--no-capture Disable session capture during setup
|
|
3832
|
+
--allow-cross-agent-extraction
|
|
3833
|
+
Let capture fall back to another installed agent CLI (capture on)
|
|
3834
|
+
--no-browser Print the sign-in URL instead of opening a browser (login, capture on)
|
|
3835
|
+
--force Re-authenticate even if credentials are still valid (login)
|
|
3836
|
+
--env prod or dev; dev targets a local API stub on localhost:8787
|
|
3837
|
+
--api-base Override the API base URL (MEMROOT_API_BASE also works)
|
|
3838
|
+
--help Show help
|
|
3839
|
+
--version Show version
|
|
3840
|
+
|
|
3841
|
+
${cloudStatus}
|
|
3842
|
+
Session capture runs your own agent CLI locally without tools after a session ends,
|
|
3843
|
+
redacts locally and uploads only typed, session-extracted memories.
|
|
3844
|
+
Grok setup trusts the selected local plugin via its native --trust option.
|
|
3845
|
+
Hook execution trust remains controlled by each agent.
|
|
3846
|
+
`;
|
|
3847
|
+
var commands = [
|
|
3848
|
+
"setup",
|
|
3849
|
+
"doctor",
|
|
3850
|
+
"login",
|
|
3851
|
+
"logout",
|
|
3852
|
+
"whoami",
|
|
3853
|
+
"status",
|
|
3854
|
+
"token",
|
|
3855
|
+
"capture"
|
|
3856
|
+
];
|
|
3857
|
+
var authCommands = [
|
|
3858
|
+
"login",
|
|
3859
|
+
"logout",
|
|
3860
|
+
"whoami",
|
|
3861
|
+
"status",
|
|
3862
|
+
"token"
|
|
3863
|
+
];
|
|
3864
|
+
function parse(args) {
|
|
3865
|
+
const command4 = args.shift();
|
|
3866
|
+
if (!command4 || !commands.includes(command4))
|
|
3867
|
+
throw new Error(
|
|
3868
|
+
"INVALID_ARGUMENT: Choose setup, doctor, login, logout, whoami, status, token, or capture. Run memroot --help."
|
|
3869
|
+
);
|
|
3870
|
+
const options = {
|
|
3871
|
+
command: command4,
|
|
3872
|
+
allowCrossAgent: false,
|
|
3873
|
+
selected: [],
|
|
3874
|
+
yes: false,
|
|
3875
|
+
dryRun: false,
|
|
3876
|
+
noBrowser: false,
|
|
3877
|
+
force: false
|
|
3878
|
+
};
|
|
3879
|
+
if (command4 === "capture") {
|
|
3880
|
+
const action = args.shift();
|
|
3881
|
+
if (action !== "on" && action !== "off" && action !== "status")
|
|
3882
|
+
throw new Error(
|
|
3883
|
+
"INVALID_ARGUMENT: Use memroot capture on, off, or status."
|
|
3884
|
+
);
|
|
3885
|
+
options.captureAction = action;
|
|
3886
|
+
}
|
|
3887
|
+
while (args.length) {
|
|
3888
|
+
const flag = args.shift();
|
|
3889
|
+
if (flag === "--yes") options.yes = true;
|
|
3890
|
+
else if (flag === "--capture") options.capture = true;
|
|
3891
|
+
else if (flag === "--no-capture") options.capture = false;
|
|
3892
|
+
else if (flag === "--allow-cross-agent-extraction")
|
|
3893
|
+
options.allowCrossAgent = true;
|
|
3894
|
+
else if (flag === "--dry-run") options.dryRun = true;
|
|
3895
|
+
else if (flag === "--no-browser") options.noBrowser = true;
|
|
3896
|
+
else if (flag === "--force") options.force = true;
|
|
3897
|
+
else if (flag === "--env") {
|
|
3898
|
+
const value = args.shift();
|
|
3899
|
+
if (value !== "prod" && value !== "dev")
|
|
3900
|
+
throw new Error("INVALID_ARGUMENT: --env must be prod or dev.");
|
|
3901
|
+
options.env = value;
|
|
3902
|
+
} else if (flag === "--api-base") {
|
|
3903
|
+
const value = args.shift();
|
|
3904
|
+
try {
|
|
3905
|
+
if (!value) throw new Error("missing");
|
|
3906
|
+
new URL(value);
|
|
3907
|
+
} catch {
|
|
3908
|
+
throw new Error("INVALID_ARGUMENT: --api-base needs a valid URL.");
|
|
3909
|
+
}
|
|
3910
|
+
options.apiBase = value;
|
|
3911
|
+
} else if (flag === "--agent") {
|
|
3912
|
+
const value = args.shift();
|
|
3913
|
+
if (!agents.includes(value))
|
|
3914
|
+
throw new Error("INVALID_AGENT: Use claude, codex, or grok.");
|
|
3915
|
+
if (!options.selected.includes(value))
|
|
3916
|
+
options.selected.push(value);
|
|
3917
|
+
} else
|
|
3918
|
+
throw new Error(
|
|
3919
|
+
`INVALID_ARGUMENT: Unknown option ${flag}. Run memroot --help.`
|
|
3920
|
+
);
|
|
3921
|
+
}
|
|
3922
|
+
if (options.capture !== void 0 && command4 !== "setup")
|
|
3923
|
+
throw new Error(
|
|
3924
|
+
"INVALID_ARGUMENT: --capture and --no-capture apply to setup."
|
|
3925
|
+
);
|
|
3926
|
+
if (options.allowCrossAgent && !(command4 === "capture" && options.captureAction === "on"))
|
|
3927
|
+
throw new Error(
|
|
3928
|
+
"INVALID_ARGUMENT: --allow-cross-agent-extraction applies to capture on."
|
|
3929
|
+
);
|
|
3930
|
+
if (command4 === "capture") {
|
|
3931
|
+
if (options.captureAction !== "on" && (options.selected.length || options.yes || options.noBrowser))
|
|
3932
|
+
throw new Error(
|
|
3933
|
+
`INVALID_ARGUMENT: capture ${options.captureAction} takes no --agent, --yes or --no-browser.`
|
|
3934
|
+
);
|
|
3935
|
+
if (options.dryRun || options.force)
|
|
3936
|
+
throw new Error(
|
|
3937
|
+
"INVALID_ARGUMENT: capture does not support --dry-run or --force."
|
|
3938
|
+
);
|
|
3939
|
+
} else if (authCommands.includes(command4)) {
|
|
3940
|
+
if (options.selected.length || options.yes || options.dryRun)
|
|
3941
|
+
throw new Error(
|
|
3942
|
+
`INVALID_ARGUMENT: ${command4} does not support --agent, --yes, or --dry-run.`
|
|
3943
|
+
);
|
|
3944
|
+
if (command4 !== "login" && (options.noBrowser || options.force))
|
|
3945
|
+
throw new Error(
|
|
3946
|
+
`INVALID_ARGUMENT: ${command4} does not support --no-browser or --force.`
|
|
3947
|
+
);
|
|
3948
|
+
} else if (options.force || (options.noBrowser || options.env || options.apiBase) && !(command4 === "setup" && options.capture === true))
|
|
3949
|
+
throw new Error(
|
|
3950
|
+
`INVALID_ARGUMENT: ${command4} does not support cloud sign-in options.`
|
|
3951
|
+
);
|
|
3952
|
+
if (command4 === "doctor" && (options.yes || options.dryRun))
|
|
3953
|
+
throw new Error("INVALID_ARGUMENT: doctor supports only --agent.");
|
|
3954
|
+
return options;
|
|
3955
|
+
}
|
|
3956
|
+
async function doctor(selected) {
|
|
3957
|
+
const data = dataDirectory();
|
|
3958
|
+
let failures = 0;
|
|
3959
|
+
for (const agent of selected.length ? selected : agents) {
|
|
3960
|
+
const path = executable(agent);
|
|
3961
|
+
try {
|
|
3962
|
+
const receipt = await readReceipt(data, agent);
|
|
3963
|
+
if (!receipt) {
|
|
3964
|
+
console.log(
|
|
3965
|
+
`${labels[agent]}: no installation receipt; CLI ${path ? "detected" : "not found"}.`
|
|
3966
|
+
);
|
|
3967
|
+
continue;
|
|
3968
|
+
}
|
|
3969
|
+
await verifyReceipt(receipt);
|
|
3970
|
+
if (path) {
|
|
3971
|
+
const state = inspectNative(path, agent, receipt.marketplace);
|
|
3972
|
+
if (!state.installed)
|
|
3973
|
+
throw new Error(
|
|
3974
|
+
"PLUGIN_MISSING: Native plugin installation is absent. Run memroot setup again."
|
|
3975
|
+
);
|
|
3976
|
+
}
|
|
3977
|
+
console.log(
|
|
3978
|
+
`${labels[agent]}: plugin files verified (${receipt.version}); CLI ${path ? "detected" : "not found"}. ${path ? "Native plugin present" : "Native presence not verified"}; hook execution trust remains controlled by the agent.`
|
|
3979
|
+
);
|
|
3980
|
+
if (agent === "codex" && path)
|
|
3981
|
+
console.log(describeCodexMcp(inspectCodexMcp(path)));
|
|
3982
|
+
if (agent === "claude" && path)
|
|
3983
|
+
console.log(describeClaudeMcp(inspectClaudeMcp(path)));
|
|
3984
|
+
if (agent === "grok" && path)
|
|
3985
|
+
console.log(describeGrokMcp(inspectGrokMcp(path)));
|
|
3986
|
+
if (!path) failures++;
|
|
3987
|
+
} catch (error) {
|
|
3988
|
+
failures++;
|
|
3989
|
+
console.error(
|
|
3990
|
+
`${labels[agent]}: ${error instanceof Error ? error.message : "check failed"}`
|
|
3991
|
+
);
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
console.log(cloudStatus);
|
|
3995
|
+
for (const line of await captureReport({ probe: false })) console.log(line);
|
|
3996
|
+
if (failures) process.exitCode = 1;
|
|
3997
|
+
}
|
|
3998
|
+
async function setup(options) {
|
|
3999
|
+
if (process.platform !== "darwin" && process.platform !== "linux")
|
|
4000
|
+
throw new Error(
|
|
4001
|
+
"UNSUPPORTED_PLATFORM: This installer alpha supports macOS and Linux. Windows support is not available yet."
|
|
4002
|
+
);
|
|
4003
|
+
const found = new Map(agents.map((agent) => [agent, executable(agent)]));
|
|
4004
|
+
const interactive = Boolean(
|
|
4005
|
+
process.stdin.isTTY && process.stdout.isTTY && !options.yes && !options.dryRun
|
|
4006
|
+
);
|
|
4007
|
+
if (interactive) {
|
|
1588
4008
|
intro(`Memroot ${version}`);
|
|
1589
4009
|
note(
|
|
1590
|
-
"Install Memroot plugins for your coding agents.\
|
|
4010
|
+
"Install Memroot plugins for your coding agents.\nProduction memory is linked through each agent's native sign-in.",
|
|
1591
4011
|
"Set up your agents"
|
|
1592
4012
|
);
|
|
1593
4013
|
if (!options.selected.length) {
|
|
@@ -1622,9 +4042,7 @@ async function setup(options) {
|
|
|
1622
4042
|
"CONFIRMATION_REQUIRED: Noninteractive setup requires --yes with explicit --agent selections."
|
|
1623
4043
|
);
|
|
1624
4044
|
const data = dataDirectory();
|
|
1625
|
-
const summary =
|
|
1626
|
-
User scope \xB7 ${data}
|
|
1627
|
-
${options.selected.includes("grok") ? "Grok: trust this local Memroot plugin using native --trust.\n" : ""}${cloudStatus}`;
|
|
4045
|
+
const summary = installationSummary(options.selected, data);
|
|
1628
4046
|
if (options.dryRun) {
|
|
1629
4047
|
console.log(`Dry run \u2014 no changes
|
|
1630
4048
|
${summary}`);
|
|
@@ -1632,6 +4050,26 @@ ${summary}`);
|
|
|
1632
4050
|
console.log(
|
|
1633
4051
|
`${agent}: ${JSON.stringify(installCommands(agent, `${data}/releases/${version}/${agent}`))}`
|
|
1634
4052
|
);
|
|
4053
|
+
if (options.selected.includes("claude"))
|
|
4054
|
+
console.log(
|
|
4055
|
+
`claude: mcp add --scope user --transport http memroot ${claudeMcpUrl}; authorize with /mcp.`
|
|
4056
|
+
);
|
|
4057
|
+
if (options.selected.includes("codex"))
|
|
4058
|
+
console.log(
|
|
4059
|
+
`codex: mcp add memroot --url ${codexMcpUrl} --oauth-resource ${codexMcpUrl}; reuse existing OAuth authorization.`
|
|
4060
|
+
);
|
|
4061
|
+
if (options.selected.includes("grok"))
|
|
4062
|
+
console.log(
|
|
4063
|
+
`grok: mcp add --scope user --transport http memroot ${grokMcpUrl}; authorize with /mcps in Grok.`
|
|
4064
|
+
);
|
|
4065
|
+
if (options.selected.includes("codex")) console.log(codexHookTrustStep);
|
|
4066
|
+
if (options.selected.includes("grok")) console.log(grokMcpAuthStep);
|
|
4067
|
+
if (options.capture === true)
|
|
4068
|
+
console.log(
|
|
4069
|
+
`capture: enable session capture for ${options.selected.join(", ")} (browser authorization of the Memroot CLI capture connection).`
|
|
4070
|
+
);
|
|
4071
|
+
else if (options.capture === false)
|
|
4072
|
+
console.log("capture: disable session capture.");
|
|
1635
4073
|
return;
|
|
1636
4074
|
}
|
|
1637
4075
|
if (interactive) {
|
|
@@ -1663,23 +4101,147 @@ ${summary}`);
|
|
|
1663
4101
|
console.log(
|
|
1664
4102
|
`${labels[agent]}: already installed; plugin files verified. Check native enablement in the agent.`
|
|
1665
4103
|
);
|
|
4104
|
+
if (agent === "codex")
|
|
4105
|
+
console.log(
|
|
4106
|
+
describeCodexMcp(
|
|
4107
|
+
await recordMcp("codex", () => ensureCodexMcp({ dryRun: false }))
|
|
4108
|
+
)
|
|
4109
|
+
);
|
|
4110
|
+
if (agent === "claude")
|
|
4111
|
+
console.log(
|
|
4112
|
+
describeClaudeMcp(
|
|
4113
|
+
await recordMcp(
|
|
4114
|
+
"claude",
|
|
4115
|
+
async () => ensureClaudeMcp({ dryRun: false })
|
|
4116
|
+
)
|
|
4117
|
+
)
|
|
4118
|
+
);
|
|
4119
|
+
if (agent === "grok")
|
|
4120
|
+
console.log(
|
|
4121
|
+
describeGrokMcp(
|
|
4122
|
+
await recordMcp(
|
|
4123
|
+
"grok",
|
|
4124
|
+
async () => ensureGrokMcp({ dryRun: false })
|
|
4125
|
+
)
|
|
4126
|
+
)
|
|
4127
|
+
);
|
|
1666
4128
|
continue;
|
|
1667
4129
|
}
|
|
1668
|
-
const
|
|
1669
|
-
if (
|
|
1670
|
-
|
|
1671
|
-
|
|
4130
|
+
const commands2 = installCommands(agent, candidate.marketplace);
|
|
4131
|
+
if (agent === "codex" && receipt && state.registered && !state.currentMarketplace) {
|
|
4132
|
+
const removeHelp = native(found.get(agent), agent, [
|
|
4133
|
+
"plugin",
|
|
4134
|
+
"marketplace",
|
|
4135
|
+
"remove",
|
|
4136
|
+
"--help"
|
|
4137
|
+
]);
|
|
4138
|
+
if (!removeHelp.includes("--json"))
|
|
4139
|
+
throw new Error(
|
|
4140
|
+
"NATIVE_COMMAND_UNSUPPORTED: Update Codex to support marketplace remove --json before upgrading Memroot. The previous installation is preserved."
|
|
4141
|
+
);
|
|
4142
|
+
native(found.get(agent), agent, [
|
|
4143
|
+
"plugin",
|
|
4144
|
+
"marketplace",
|
|
4145
|
+
"remove",
|
|
4146
|
+
"memroot",
|
|
4147
|
+
"--json"
|
|
4148
|
+
]);
|
|
4149
|
+
}
|
|
4150
|
+
if (state.currentMarketplace) commands2.shift();
|
|
4151
|
+
for (const args of commands2)
|
|
1672
4152
|
native(found.get(agent), agent, args);
|
|
1673
4153
|
await writeReceipt(data, candidate);
|
|
1674
4154
|
console.log(
|
|
1675
4155
|
`${labels[agent]}: plugin installed. Restart the agent to load it.`
|
|
1676
4156
|
);
|
|
4157
|
+
if (agent === "codex")
|
|
4158
|
+
console.log(
|
|
4159
|
+
describeCodexMcp(
|
|
4160
|
+
await recordMcp("codex", () => ensureCodexMcp({ dryRun: false }))
|
|
4161
|
+
)
|
|
4162
|
+
);
|
|
4163
|
+
if (agent === "claude")
|
|
4164
|
+
console.log(
|
|
4165
|
+
describeClaudeMcp(
|
|
4166
|
+
await recordMcp(
|
|
4167
|
+
"claude",
|
|
4168
|
+
async () => ensureClaudeMcp({ dryRun: false })
|
|
4169
|
+
)
|
|
4170
|
+
)
|
|
4171
|
+
);
|
|
4172
|
+
if (agent === "grok")
|
|
4173
|
+
console.log(
|
|
4174
|
+
describeGrokMcp(
|
|
4175
|
+
await recordMcp(
|
|
4176
|
+
"grok",
|
|
4177
|
+
async () => ensureGrokMcp({ dryRun: false })
|
|
4178
|
+
)
|
|
4179
|
+
)
|
|
4180
|
+
);
|
|
1677
4181
|
}
|
|
1678
4182
|
} finally {
|
|
1679
4183
|
await release();
|
|
1680
4184
|
}
|
|
1681
|
-
|
|
1682
|
-
|
|
4185
|
+
await setupCapture(options, interactive);
|
|
4186
|
+
if (options.selected.includes("codex")) console.log(codexHookTrustStep);
|
|
4187
|
+
if (options.selected.includes("grok")) console.log(grokMcpAuthStep);
|
|
4188
|
+
if (interactive) outro(setupComplete(options.selected));
|
|
4189
|
+
else console.log(setupComplete(options.selected));
|
|
4190
|
+
}
|
|
4191
|
+
async function setupCapture(options, interactive) {
|
|
4192
|
+
const auth = authOptions(options);
|
|
4193
|
+
if (options.capture === false) {
|
|
4194
|
+
captureOff();
|
|
4195
|
+
return;
|
|
4196
|
+
}
|
|
4197
|
+
if (options.capture === true) {
|
|
4198
|
+
await captureOn({
|
|
4199
|
+
agents: options.selected,
|
|
4200
|
+
allowCrossAgent: false,
|
|
4201
|
+
yes: true,
|
|
4202
|
+
noBrowser: options.noBrowser,
|
|
4203
|
+
auth
|
|
4204
|
+
});
|
|
4205
|
+
return;
|
|
4206
|
+
}
|
|
4207
|
+
if (!interactive) return;
|
|
4208
|
+
note(CAPTURE_CONSENT_TEXT_V1, "Session capture");
|
|
4209
|
+
const enable = await confirm({
|
|
4210
|
+
message: "Enable automatic session capture? You can change this later with memroot capture on|off.",
|
|
4211
|
+
initialValue: false
|
|
4212
|
+
});
|
|
4213
|
+
if (isCancel(enable) || !enable) return;
|
|
4214
|
+
await captureOn({
|
|
4215
|
+
agents: options.selected,
|
|
4216
|
+
allowCrossAgent: false,
|
|
4217
|
+
yes: true,
|
|
4218
|
+
noBrowser: false,
|
|
4219
|
+
auth
|
|
4220
|
+
});
|
|
4221
|
+
}
|
|
4222
|
+
async function recordMcp(agent, ensure) {
|
|
4223
|
+
let state;
|
|
4224
|
+
try {
|
|
4225
|
+
state = await ensure();
|
|
4226
|
+
return state;
|
|
4227
|
+
} finally {
|
|
4228
|
+
const config = await readConfig();
|
|
4229
|
+
const registered = { ...config.mcpRegistered };
|
|
4230
|
+
if (state?.configured) registered[agent] = true;
|
|
4231
|
+
else delete registered[agent];
|
|
4232
|
+
if (registered.claude || registered.codex || registered.grok)
|
|
4233
|
+
config.mcpRegistered = registered;
|
|
4234
|
+
else delete config.mcpRegistered;
|
|
4235
|
+
await writeConfig(config);
|
|
4236
|
+
}
|
|
4237
|
+
}
|
|
4238
|
+
function authOptions(options) {
|
|
4239
|
+
const auth = {};
|
|
4240
|
+
if (options.env) auth.env = options.env;
|
|
4241
|
+
if (options.apiBase) auth.apiBase = options.apiBase;
|
|
4242
|
+
if (options.noBrowser) auth.noBrowser = true;
|
|
4243
|
+
if (options.force) auth.force = true;
|
|
4244
|
+
return auth;
|
|
1683
4245
|
}
|
|
1684
4246
|
async function main() {
|
|
1685
4247
|
const args = process.argv.slice(2);
|
|
@@ -1692,12 +4254,31 @@ async function main() {
|
|
|
1692
4254
|
return;
|
|
1693
4255
|
}
|
|
1694
4256
|
const options = parse(args);
|
|
1695
|
-
|
|
1696
|
-
|
|
4257
|
+
const auth = authOptions(options);
|
|
4258
|
+
if (options.command === "capture") {
|
|
4259
|
+
if (options.captureAction === "on")
|
|
4260
|
+
await captureOn({
|
|
4261
|
+
agents: options.selected,
|
|
4262
|
+
allowCrossAgent: options.allowCrossAgent,
|
|
4263
|
+
yes: options.yes,
|
|
4264
|
+
noBrowser: options.noBrowser,
|
|
4265
|
+
auth
|
|
4266
|
+
});
|
|
4267
|
+
else if (options.captureAction === "off") captureOff();
|
|
4268
|
+
else await captureStatus();
|
|
4269
|
+
return;
|
|
4270
|
+
}
|
|
4271
|
+
if (options.command === "setup") await setup(options);
|
|
4272
|
+
else if (options.command === "doctor") await doctor(options.selected);
|
|
4273
|
+
else if (options.command === "login") await login(auth);
|
|
4274
|
+
else if (options.command === "logout") await logout(auth);
|
|
4275
|
+
else if (options.command === "whoami") await whoami(auth);
|
|
4276
|
+
else if (options.command === "status") await status(auth);
|
|
4277
|
+
else await printAccessToken(auth);
|
|
1697
4278
|
}
|
|
1698
4279
|
main().catch((error) => {
|
|
1699
4280
|
console.error(
|
|
1700
|
-
error instanceof Error ? error.message : "
|
|
4281
|
+
error instanceof Error ? error.message : "MEMROOT_FAILED: Unexpected CLI failure."
|
|
1701
4282
|
);
|
|
1702
4283
|
process.exitCode = 1;
|
|
1703
4284
|
});
|