@lore-co/cli 0.1.2 → 0.1.4

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
@@ -5,13 +5,18 @@ import { randomUUID } from "node:crypto";
5
5
  import { delimiter, dirname, resolve } from "node:path";
6
6
  import { homedir, platform } from "node:os";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import { WorkspaceIdentityResponseSchema, } from "@lore-co/core";
8
9
  import { runHook, } from "./runtime.js";
9
10
  import { connectGithub, runGithubCommand } from "./github.js";
10
11
  import { runDevinCommand } from "./devin.js";
11
12
  import { runHostCommand } from "./host.js";
13
+ import { runDemoCommand } from "./demo.js";
12
14
  import { updateCommand } from "./update.js";
15
+ import { runSelfHostCommand } from "./self-host.js";
13
16
  import { IS_STANDALONE_BINARY, LORE_VERSION } from "./version.js";
14
17
  const LORE_OWNER_ARGUMENT = "--owner lore";
18
+ export const LORE_OPENCODE_PLUGIN = "@lore-co/opencode";
19
+ const CONFIGURED_AGENT_NAMES = ["claude", "codex", "opencode"];
15
20
  const HOOK_EVENTS = ["UserPromptSubmit", "Stop", "SessionEnd"];
16
21
  const ROOT_HELP = `lore
17
22
  Connect local coding agents to Lore shared engineering memory.
@@ -20,51 +25,56 @@ Usage:
20
25
  lore <command> [options]
21
26
 
22
27
  Commands:
23
- connect Configure Lore and install native agent hooks
28
+ connect Configure Lore and install native agent integrations
24
29
  github Prepare/post GitHub reviews and observe corrections
25
30
  devin Start and manage Lore-enabled Devin sessions
26
31
  host Call auditable APIs from external agent hosts
27
- status Show connector and hook state
28
- doctor Diagnose configuration, hooks, and API reachability
32
+ status Show connector and agent integration state
33
+ doctor Diagnose configuration, integrations, and API reachability
34
+ self-host Run a version-pinned local Docker Compose deployment
35
+ demo Prove a file-scoped Claude-to-Codex handoff
29
36
  update Install the latest Lore CLI binary
30
- disconnect Remove Lore-owned hooks and local credentials
37
+ disconnect Remove Lore-owned integrations and local credentials
31
38
  hook Internal native hook handler
32
39
 
33
40
  Discover:
34
41
  lore connect --help
35
42
  lore status --help
36
43
  lore doctor --help
44
+ lore self-host --help
37
45
  lore disconnect --help
38
46
  lore host --help
39
47
 
40
48
  Examples:
41
- lore connect --url https://lore.example.com --token "$LORE_TOKEN"
49
+ lore connect --url https://lore.example.com --token "$LORE_WORKSPACE_TOKEN"
42
50
  lore connect github --repo owner/repository
43
51
  lore status --json
44
52
  lore doctor
53
+ lore demo
45
54
  lore update
46
55
  lore devin --help
47
56
  `;
48
57
  const CONNECT_HELP = `lore connect
49
- Store a workspace credential and idempotently install Codex and Claude hooks.
58
+ Store a workspace credential and idempotently install agent integrations.
50
59
 
51
60
  Usage:
52
61
  lore connect --url <url> --token <token> [options]
53
62
 
54
63
  Options:
55
64
  --url <url> Lore API base URL (or LORE_API_URL)
56
- --token <token> Workspace bearer token (or LORE_TOKEN)
57
- --agent <name> codex or claude; repeat to override auto-detection
65
+ --dashboard-url <url> Lore dashboard URL used for receipt links
66
+ --token <token> Workspace bearer token (or LORE_WORKSPACE_TOKEN/LORE_TOKEN)
67
+ --agent <name> claude, codex, or opencode; repeat to override auto-detection
58
68
  --timeout-ms <ms> Hook request timeout, 250-10000 (default: 2500)
59
69
  --json Print machine-readable output
60
70
  --help Show this command's help
61
71
 
62
72
  Examples:
63
- lore connect --url https://lore.example.com --token "$LORE_TOKEN"
73
+ lore connect --url https://lore.example.com --token "$LORE_WORKSPACE_TOKEN"
64
74
  lore connect --url http://localhost:3004 --token dev-token --agent codex
65
75
  `;
