@lore-co/cli 0.2.22 → 0.2.23

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/dist/cli.js CHANGED
@@ -12,11 +12,12 @@ import { withDirectoryLock } from "./reliability-store.js";
12
12
  import { runConfigCommand } from "./config-command.js";
13
13
  import { codexApprovalDetail, inspectCodexHookApproval } from "./codex-hook-approval.js";
14
14
  import { claudeOnlyCommand, disableDevinCli, runDevinCliHook } from "./devin-cli.js";
15
+ import { LORE_MCP_SERVER_NAME, MCP_HOST_AGENTS, inspectCodexMcp, isLoreMcpServer, loreMcpServerEntry, mergeLoreCodexMcp, mergeLoreMcpServers, removeLoreCodexMcp, removeLoreMcpServers, } from "./mcp-config.js";
15
16
  import { access, chmod, copyFile, mkdir, open, readFile, readdir, rename, rm, stat, writeFile, } from "node:fs/promises";
16
17
  import { constants as fsConstants, realpathSync } from "node:fs";
17
18
  import { spawn, isWindows, windowsCommand, commandText, findExecutable, openBrowser } from "./platform.js";
18
19
  import { createHash, randomUUID } from "node:crypto";
19
- import { dirname, resolve } from "node:path";
20
+ import { basename, dirname, resolve } from "node:path";
20
21
  import { homedir, hostname, platform } from "node:os";
21
22
  import { setTimeout as delay } from "node:timers/promises";
22
23
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -25,6 +26,7 @@ import { runHook, } from "./runtime.js";
25
26
  import { readInvocationHealthWriteInput, runInvocationHealthWrite, } from "./invocation-health-writer.js";
26
27
  import { runAskCommand } from "./ask.js";
27
28
  import { runLearningsCommand } from "./learnings.js";
29
+ import { runReviewCommand } from "./review.js";
28
30
  import { runEmbeddingsCommand } from "./embeddings.js";
29
31
  import { runRememberCommand } from "./remember.js";
30
32
  import { installLoreSkill, removeLoreSkill } from "./skills.js";
@@ -398,6 +400,34 @@ export function removeLoreCursorMcp(input) {
398
400
  const { [CURSOR_MCP_NAME]: _lore, ...rest } = config.mcpServers;
399
401
  return { ...config, mcpServers: rest };
400
402
  }
403
+ function isMcpHostAgent(agent) {
404
+ return MCP_HOST_AGENTS.includes(agent);
405
+ }
406
+ /** The full Lore MCP server, run by this CLI so it shares the hooks' credentials and version. */
407
+ export function loreMcpServer(paths) {
408
+ return loreMcpServerEntry([process.execPath, ...(IS_STANDALONE_BINARY ? [] : [fileURLToPath(import.meta.url)])], paths.home);
409
+ }
410
+ /** Where each agent lists MCP servers, located from the hooks file Lore already manages. */
411
+ export function mcpConfigPath(agent, hooksPath) {
412
+ const directory = dirname(hooksPath);
413
+ switch (agent) {
414
+ case "claude":
415
+ // ~/.claude/settings.json pairs with ~/.claude.json; a CLAUDE_CONFIG_DIR keeps both inside it.
416
+ return basename(directory) === ".claude"
417
+ ? resolve(directory, "..", ".claude.json")
418
+ : resolve(directory, ".claude.json");
419
+ case "codex":
420
+ return resolve(directory, "config.toml");
421
+ case "cursor":
422
+ return resolve(directory, "mcp.json");
423
+ }
424
+ }
425
+ export function mergeLoreMcp(input, agent, paths) {
426
+ return mergeLoreMcpServers(objectConfiguration(input, agent), loreMcpServer(paths), agent);
427
+ }
428
+ export function removeLoreMcp(input, agent) {
429
+ return removeLoreMcpServers(objectConfiguration(input, agent));
430
+ }
401
431
  function stripLoreFromCursorEvent(value) {
402
432
  return Array.isArray(value) ? value.filter((hook) => !isLoreHook(hook)) : [];
403
433
  }