66
76
  const STATUS_HELP = `lore status
67
- Show whether Lore is configured and its native hooks are installed.
77
+ Show whether Lore is configured and each native integration is installed.
68
78
 
69
79
  Usage:
70
80
  lore status [--json]
@@ -74,7 +84,7 @@ Examples:
74
84
  lore status --json
75
85
  `;
76
86
  const DOCTOR_HELP = `lore doctor
77
- Check local security, runtime, native hooks, agent binaries, and Lore health.
87
+ Check local security, runtime, agent integrations, binaries, and Lore health.
78
88
 
79
89
  Usage:
80
90
  lore doctor [--json]
@@ -84,8 +94,8 @@ Examples:
84
94
  lore doctor --json
85
95
  `;
86
96
  const DISCONNECT_HELP = `lore disconnect
87
- Remove only Lore-owned native hooks, credentials, runtime, state, and retry queue.
88
- Unrelated Codex and Claude settings and Lore-created backups are retained.
97
+ Remove only Lore-owned hooks/plugin, credentials, runtime, state, and retry queue.
98
+ Unrelated agent settings, plugins, and Lore-created backups are retained.
89
99
 
90
100
  Usage:
91
101
  lore disconnect [--json]
@@ -114,11 +124,18 @@ export function getLorePaths(home) {
114
124
  queue: resolve(loreDirectory, "queue"),
115
125
  codexHooks: resolve(resolvedHome, ".codex", "hooks.json"),
116
126
  claudeSettings: resolve(resolvedHome, ".claude", "settings.json"),
127
+ openCodeConfig: resolve(resolvedHome, ".config", "opencode", "opencode.json"),
117
128
  };
118
129
  }
119
130
  function shellQuote(value) {
120
131
  return `'${value.replaceAll("'", "'\"'\"'")}'`;
121
132
  }
133
+ function isConfiguredAgent(value) {
134
+ return (value === "claude" || value === "codex" || value === "opencode");
135
+ }
136
+ function isCommandHookAgent(agent) {
137
+ return agent === "claude" || agent === "codex";
138
+ }
122
139
  function hookCommand(agent, paths) {
123
140
  if (IS_STANDALONE_BINARY) {
124
141
  return `env -u BUN_OPTIONS -u BUN_BE_BUN ${shellQuote(process.execPath)} hook --agent ${agent} ${LORE_OWNER_ARGUMENT}`;
@@ -223,6 +240,41 @@ export function countLoreHooks(input) {
223
240
  }
224
241
  return count;
225
242
  }
243
+ function isLoreOpenCodePlugin(value) {
244
+ return (typeof value === "string" &&
245
+ /^@lore-co\/(?:opencode|opencode-plugin)(?:@[^\s]+)?$/u.test(value));
246
+ }
247
+ export function mergeLoreOpenCodePlugin(input) {
248
+ const result = cloneObject(input);
249
+ if (result.plugin !== undefined && !Array.isArray(result.plugin)) {
250
+ throw new Error('OpenCode configuration field "plugin" must be a JSON array');
251
+ }
252
+ const plugins = Array.isArray(result.plugin) ? result.plugin : [];
253
+ result.plugin = [
254
+ ...plugins.filter((plugin) => !isLoreOpenCodePlugin(plugin)),
255
+ LORE_OPENCODE_PLUGIN,
256
+ ];
257
+ return result;
258
+ }
259
+ export function removeLoreOpenCodePlugin(input) {
260
+ const result = cloneObject(input);
261
+ if (!Array.isArray(result.plugin)) {
262
+ return result;
263
+ }
264
+ const plugins = result.plugin.filter((plugin) => !isLoreOpenCodePlugin(plugin));
265
+ if (plugins.length === 0) {
266
+ delete result.plugin;
267
+ }
268
+ else {
269
+ result.plugin = plugins;
270
+ }
271
+ return result;
272
+ }
273
+ export function countLoreOpenCodePlugins(input) {
274
+ return Array.isArray(input.plugin)
275
+ ? input.plugin.filter(isLoreOpenCodePlugin).length
276
+ : 0;
277
+ }
226
278
  async function readJsonDocument(path) {
227
279
  try {
228
280
  const [raw, metadata] = await Promise.all([
@@ -292,16 +344,20 @@ function parseConnectorConfig(value) {
292
344
  typeof value.connectedAt !== "string") {
293
345
  return null;
294
346
  }
295
- const agents = value.agents.filter((agent) => agent === "codex" || agent === "claude");
347
+ const agents = value.agents.filter((agent) => isConfiguredAgent(agent));
296
348
  const timeoutMs = typeof value.timeoutMs === "number" &&
297
349
  Number.isInteger(value.timeoutMs) &&
298
350
  value.timeoutMs >= 250 &&
299
351
  value.timeoutMs <= 10_000
300
352
  ? value.timeoutMs
301
353
  : 2_500;
354
+ const dashboardUrl = typeof value.dashboardUrl === "string"
355
+ ? normalizeApiUrl(value.dashboardUrl)
356
+ : undefined;
302
357
  return {
303
358
  version: 1,
304
359
  apiUrl: value.apiUrl,
360
+ ...(dashboardUrl === undefined ? {} : { dashboardUrl }),
305
361
  token: value.token,
306
362
  agents,
307
363
  connectedAt: value.connectedAt,
@@ -322,7 +378,7 @@ function normalizeApiUrl(value) {
322
378
  parsed = new URL(value);
323
379
  }
324
380
  catch {
325
- throw new Error(`Invalid Lore API URL: ${value}`);
381
+ throw new Error("Lore API URL is invalid");
326
382
  }
327
383
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
328
384
  throw new Error("Lore API URL must use http or https");
@@ -330,10 +386,79 @@ function normalizeApiUrl(value) {
330
386
  if (parsed.username !== "" || parsed.password !== "") {
331
387
  throw new Error("Lore API URL must not contain credentials");
332
388
  }
333
- parsed.hash = "";
334
- parsed.search = "";
389
+ if (parsed.hash !== "" || parsed.search !== "") {
390
+ throw new Error("Lore API URL must not contain a query or fragment");
391
+ }
335
392
  return parsed.href.replace(/\/+$/u, "");
336
393
  }
394
+ function configuredToken(explicit, existing) {
395
+ if (explicit !== undefined) {
396
+ return explicit;
397
+ }
398
+ const workspaceToken = process.env.LORE_WORKSPACE_TOKEN?.trim();
399
+ const legacyToken = process.env.LORE_TOKEN?.trim();
400
+ if (workspaceToken !== undefined &&
401
+ workspaceToken !== "" &&
402
+ legacyToken !== undefined &&
403
+ legacyToken !== "" &&
404
+ workspaceToken !== legacyToken) {
405
+ throw new Error("LORE_WORKSPACE_TOKEN and LORE_TOKEN disagree. Set only one credential.");
406
+ }
407
+ return workspaceToken || legacyToken || existing?.token;
408
+ }
409
+ function semverCompatibility(version) {
410
+ const match = /^(\d+)\.(\d+)\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.exec(version);
411
+ if (match?.[1] === undefined || match[2] === undefined) {
412
+ throw new Error("Invalid semantic version");
413
+ }
414
+ return { major: Number(match[1]), minor: Number(match[2]) };
415
+ }
416
+ function isCompatibleServerVersion(serverVersion) {
417
+ const cli = semverCompatibility(LORE_VERSION);
418
+ const server = semverCompatibility(serverVersion);
419
+ return (cli.major === server.major &&
420
+ (cli.major !== 0 || cli.minor === server.minor));
421
+ }
422
+ async function authenticatedIdentity(apiUrl, token, timeoutMs) {
423
+ const url = `${apiUrl}/v1/workspace/identity`;
424
+ let response;
425
+ try {
426
+ response = await fetch(url, {
427
+ headers: {
428
+ accept: "application/json",
429
+ authorization: `Bearer ${token}`,
430
+ },
431
+ signal: AbortSignal.timeout(timeoutMs),
432
+ });
433
+ }
434
+ catch (error) {
435
+ if (error instanceof Error &&
436
+ (error.name === "TimeoutError" || error.name === "AbortError")) {
437
+ throw new Error(`Lore preflight timed out after ${timeoutMs}ms. Verify the URL and server availability.`);
438
+ }
439
+ throw new Error("Lore API is unreachable. Verify --url and the server's network availability.");
440
+ }
441
+ if (response.status === 401 || response.status === 403) {
442
+ throw new Error("Lore authentication failed. Verify the workspace token is active and belongs to this server.");
443
+ }
444
+ if (!response.ok) {
445
+ throw new Error(response.status === 404
446
+ ? "Lore server is incompatible with this CLI. Upgrade the server before connecting."
447
+ : `Lore connector preflight failed with HTTP ${response.status}. Check server readiness and retry.`);
448
+ }
449
+ let body;
450
+ try {
451
+ body = JSON.parse(await response.text());
452
+ }
453
+ catch {
454
+ throw new Error("Lore server returned an incompatible identity response. Upgrade the server and CLI.");
455
+ }
456
+ const parsed = WorkspaceIdentityResponseSchema.safeParse(body);
457
+ if (!parsed.success || !isCompatibleServerVersion(parsed.data.server.version)) {
458
+ throw new Error("Lore server is incompatible with this CLI. Upgrade the server and CLI to compatible versions.");
459
+ }
460
+ return parsed.data;
461
+ }
337
462
  async function isExecutable(path) {
338
463
  try {
339
464
  await access(path, fsConstants.X_OK);
@@ -355,14 +480,28 @@ async function commandExists(command) {
355
480
  }
356
481
  return false;
357
482
  }
358
- async function detectAgents() {
359
- const [codex, claude] = await Promise.all([
483
+ async function pathExists(path) {
484
+ try {
485
+ await access(path, fsConstants.F_OK);
486
+ return true;
487
+ }
488
+ catch {
489
+ return false;
490
+ }
491
+ }
492
+ async function detectAgents(paths) {
493
+ const [codex, claude, openCodeExecutable, openCodeConfig] = await Promise.all([
360
494
  commandExists("codex"),
361
495
  commandExists("claude"),
496
+ commandExists("opencode"),
497
+ pathExists(paths.openCodeConfig),
362
498
  ]);
363
499
  return [
364
500
  ...(codex ? ["codex"] : []),
365
501
  ...(claude ? ["claude"] : []),
502
+ ...(openCodeExecutable || openCodeConfig
503
+ ? ["opencode"]
504
+ : []),
366
505
  ];
367
506
  }
368
507
  async function installRuntime(paths) {
@@ -388,6 +527,26 @@ async function installRuntime(paths) {
388
527
  function hookPath(agent, paths) {
389
528
  return agent === "codex" ? paths.codexHooks : paths.claudeSettings;
390
529
  }
530
+ function agentConfigPath(agent, paths) {
531
+ return agent === "opencode"
532
+ ? paths.openCodeConfig
533
+ : hookPath(agent, paths);
534
+ }
535
+ function mergeAgentConfig(input, agent, paths) {
536
+ return agent === "opencode"
537
+ ? mergeLoreOpenCodePlugin(input)
538
+ : mergeLoreHooks(input, agent, paths);
539
+ }
540
+ function removeAgentConfig(input, agent) {
541
+ return agent === "opencode"
542
+ ? removeLoreOpenCodePlugin(input)
543
+ : removeLoreHooks(input);
544
+ }
545
+ function countAgentIntegrations(input, agent) {
546
+ return agent === "opencode"
547
+ ? countLoreOpenCodePlugins(input)
548
+ : countLoreHooks(input);
549
+ }
391
550
  function parseInteger(value, flag) {
392
551
  const parsed = Number(value);
393
552
  if (!Number.isInteger(parsed)) {
@@ -415,6 +574,7 @@ function parseConnectArguments(args) {
415
574
  continue;
416
575
  }
417
576
  if (argument !== "--url" &&
577
+ argument !== "--dashboard-url" &&
418
578
  argument !== "--token" &&
419
579
  argument !== "--agent" &&
420
580
  argument !== "--timeout-ms") {
@@ -425,6 +585,9 @@ function parseConnectArguments(args) {
425
585
  if (argument === "--url") {
426
586
  parsed.apiUrl = value;
427
587
  }
588
+ else if (argument === "--dashboard-url") {
589
+ parsed.dashboardUrl = value;
590
+ }
428
591
  else if (argument === "--token") {
429
592
  parsed.token = value;
430
593
  }
@@ -435,11 +598,11 @@ function parseConnectArguments(args) {
435
598
  }
436
599
  parsed.timeoutMs = timeoutMs;
437
600
  }
438
- else if (value === "codex" || value === "claude") {
601
+ else if (isConfiguredAgent(value)) {
439
602
  parsed.agents.push(value);
440
603
  }
441
604
  else {
442
- throw new Error("--agent must be codex or claude");
605
+ throw new Error("--agent must be claude, codex, or opencode");
443
606
  }
444
607
  }
445
608
  return parsed;
@@ -468,7 +631,7 @@ async function connectCommand(args) {
468
631
  return;
469
632
  }
470
633
  if (platform() !== "darwin" && platform() !== "linux") {
471
- throw new Error("Lore hooks currently support macOS and Linux");
634
+ throw new Error("Lore agent integrations currently support macOS and Linux");
472
635
  }
473
636
  const paths = getLorePaths();
474
637
  const existing = await readConnectorConfig(paths);
@@ -476,14 +639,20 @@ async function connectCommand(args) {
476
639
  process.env.LORE_API_URL ??
477
640
  process.env.LORE_BASE_URL ??
478
641
  existing?.apiUrl;
479
- const token = parsed.token ?? process.env.LORE_TOKEN ?? existing?.token;
642
+ const token = configuredToken(parsed.token, existing);
643
+ const dashboardUrlValue = parsed.dashboardUrl ??
644
+ process.env.LORE_DASHBOARD_URL ??
645
+ existing?.dashboardUrl;
480
646
  if (apiUrlValue === undefined || apiUrlValue.trim() === "") {
481
647
  throw new Error("Lore API URL is required. Use --url <url> or LORE_API_URL.");
482
648
  }
483
649
  if (token === undefined || token.trim() === "") {
484
- throw new Error("Workspace token is required. Use --token <token> or LORE_TOKEN.");
650
+ throw new Error("Workspace token is required. Use --token <token>, LORE_WORKSPACE_TOKEN, or LORE_TOKEN.");
485
651
  }
486
- const detected = parsed.agents.length === 0 ? await detectAgents() : [];
652
+ const apiUrl = normalizeApiUrl(apiUrlValue);
653
+ const timeoutMs = parsed.timeoutMs ?? existing?.timeoutMs ?? 2_500;
654
+ const identity = await authenticatedIdentity(apiUrl, token.trim(), timeoutMs);
655
+ const detected = parsed.agents.length === 0 ? await detectAgents(paths) : [];
487
656
  const agents = [
488
657
  ...new Set([
489
658
  ...(existing?.agents ?? []),
@@ -492,17 +661,19 @@ async function connectCommand(args) {
492
661
  ]),
493
662
  ].sort();
494
663
  if (agents.length === 0) {
495
- throw new Error("No Codex or Claude executable detected. Use --agent codex or --agent claude.");
664
+ throw new Error("No Claude, Codex, or OpenCode installation detected. Use --agent <name>.");
496
665
  }
497
666
  const now = new Date();
498
667
  const documents = new Map();
499
668
  const mergedDocuments = new Map();
500
669
  for (const agent of agents) {
501
- const document = await readJsonDocument(hookPath(agent, paths));
670
+ const document = await readJsonDocument(agentConfigPath(agent, paths));
502
671
  documents.set(agent, document);
503
- mergedDocuments.set(agent, mergeLoreHooks(document.value, agent, paths));
672
+ mergedDocuments.set(agent, mergeAgentConfig(document.value, agent, paths));
673
+ }
674
+ if (agents.some(isCommandHookAgent)) {
675
+ await installRuntime(paths);
504
676
  }
505
- await installRuntime(paths);
506
677
  const changedHookFiles = [];
507
678
  const backups = [];
508
679
  for (const agent of agents) {
@@ -511,9 +682,9 @@ async function connectCommand(args) {
511
682
  if (document === undefined || merged === undefined) {
512
683
  continue;
513
684
  }
514
- const result = await writeMergedJson(hookPath(agent, paths), document, merged, now);
685
+ const result = await writeMergedJson(agentConfigPath(agent, paths), document, merged, now);
515
686
  if (result.changed) {
516
- changedHookFiles.push(hookPath(agent, paths));
687
+ changedHookFiles.push(agentConfigPath(agent, paths));
517
688
  }
518
689
  if (result.backup !== undefined) {
519
690
  backups.push(result.backup);
@@ -521,16 +692,20 @@ async function connectCommand(args) {
521
692
  }
522
693
  const config = {
523
694
  version: 1,
524
- apiUrl: normalizeApiUrl(apiUrlValue),
695
+ apiUrl,
696
+ ...(dashboardUrlValue === undefined
697
+ ? {}
698
+ : { dashboardUrl: normalizeApiUrl(dashboardUrlValue) }),
525
699
  token: token.trim(),
526
700
  agents,
527
701
  connectedAt: existing?.connectedAt ?? now.toISOString(),
528
- timeoutMs: parsed.timeoutMs ?? existing?.timeoutMs ?? 2_500,
702
+ timeoutMs,
529
703
  };
530
704
  await atomicWrite(paths.config, `${JSON.stringify(config, null, 2)}\n`, 0o600);
531
705
  const result = {
532
706
  connected: true,
533
707
  apiUrl: config.apiUrl,
708
+ identity,
534
709
  agents,
535
710
  config: paths.config,
536
711
  changedHookFiles,
@@ -547,21 +722,23 @@ async function queueCount(paths) {
547
722
  }
548
723
  }
549
724
  async function getAgentStatus(agent, config, paths) {
550
- const path = hookPath(agent, paths);
551
- let installedHooks = 0;
725
+ const path = agentConfigPath(agent, paths);
726
+ let installed = 0;
552
727
  try {
553
- installedHooks = countLoreHooks((await readJsonDocument(path)).value);
728
+ installed = countAgentIntegrations((await readJsonDocument(path)).value, agent);
554
729
  }
555
730
  catch {
556
- installedHooks = 0;
731
+ installed = 0;
557
732
  }
558
733
  return {
559
734
  agent,
560
735
  configured: config?.agents.includes(agent) ?? false,
561
736
  executable: await commandExists(agent),
562
- hookFile: path,
563
- installedHooks,
564
- expectedHooks: HOOK_EVENTS.length,
737
+ configExists: await pathExists(path),
738
+ configFile: path,
739
+ integration: agent === "opencode" ? "plugin" : "hooks",
740
+ installed,
741
+ expected: agent === "opencode" ? 1 : HOOK_EVENTS.length,
565
742
  };
566
743
  }
567
744
  async function statusData(paths) {
@@ -573,27 +750,32 @@ async function statusData(paths) {
573
750
  catch {
574
751
  // Missing configuration is represented as disconnected.
575
752
  }
576
- const runtimeChecks = IS_STANDALONE_BINARY
577
- ? [access(process.execPath, fsConstants.R_OK | fsConstants.X_OK)]
578
- : [
579
- access(paths.runtime, fsConstants.R_OK | fsConstants.X_OK),
580
- access(paths.runtimeRepository, fsConstants.R_OK),
581
- access(paths.runtimePackage, fsConstants.R_OK),
582
- ];
583
- const [runtimeInstalled, queuedTurns, codex, claude] = await Promise.all([
584
- Promise.all(runtimeChecks).then(() => true, () => false),
753
+ const runtimeRequired = config?.agents.some(isCommandHookAgent) ?? false;
754
+ const runtimeInstalledCheck = runtimeRequired
755
+ ? Promise.all(IS_STANDALONE_BINARY
756
+ ? [access(process.execPath, fsConstants.R_OK | fsConstants.X_OK)]
757
+ : [
758
+ access(paths.runtime, fsConstants.R_OK | fsConstants.X_OK),
759
+ access(paths.runtimeRepository, fsConstants.R_OK),
760
+ access(paths.runtimePackage, fsConstants.R_OK),
761
+ ]).then(() => true, () => false)
762
+ : Promise.resolve(false);
763
+ const [runtimeInstalled, queuedTurns, claude, codex, opencode] = await Promise.all([
764
+ runtimeInstalledCheck,
585
765
  queueCount(paths),
586
- getAgentStatus("codex", config, paths),
587
766
  getAgentStatus("claude", config, paths),
767
+ getAgentStatus("codex", config, paths),
768
+ getAgentStatus("opencode", config, paths),
588
769
  ]);
589
770
  return {
590
771
  connected: config !== null,
591
772
  apiUrl: config?.apiUrl ?? null,
592
773
  config: paths.config,
593
774
  configMode,
775
+ runtimeRequired,
594
776
  runtimeInstalled,
595
777
  queuedTurns,
596
- agents: [codex, claude],
778
+ agents: [claude, codex, opencode],
597
779
  };
598
780
  }
599
781
  async function statusCommand(args) {
@@ -603,9 +785,9 @@ async function statusCommand(args) {
603
785
  }
604
786
  const data = await statusData(getLorePaths());
605
787
  const agentLines = data.agents
606
- .map((agent) => `${agent.agent}: ${agent.configured ? "configured" : "not configured"}, hooks ${agent.installedHooks}/${agent.expectedHooks}, executable ${agent.executable ? "yes" : "no"}`)
788
+ .map((agent) => `${agent.agent}: ${agent.configured ? "configured" : "not configured"}, ${agent.integration} ${agent.installed}/${agent.expected}, executable ${agent.executable ? "yes" : "no"}, config ${agent.configExists ? "yes" : "no"}`)
607
789
  .join("\n");
608
- writeResult(data, parsed.json, `connected: ${data.connected ? "yes" : "no"}\napi_url: ${data.apiUrl ?? "-"}\nconfig_mode: ${data.configMode ?? "-"}\nruntime: ${data.runtimeInstalled ? "installed" : "missing"}\nqueued_turns: ${data.queuedTurns}\n${agentLines}\n`);
790
+ writeResult(data, parsed.json, `connected: ${data.connected ? "yes" : "no"}\napi_url: ${data.apiUrl ?? "-"}\nconfig_mode: ${data.configMode ?? "-"}\nruntime: ${data.runtimeRequired ? (data.runtimeInstalled ? "installed" : "missing") : "not required"}\nqueued_turns: ${data.queuedTurns}\n${agentLines}\n`);
609
791
  }
610
792
  async function disconnectCommand(args) {
611
793
  const parsed = parseOutputArguments(args, DISCONNECT_HELP, "disconnect");
@@ -616,13 +798,13 @@ async function disconnectCommand(args) {
616
798
  const now = new Date();
617
799
  const changedHookFiles = [];
618
800
  const backups = [];
619
- for (const agent of ["codex", "claude"]) {
620
- const path = hookPath(agent, paths);
801
+ for (const agent of CONFIGURED_AGENT_NAMES) {
802
+ const path = agentConfigPath(agent, paths);
621
803
  const document = await readJsonDocument(path);
622
804
  if (!document.exists) {
623
805
  continue;
624
806
  }
625
- const result = await writeMergedJson(path, document, removeLoreHooks(document.value), now);
807
+ const result = await writeMergedJson(path, document, removeAgentConfig(document.value, agent), now);
626
808
  if (result.changed) {
627
809
  changedHookFiles.push(path);
628
810
  }
@@ -641,28 +823,60 @@ async function disconnectCommand(args) {
641
823
  const result = { connected: false, changedHookFiles, backups };
642
824
  writeResult(result, parsed.json, `disconnected: yes\nchanged_hook_files: ${changedHookFiles.length}\n`);
643
825
  }
644
- async function apiHealth(config) {
645
- const url = `${config.apiUrl.replace(/\/+$/u, "")}/health`;
826
+ async function apiChecks(config) {
827
+ const readinessUrl = `${config.apiUrl}/health/ready`;
828
+ let readiness;
646
829
  try {
647
- const response = await fetch(url, {
648
- headers: { authorization: `Bearer ${config.token}` },
830
+ readiness = await fetch(readinessUrl, {
649
831
  signal: AbortSignal.timeout(3_000),
650
832
  });
651
- return response.ok
652
- ? { name: "api", status: "ok", detail: `${url} returned ${response.status}` }
833
+ }
834
+ catch {
835
+ return [
836
+ {
837
+ name: "api-readiness",
838
+ status: "error",
839
+ detail: `unreachable: ${readinessUrl}`,
840
+ },
841
+ {
842
+ name: "api-identity",
843
+ status: "warning",
844
+ detail: "not checked because the API is unreachable",
845
+ },
846
+ ];
847
+ }
848
+ const checks = [
849
+ readiness.ok
850
+ ? {
851
+ name: "api-readiness",
852
+ status: "ok",
853
+ detail: `ready: ${readinessUrl}`,
854
+ }
653
855
  : {
654
- name: "api",
856
+ name: "api-readiness",
655
857
  status: "error",
656
- detail: `${url} returned ${response.status}`,
657
- };
858
+ detail: `unready: ${readinessUrl} returned ${readiness.status}`,
859
+ },
860
+ ];
861
+ try {
862
+ const identity = await authenticatedIdentity(config.apiUrl, config.token, 3_000);
863
+ checks.push({
864
+ name: "api-identity",
865
+ status: "ok",
866
+ detail: `authorized for ${identity.organization}/${identity.workspaceName} as ${identity.credentialType}; server ${identity.server.version}`,
867
+ });
658
868
  }
659
869
  catch (error) {
660
- return {
661
- name: "api",
870
+ const detail = error instanceof Error ? error.message : "Identity check failed";
871
+ checks.push({
872
+ name: "api-identity",
662
873
  status: "error",
663
- detail: error instanceof Error ? error.message : String(error),
664
- };
874
+ detail: detail.startsWith("Lore authentication failed")
875
+ ? "unauthorized: the configured credential was rejected"
876
+ : detail,
877
+ });
665
878
  }
879
+ return checks;
666
880
  }
667
881
  async function doctorCommand(args) {
668
882
  const parsed = parseOutputArguments(args, DOCTOR_HELP, "doctor");
@@ -699,11 +913,13 @@ async function doctorCommand(args) {
699
913
  status: status.configMode === "600" ? "ok" : "error",
700
914
  detail: status.configMode ?? "missing",
701
915
  });
702
- checks.push({
703
- name: "runtime",
704
- status: status.runtimeInstalled ? "ok" : "error",
705
- detail: IS_STANDALONE_BINARY ? process.execPath : paths.runtime,
706
- });
916
+ if (status.runtimeRequired) {
917
+ checks.push({
918
+ name: "runtime",
919
+ status: status.runtimeInstalled ? "ok" : "error",
920
+ detail: IS_STANDALONE_BINARY ? process.execPath : paths.runtime,
921
+ });
922
+ }
707
923
  for (const agent of status.agents.filter((item) => item.configured)) {
708
924
  checks.push({
709
925
  name: `${agent.agent}-executable`,
@@ -711,9 +927,9 @@ async function doctorCommand(args) {
711
927
  detail: agent.executable ? "found on PATH" : "not found on PATH",
712
928
  });
713
929
  checks.push({
714
- name: `${agent.agent}-hooks`,
715
- status: agent.installedHooks === agent.expectedHooks ? "ok" : "error",
716
- detail: `${agent.installedHooks}/${agent.expectedHooks} Lore hooks in ${agent.hookFile}`,
930
+ name: `${agent.agent}-${agent.integration}`,
931
+ status: agent.installed === agent.expected ? "ok" : "error",
932
+ detail: `${agent.installed}/${agent.expected} Lore ${agent.integration} in ${agent.configFile}`,
717
933
  });
718
934
  }
719
935
  checks.push({
@@ -722,7 +938,7 @@ async function doctorCommand(args) {
722
938
  detail: `${status.queuedTurns} queued turn(s)`,
723
939
  });
724
940
  if (config !== null) {
725
- checks.push(await apiHealth(config));
941
+ checks.push(...(await apiChecks(config)));
726
942
  }
727
943
  const errors = checks.filter((check) => check.status === "error").length;
728
944
  const warnings = checks.filter((check) => check.status === "warning").length;
@@ -766,6 +982,12 @@ export async function runCli(args = process.argv.slice(2)) {
766
982
  case "doctor":
767
983
  await doctorCommand(commandArgs);
768
984
  return;
985
+ case "self-host":
986
+ await runSelfHostCommand(commandArgs);
987
+ return;
988
+ case "demo":
989
+ await runDemoCommand(commandArgs, await readConnectorConfig(getLorePaths()));
990
+ return;
769
991
  case "update":
770
992
  await updateCommand(commandArgs);
771
993
  return;