@@ -920,6 +950,39 @@ async function writeMergedJson(path, document, value, now) {
920
950
  ...(backup === undefined ? {} : { backup }),
921
951
  };
922
952
  }
953
+ async function readTextDocument(path) {
954
+ try {
955
+ const [value, metadata] = await Promise.all([
956
+ readFile(path, "utf8"),
957
+ stat(path),
958
+ ]);
959
+ return { exists: true, value, mode: metadata.mode & 0o777 };
960
+ }
961
+ catch (error) {
962
+ const code = typeof error === "object" && error !== null && "code" in error
963
+ ? error.code
964
+ : undefined;
965
+ if (code === "ENOENT") {
966
+ return { exists: false, value: "", mode: 0o600 };
967
+ }
968
+ throw error;
969
+ }
970
+ }
971
+ async function writeMergedText(path, document, value, now) {
972
+ if (document.exists && document.value === value) {
973
+ return { changed: false };
974
+ }
975
+ let backup;
976
+ if (document.exists) {
977
+ backup = `${path}.lore-backup-${backupTimestamp(now)}-${randomUUID().slice(0, 8)}`;
978
+ await copyFile(path, backup, fsConstants.COPYFILE_EXCL);
979
+ }
980
+ await atomicWrite(path, value, document.mode);
981
+ return {
982
+ changed: true,
983
+ ...(backup === undefined ? {} : { backup }),
984
+ };
985
+ }
923
986
  function parseConnectorConfig(value) {
924
987
  if (!isObject(value) ||
925
988
  (value.version !== 1 && value.version !== 2) ||
@@ -1145,7 +1208,8 @@ function objectConfiguration(input, agent) {
1145
1208
  const AGENT_TARGETS = {
1146
1209
  claude: {
1147
1210
  integration: "hooks",
1148
- expected: HOOK_EVENTS.length,
1211
+ // Native hooks plus the Lore MCP server entry.
1212
+ expected: HOOK_EVENTS.length + 1,
1149
1213
  runtimeRequired: true,
1150
1214
  emptyValue: () => ({}),
1151
1215
  configPath: (paths) => paths.claudeSettings,
@@ -1157,7 +1221,7 @@ const AGENT_TARGETS = {
1157
1221
  },
1158
1222
  codex: {
1159
1223
  integration: "hooks",
1160
- expected: HOOK_EVENTS.length,
1224
+ expected: HOOK_EVENTS.length + 1,
1161
1225
  runtimeRequired: true,
1162
1226
  emptyValue: () => ({}),
1163
1227
  configPath: (paths) => paths.codexHooks,
@@ -1193,7 +1257,8 @@ const AGENT_TARGETS = {
1193
1257
  },
1194
1258
  cursor: {
1195
1259
  integration: "hooks",
1196
- expected: CURSOR_HOOK_EVENTS.length + 1,
1260
+ // Hooks, the capture bridge, and the Lore MCP server entry.
1261
+ expected: CURSOR_HOOK_EVENTS.length + 2,
1197
1262
  runtimeRequired: true,
1198
1263
  emptyValue: () => ({}),
1199
1264
  configPath: (paths) => paths.cursorHooks,
@@ -1548,6 +1613,99 @@ function mergeAgentConfig(input, agent, paths) {
1548
1613
  function removeAgentConfig(input, agent) {
1549
1614
  return targetFor(agent).remove(input);
1550
1615
  }
1616
+ /**
1617
+ * Read each installation's hooks file and, for MCP hosts, its MCP config, and
1618
+ * merge the Lore entries this CLI version writes. Nothing touches disk yet.
1619
+ */
1620
+ async function prepareAgentEntries(installations, paths) {
1621
+ const documents = new Map();
1622
+ const mergedDocuments = new Map();
1623
+ const mcpDocuments = new Map();
1624
+ for (const installation of installations) {
1625
+ const key = installationKey(installation);
1626
+ const document = await readJsonDocument(installation.configPath);
1627
+ documents.set(key, document);
1628
+ mergedDocuments.set(key, mergeAgentConfig(document.value, installation.agent, paths));
1629
+ if (isMcpHostAgent(installation.agent)) {
1630
+ const mcpPath = mcpConfigPath(installation.agent, installation.configPath);
1631
+ if (installation.agent === "codex") {
1632
+ const text = await readTextDocument(mcpPath);
1633
+ mcpDocuments.set(mcpPath, { kind: "text", document: text, merged: mergeLoreCodexMcp(text.value, loreMcpServer(paths)) });
1634
+ }
1635
+ else {
1636
+ const mcp = await readJsonDocument(mcpPath);
1637
+ const base = mcp.exists ? mcp.value : {};
1638
+ // Cursor keeps its capture bridge next to the shared tool server.
1639
+ const withBridge = installation.agent === "cursor" ? mergeLoreCursorMcp(base, paths) : base;
1640
+ mcpDocuments.set(mcpPath, { kind: "json", document: mcp, merged: mergeLoreMcp(withBridge, installation.agent, paths) });
1641
+ }
1642
+ }
1643
+ }
1644
+ return { installations: [...installations], documents, mergedDocuments, mcpDocuments };
1645
+ }
1646
+ /**
1647
+ * Install the hook runtime when an installation needs it, then write the
1648
+ * merged files. Unchanged files are left alone; changed ones get a backup.
1649
+ */
1650
+ async function writeAgentEntries(entries, paths, now) {
1651
+ if (entries.installations.some((installation) => targetFor(installation.agent).runtimeRequired)) {
1652
+ await installRuntime(paths);
1653
+ }
1654
+ const changedFiles = [];
1655
+ const backups = [];
1656
+ for (const [path, entry] of entries.mcpDocuments) {
1657
+ const result = entry.kind === "json"
1658
+ ? await writeMergedJson(path, entry.document, entry.merged, now)
1659
+ : await writeMergedText(path, entry.document, entry.merged, now);
1660
+ if (result.changed)
1661
+ changedFiles.push(path);
1662
+ if (result.backup)
1663
+ backups.push(result.backup);
1664
+ }
1665
+ for (const installation of entries.installations) {
1666
+ const key = installationKey(installation);
1667
+ const document = entries.documents.get(key);
1668
+ const merged = entries.mergedDocuments.get(key);
1669
+ if (document === undefined || merged === undefined) {
1670
+ continue;
1671
+ }
1672
+ const result = await writeMergedJson(installation.configPath, document, merged, now);
1673
+ if (result.changed) {
1674
+ changedFiles.push(installation.configPath);
1675
+ }
1676
+ if (result.backup !== undefined) {
1677
+ backups.push(result.backup);
1678
+ }
1679
+ }
1680
+ return { changedFiles, backups };
1681
+ }
1682
+ /**
1683
+ * Re-apply the Lore-owned hook and MCP entries and the `lore` skill for the
1684
+ * agents recorded in the stored connection, for example after `lore update`
1685
+ * replaced the binary. Nothing is authorized again and folder rules are
1686
+ * untouched. Resolves to null when no connection is stored.
1687
+ */
1688
+ export async function refreshAgentEntries(paths = getLorePaths()) {
1689
+ const config = await readConnectorConfig(paths);
1690
+ if (config === null)
1691
+ return null;
1692
+ const installations = uniqueInstallations(configuredInstallations(config, paths));
1693
+ return withDirectoryLock(resolve(paths.loreDirectory, "connections.lock"), async () => {
1694
+ const entries = await prepareAgentEntries(installations, paths);
1695
+ const written = await writeAgentEntries(entries, paths, new Date());
1696
+ const skills = await installLoreSkill(paths, installations);
1697
+ return {
1698
+ // Same order the files are written in, so the two update lines match.
1699
+ configFiles: [
1700
+ ...entries.mcpDocuments.keys(),
1701
+ ...installations.map((installation) => installation.configPath),
1702
+ ],
1703
+ ...written,
1704
+ skillFiles: skills.files,
1705
+ warnings: skills.warnings,
1706
+ };
1707
+ });
1708
+ }
1551
1709
  function countAgentIntegrations(input, agent) {
1552
1710
  const target = targetFor(agent);
1553
1711
  return target.count(input === undefined ? target.emptyValue() : input);
@@ -1773,20 +1931,9 @@ async function connectWithCredentialLocked(options, identity) {
1773
1931
  throw new Error("No Claude, Codex, Copilot CLI, Cursor, OpenCode, or Polytoken installation detected. Use --agent <name>; T3 Code users can use --agent t3code, and Devin CLI users --agent devin.");
1774
1932
  }
1775
1933
  const now = new Date();
1776
- const documents = new Map();
1777
- const mergedDocuments = new Map();
1778
- const cursorMcpDocuments = new Map();
1779
- for (const installation of installations) {
1780
- const key = installationKey(installation);
1781
- const document = await readJsonDocument(installation.configPath);
1782
- documents.set(key, document);
1783
- mergedDocuments.set(key, mergeAgentConfig(document.value, installation.agent, paths));
1784
- if (installation.agent === "cursor") {
1785
- const mcpPath = resolve(dirname(installation.configPath), "mcp.json");
1786
- const mcp = await readJsonDocument(mcpPath);
1787
- cursorMcpDocuments.set(mcpPath, { document: mcp, merged: mergeLoreCursorMcp(mcp.exists ? mcp.value : {}, paths) });
1788
- }
1789
- }
1934
+ // Read and merge before anything is persisted so an unreadable agent file
1935
+ // stops the run without a half-written connection.
1936
+ const entries = await prepareAgentEntries(installations, paths);
1790
1937
  const agentConfigPaths = {};
1791
1938
  for (const agent of agents) {
1792
1939
  agentConfigPaths[agent] = installations
@@ -1812,33 +1959,7 @@ async function connectWithCredentialLocked(options, identity) {
1812
1959
  await reliabilityStore.initialize();
1813
1960
  // Folder restrictions must be durable before any new hook can run.
1814
1961
  await persistConnection(paths, config, connection.profiles);
1815
- if (installations.some((installation) => targetFor(installation.agent).runtimeRequired)) {
1816
- await installRuntime(paths);
1817
- }
1818
- const changedHookFiles = [];
1819
- const backups = [];
1820
- for (const [path, { document, merged }] of cursorMcpDocuments) {
1821
- const result = await writeMergedJson(path, document, merged, now);
1822
- if (result.changed)
1823
- changedHookFiles.push(path);
1824
- if (result.backup)
1825
- backups.push(result.backup);
1826
- }
1827
- for (const installation of installations) {
1828
- const key = installationKey(installation);
1829
- const document = documents.get(key);
1830
- const merged = mergedDocuments.get(key);
1831
- if (document === undefined || merged === undefined) {
1832
- continue;
1833
- }
1834
- const result = await writeMergedJson(installation.configPath, document, merged, now);
1835
- if (result.changed) {
1836
- changedHookFiles.push(installation.configPath);
1837
- }
1838
- if (result.backup !== undefined) {
1839
- backups.push(result.backup);
1840
- }
1841
- }
1962
+ const { changedFiles: changedHookFiles, backups } = await writeAgentEntries(entries, paths, now);
1842
1963
  if (existing && !existing.connectionId && existing.apiUrl === apiUrl && existing.token === token) {
1843
1964
  await new ReliabilityStore(identity.workspaceId, { home: paths.home }).transferPendingTo(reliabilityStore);
1844
1965
  await reliabilityStore.migrateLegacyQueue(paths.queue);
@@ -2024,11 +2145,11 @@ async function acknowledgeDeviceAuthorization(apiUrl, authorization) {
2024
2145
  export function connectNextStep(agent) {
2025
2146
  switch (agent) {
2026
2147
  case "claude":
2027
- return "Restart Claude Code.";
2148
+ return "Restart Claude Code and allow the lore MCP server when it asks.";
2028
2149
  case "codex":
2029
2150
  return "Restart Codex, open /hooks, approve Lore's hooks, then start a fresh session.";
2030
2151
  case "cursor":
2031
- return "Restart Cursor and enable the lore-native MCP server (lore_context and lore_complete).";
2152
+ return "Restart Cursor and enable the lore-native and lore MCP servers.";
2032
2153
  case "opencode":
2033
2154
  return "Restart OpenCode.";
2034
2155
  case "copilot":
@@ -2350,13 +2471,30 @@ async function getAgentStatus(agent, config, paths, runtime) {
2350
2471
  units.push(...inspectInstallationUnits(document.value === undefined
2351
2472
  ? target.emptyValue()
2352
2473
  : document.value, agent, paths, index, configPaths.length));
2353
- if (agent === "cursor") {
2354
- const mcp = await readJsonDocument(resolve(dirname(path), "mcp.json"));
2355
- const value = isObject(mcp.value) && isObject(mcp.value.mcpServers) ? mcp.value.mcpServers[CURSOR_MCP_NAME] : undefined;
2356
- const owned = Number(isLoreCursorMcp(value));
2357
- installed += owned;
2358
- units.push({ id: installationUnitId("mcp", index, configPaths.length),
2359
- state: installationUnitState(owned, Number(exactConfiguration(value, cursorMcpServer(paths)))) });
2474
+ if (isMcpHostAgent(agent)) {
2475
+ const mcpPath = mcpConfigPath(agent, path);
2476
+ if (agent === "codex") {
2477
+ const state = inspectCodexMcp((await readTextDocument(mcpPath)).value, loreMcpServer(paths));
2478
+ installed += state.owned;
2479
+ units.push({ id: installationUnitId("mcp-tools", index, configPaths.length),
2480
+ state: installationUnitState(state.owned, Number(state.exact)) });
2481
+ }
2482
+ else {
2483
+ const mcp = await readJsonDocument(mcpPath);
2484
+ const servers = isObject(mcp.value) && isObject(mcp.value.mcpServers) ? mcp.value.mcpServers : {};
2485
+ if (agent === "cursor") {
2486
+ const bridge = servers[CURSOR_MCP_NAME];
2487
+ const owned = Number(isLoreCursorMcp(bridge));
2488
+ installed += owned;
2489
+ units.push({ id: installationUnitId("mcp", index, configPaths.length),
2490
+ state: installationUnitState(owned, Number(exactConfiguration(bridge, cursorMcpServer(paths)))) });
2491
+ }
2492
+ const value = servers[LORE_MCP_SERVER_NAME];
2493
+ const owned = Number(isLoreMcpServer(value));
2494
+ installed += owned;
2495
+ units.push({ id: installationUnitId("mcp-tools", index, configPaths.length),
2496
+ state: installationUnitState(owned, Number(exactConfiguration(value, loreMcpServer(paths)))) });
2497
+ }
2360
2498
  }
2361
2499
  }
2362
2500
  catch {
@@ -2518,15 +2656,29 @@ export async function disconnectInstallation(paths = getLorePaths(), preservePen
2518
2656
  ]);
2519
2657
  for (const installation of installations) {
2520
2658
  const path = installation.configPath;
2521
- if (installation.agent === "cursor") {
2522
- const mcpPath = resolve(dirname(path), "mcp.json");
2523
- const mcp = await readJsonDocument(mcpPath);
2524
- if (mcp.exists) {
2525
- const result = await writeMergedJson(mcpPath, mcp, removeLoreCursorMcp(mcp.value), now);
2526
- if (result.changed)
2527
- changedHookFiles.push(mcpPath);
2528
- if (result.backup)
2529
- backups.push(result.backup);
2659
+ if (isMcpHostAgent(installation.agent)) {
2660
+ const mcpPath = mcpConfigPath(installation.agent, path);
2661
+ if (installation.agent === "codex") {
2662
+ const text = await readTextDocument(mcpPath);
2663
+ if (text.exists) {
2664
+ const result = await writeMergedText(mcpPath, text, removeLoreCodexMcp(text.value), now);
2665
+ if (result.changed)
2666
+ changedHookFiles.push(mcpPath);
2667
+ if (result.backup)
2668
+ backups.push(result.backup);
2669
+ }
2670
+ }
2671
+ else {
2672
+ const mcp = await readJsonDocument(mcpPath);
2673
+ if (mcp.exists) {
2674
+ const withoutTools = removeLoreMcp(mcp.value, installation.agent);
2675
+ const cleaned = installation.agent === "cursor" ? removeLoreCursorMcp(withoutTools) : withoutTools;
2676
+ const result = await writeMergedJson(mcpPath, mcp, cleaned, now);
2677
+ if (result.changed)
2678
+ changedHookFiles.push(mcpPath);
2679
+ if (result.backup)
2680
+ backups.push(result.backup);
2681
+ }
2530
2682
  }
2531
2683
  }
2532
2684
  const document = await readJsonDocument(path);
@@ -2910,6 +3062,9 @@ async function printCommandHelp(path) {
2910
3062
  case "learnings":
2911
3063
  await runLearningsCommand(args, null);
2912
3064
  return;
3065
+ case "review":
3066
+ await runReviewCommand(args, null);
3067
+ return;
2913
3068
  case "remember":
2914
3069
  await runRememberCommand(args, null);
2915
3070
  return;
@@ -2994,7 +3149,7 @@ async function executeCli(args = process.argv.slice(2)) {
2994
3149
  const helpIndex = args.findIndex((value, index) => index > 0 && (separator < 0 || index < separator) && (value === "--help" || value === "-h"));
2995
3150
  if (helpIndex > 0) {
2996
3151
  const subcommand = args[1];
2997
- const grouped = ["connect", "config", "embeddings", "guard", "recovery", "github", "devin", "host", "self-host", "scope"].includes(command ?? "");
3152
+ const grouped = ["connect", "config", "embeddings", "guard", "recovery", "github", "devin", "host", "self-host", "scope", "review"].includes(command ?? "");
2998
3153
  await printCommandHelp([command, ...(grouped && subcommand && !subcommand.startsWith("-") ? [subcommand] : [])]);
2999
3154
  return;
3000
3155
  }
@@ -3007,7 +3162,7 @@ async function executeCli(args = process.argv.slice(2)) {
3007
3162
  return;
3008
3163
  }
3009
3164
  const commandArgs = args.slice(1);
3010
- if (["ask", "learnings", "remember", "share", "resume", "continue", "approach", "skill"].includes(command) && !(command === "skill" && commandArgs[0] === "discover")) {
3165
+ if (["ask", "learnings", "review", "remember", "share", "resume", "continue", "approach", "skill"].includes(command) && !(command === "skill" && commandArgs[0] === "discover")) {
3011
3166
  const decision = await checkDirectory(process.cwd(), getLorePaths(["approach", "skill"].includes(command) ? process.env.LORE_HOME : undefined).home);
3012
3167
  if (!decision.allowed)
3013
3168
  throw new Error(`Lore is off in this folder. ${decision.reason}`);
@@ -3050,6 +3205,9 @@ async function executeCli(args = process.argv.slice(2)) {
3050
3205
  case "learnings":
3051
3206
  await runLearningsCommand(commandArgs, await readConnectorConfig(getLorePaths(), process.cwd()));
3052
3207
  return;
3208
+ case "review":
3209
+ await runReviewCommand(commandArgs, await readConnectorConfig(getLorePaths(), process.cwd()));
3210
+ return;
3053
3211
  case "remember":
3054
3212
  await runRememberCommand(commandArgs, await readConnectorConfig(getLorePaths(), process.cwd()));
3055
3213
  return;
@@ -3164,6 +3322,10 @@ async function executeCli(args = process.argv.slice(2)) {
3164
3322
  case "cursor-mcp":
3165
3323
  await (await import("./cursor-mcp.js")).runCursorMcpServer();
3166
3324
  return;
3325
+ case "mcp":
3326
+ // The full Lore MCP server, registered by connect for hook-based agents.
3327
+ await (await import("@lore-co/mcp")).runStdioServer();
3328
+ return;
3167
3329
  case "internal-health-write": {
3168
3330
  const encoded = commandArgs[0] ?? (await readInvocationHealthWriteInput());
3169
3331
  await runInvocationHealthWrite(encoded);