@lore-co/cli 0.1.3 → 0.1.8

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,14 +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";
12
13
  import { runDemoCommand } from "./demo.js";
13
14
  import { updateCommand } from "./update.js";
15
+ import { runSelfHostCommand } from "./self-host.js";
14
16
  import { IS_STANDALONE_BINARY, LORE_VERSION } from "./version.js";
15
17
  const LORE_OWNER_ARGUMENT = "--owner lore";
18
+ export const LORE_OPENCODE_PLUGIN = "@lore-co/opencode";
19
+ const CONFIGURED_AGENT_NAMES = ["claude", "codex", "opencode"];
16
20
  const HOOK_EVENTS = ["UserPromptSubmit", "Stop", "SessionEnd"];
17
21
  const ROOT_HELP = `lore
18
22
  Connect local coding agents to Lore shared engineering memory.
@@ -21,26 +25,28 @@ Usage:
21
25
  lore <command> [options]
22
26
 
23
27
  Commands:
24
- connect Configure Lore and install native agent hooks
28
+ connect Configure Lore and install native agent integrations
25
29
  github Prepare/post GitHub reviews and observe corrections
26
30
  devin Start and manage Lore-enabled Devin sessions
27
31
  host Call auditable APIs from external agent hosts
28
- status Show connector and hook state
29
- 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
30
35
  demo Prove a file-scoped Claude-to-Codex handoff
31
36
  update Install the latest Lore CLI binary
32
- disconnect Remove Lore-owned hooks and local credentials
37
+ disconnect Remove Lore-owned integrations and local credentials
33
38
  hook Internal native hook handler
34
39
 
35
40
  Discover:
36
41
  lore connect --help
37
42
  lore status --help
38
43
  lore doctor --help
44
+ lore self-host --help
39
45
  lore disconnect --help
40
46
  lore host --help
41
47
 
42
48
  Examples:
43
- lore connect --url https://lore.example.com --token "$LORE_TOKEN"
49
+ lore connect --url https://lore.example.com --token "$LORE_WORKSPACE_TOKEN"
44
50
  lore connect github --repo owner/repository
45
51
  lore status --json
46
52
  lore doctor
@@ -49,7 +55,7 @@ Examples:
49
55
  lore devin --help
50
56
  `;
51
57
  const CONNECT_HELP = `lore connect
52
- Store a workspace credential and idempotently install Codex and Claude hooks.
58
+ Store a workspace credential and idempotently install agent integrations.
53
59
 
54
60
  Usage:
55
61
  lore connect --url <url> --token <token> [options]
@@ -57,18 +63,18 @@ Usage:
57
63
  Options:
58
64
  --url <url> Lore API base URL (or LORE_API_URL)
59
65
  --dashboard-url <url> Lore dashboard URL used for receipt links
60
- --token <token> Workspace bearer token (or LORE_TOKEN)
61
- --agent <name> codex or claude; repeat to override auto-detection
66
+ --token <token> Workspace bearer token (or LORE_WORKSPACE_TOKEN/LORE_TOKEN)
67
+ --agent <name> claude, codex, or opencode; repeat to override auto-detection
62
68
  --timeout-ms <ms> Hook request timeout, 250-10000 (default: 2500)
63
69
  --json Print machine-readable output
64
70
  --help Show this command's help
65
71
 
66
72
  Examples:
67
- lore connect --url https://lore.example.com --token "$LORE_TOKEN"
73
+ lore connect --url https://lore.example.com --token "$LORE_WORKSPACE_TOKEN"
68
74
  lore connect --url http://localhost:3004 --token dev-token --agent codex
69
75
  `;
70
76
  const STATUS_HELP = `lore status
71
- Show whether Lore is configured and its native hooks are installed.
77
+ Show whether Lore is configured and each native integration is installed.
72
78
 
73
79
  Usage:
74
80
  lore status [--json]
@@ -78,7 +84,7 @@ Examples:
78
84
  lore status --json
79
85
  `;
80
86
  const DOCTOR_HELP = `lore doctor
81
- Check local security, runtime, native hooks, agent binaries, and Lore health.
87
+ Check local security, runtime, agent integrations, binaries, and Lore health.
82
88
 
83
89
  Usage:
84
90
  lore doctor [--json]
@@ -88,8 +94,8 @@ Examples:
88
94
  lore doctor --json
89
95
  `;
90
96
  const DISCONNECT_HELP = `lore disconnect
91
- Remove only Lore-owned native hooks, credentials, runtime, state, and retry queue.
92
- 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.
93
99
 
94
100
  Usage:
95
101
  lore disconnect [--json]
@@ -118,11 +124,18 @@ export function getLorePaths(home) {
118
124
  queue: resolve(loreDirectory, "queue"),
119
125
  codexHooks: resolve(resolvedHome, ".codex", "hooks.json"),
120
126
  claudeSettings: resolve(resolvedHome, ".claude", "settings.json"),
127
+ openCodeConfig: resolve(resolvedHome, ".config", "opencode", "opencode.json"),
121
128
  };
122
129
  }
123
130
  function shellQuote(value) {
124
131
  return `'${value.replaceAll("'", "'\"'\"'")}'`;
125
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
+ }
126
139
  function hookCommand(agent, paths) {
127
140
  if (IS_STANDALONE_BINARY) {
128
141
  return `env -u BUN_OPTIONS -u BUN_BE_BUN ${shellQuote(process.execPath)} hook --agent ${agent} ${LORE_OWNER_ARGUMENT}`;
@@ -227,6 +240,41 @@ export function countLoreHooks(input) {
227
240
  }
228
241
  return count;
229
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
+ }
230
278
  async function readJsonDocument(path) {
231
279
  try {
232
280
  const [raw, metadata] = await Promise.all([
@@ -296,7 +344,7 @@ function parseConnectorConfig(value) {
296
344
  typeof value.connectedAt !== "string") {
297
345
  return null;
298
346
  }
299
- const agents = value.agents.filter((agent) => agent === "codex" || agent === "claude");
347
+ const agents = value.agents.filter((agent) => isConfiguredAgent(agent));
300
348
  const timeoutMs = typeof value.timeoutMs === "number" &&
301
349
  Number.isInteger(value.timeoutMs) &&
302
350
  value.timeoutMs >= 250 &&
@@ -330,7 +378,7 @@ function normalizeApiUrl(value) {
330
378
  parsed = new URL(value);
331
379
  }
332
380
  catch {
333
- throw new Error(`Invalid Lore API URL: ${value}`);
381
+ throw new Error("Lore API URL is invalid");
334
382
  }
335
383
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
336
384
  throw new Error("Lore API URL must use http or https");
@@ -338,10 +386,79 @@ function normalizeApiUrl(value) {
338
386
  if (parsed.username !== "" || parsed.password !== "") {
339
387
  throw new Error("Lore API URL must not contain credentials");
340
388
  }
341
- parsed.hash = "";
342
- parsed.search = "";
389
+ if (parsed.hash !== "" || parsed.search !== "") {
390
+ throw new Error("Lore API URL must not contain a query or fragment");
391
+ }
343
392
  return parsed.href.replace(/\/+$/u, "");
344
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
+ }
345
462
  async function isExecutable(path) {
346
463
  try {
347
464
  await access(path, fsConstants.X_OK);
@@ -363,14 +480,28 @@ async function commandExists(command) {
363
480
  }
364
481
  return false;
365
482
  }
366
- async function detectAgents() {
367
- 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([
368
494
  commandExists("codex"),
369
495
  commandExists("claude"),
496
+ commandExists("opencode"),
497
+ pathExists(paths.openCodeConfig),
370
498
  ]);
371
499
  return [
372
500
  ...(codex ? ["codex"] : []),
373
501
  ...(claude ? ["claude"] : []),
502
+ ...(openCodeExecutable || openCodeConfig
503
+ ? ["opencode"]
504
+ : []),
374
505
  ];
375
506
  }
376
507
  async function installRuntime(paths) {
@@ -396,6 +527,26 @@ async function installRuntime(paths) {
396
527
  function hookPath(agent, paths) {
397
528
  return agent === "codex" ? paths.codexHooks : paths.claudeSettings;
398
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
+ }
399
550
  function parseInteger(value, flag) {
400
551
  const parsed = Number(value);
401
552
  if (!Number.isInteger(parsed)) {
@@ -447,11 +598,11 @@ function parseConnectArguments(args) {
447
598
  }
448
599
  parsed.timeoutMs = timeoutMs;
449
600
  }
450
- else if (value === "codex" || value === "claude") {
601
+ else if (isConfiguredAgent(value)) {
451
602
  parsed.agents.push(value);
452
603
  }
453
604
  else {
454
- throw new Error("--agent must be codex or claude");
605
+ throw new Error("--agent must be claude, codex, or opencode");
455
606
  }
456
607
  }
457
608
  return parsed;
@@ -480,7 +631,7 @@ async function connectCommand(args) {
480
631
  return;
481
632
  }
482
633
  if (platform() !== "darwin" && platform() !== "linux") {
483
- throw new Error("Lore hooks currently support macOS and Linux");
634
+ throw new Error("Lore agent integrations currently support macOS and Linux");
484
635
  }
485
636
  const paths = getLorePaths();
486
637
  const existing = await readConnectorConfig(paths);
@@ -488,7 +639,7 @@ async function connectCommand(args) {
488
639
  process.env.LORE_API_URL ??
489
640
  process.env.LORE_BASE_URL ??
490
641
  existing?.apiUrl;
491
- const token = parsed.token ?? process.env.LORE_TOKEN ?? existing?.token;
642
+ const token = configuredToken(parsed.token, existing);
492
643
  const dashboardUrlValue = parsed.dashboardUrl ??
493
644
  process.env.LORE_DASHBOARD_URL ??
494
645
  existing?.dashboardUrl;
@@ -496,9 +647,12 @@ async function connectCommand(args) {
496
647
  throw new Error("Lore API URL is required. Use --url <url> or LORE_API_URL.");
497
648
  }
498
649
  if (token === undefined || token.trim() === "") {
499
- 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.");
500
651
  }
501
- 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) : [];
502
656
  const agents = [
503
657
  ...new Set([
504
658
  ...(existing?.agents ?? []),
@@ -507,17 +661,19 @@ async function connectCommand(args) {
507
661
  ]),
508
662
  ].sort();
509
663
  if (agents.length === 0) {
510
- 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>.");
511
665
  }
512
666
  const now = new Date();
513
667
  const documents = new Map();
514
668
  const mergedDocuments = new Map();
515
669
  for (const agent of agents) {
516
- const document = await readJsonDocument(hookPath(agent, paths));
670
+ const document = await readJsonDocument(agentConfigPath(agent, paths));
517
671
  documents.set(agent, document);
518
- 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);
519
676
  }
520
- await installRuntime(paths);
521
677
  const changedHookFiles = [];
522
678
  const backups = [];
523
679
  for (const agent of agents) {
@@ -526,9 +682,9 @@ async function connectCommand(args) {
526
682
  if (document === undefined || merged === undefined) {
527
683
  continue;
528
684
  }
529
- const result = await writeMergedJson(hookPath(agent, paths), document, merged, now);
685
+ const result = await writeMergedJson(agentConfigPath(agent, paths), document, merged, now);
530
686
  if (result.changed) {
531
- changedHookFiles.push(hookPath(agent, paths));
687
+ changedHookFiles.push(agentConfigPath(agent, paths));
532
688
  }
533
689
  if (result.backup !== undefined) {
534
690
  backups.push(result.backup);
@@ -536,19 +692,20 @@ async function connectCommand(args) {
536
692
  }
537
693
  const config = {
538
694
  version: 1,
539
- apiUrl: normalizeApiUrl(apiUrlValue),
695
+ apiUrl,
540
696
  ...(dashboardUrlValue === undefined
541
697
  ? {}
542
698
  : { dashboardUrl: normalizeApiUrl(dashboardUrlValue) }),
543
699
  token: token.trim(),
544
700
  agents,
545
701
  connectedAt: existing?.connectedAt ?? now.toISOString(),
546
- timeoutMs: parsed.timeoutMs ?? existing?.timeoutMs ?? 2_500,
702
+ timeoutMs,
547
703
  };
548
704
  await atomicWrite(paths.config, `${JSON.stringify(config, null, 2)}\n`, 0o600);
549
705
  const result = {
550
706
  connected: true,
551
707
  apiUrl: config.apiUrl,
708
+ identity,
552
709
  agents,
553
710
  config: paths.config,
554
711
  changedHookFiles,
@@ -565,21 +722,23 @@ async function queueCount(paths) {
565
722
  }
566
723
  }
567
724
  async function getAgentStatus(agent, config, paths) {
568
- const path = hookPath(agent, paths);
569
- let installedHooks = 0;
725
+ const path = agentConfigPath(agent, paths);
726
+ let installed = 0;
570
727
  try {
571
- installedHooks = countLoreHooks((await readJsonDocument(path)).value);
728
+ installed = countAgentIntegrations((await readJsonDocument(path)).value, agent);
572
729
  }
573
730
  catch {
574
- installedHooks = 0;
731
+ installed = 0;
575
732
  }
576
733
  return {
577
734
  agent,
578
735
  configured: config?.agents.includes(agent) ?? false,
579
736
  executable: await commandExists(agent),
580
- hookFile: path,
581
- installedHooks,
582
- 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,
583
742
  };
584
743
  }
585
744
  async function statusData(paths) {
@@ -591,27 +750,32 @@ async function statusData(paths) {
591
750
  catch {
592
751
  // Missing configuration is represented as disconnected.
593
752
  }
594
- const runtimeChecks = IS_STANDALONE_BINARY
595
- ? [access(process.execPath, fsConstants.R_OK | fsConstants.X_OK)]
596
- : [
597
- access(paths.runtime, fsConstants.R_OK | fsConstants.X_OK),
598
- access(paths.runtimeRepository, fsConstants.R_OK),
599
- access(paths.runtimePackage, fsConstants.R_OK),
600
- ];
601
- const [runtimeInstalled, queuedTurns, codex, claude] = await Promise.all([
602
- 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,
603
765
  queueCount(paths),
604
- getAgentStatus("codex", config, paths),
605
766
  getAgentStatus("claude", config, paths),
767
+ getAgentStatus("codex", config, paths),
768
+ getAgentStatus("opencode", config, paths),
606
769
  ]);
607
770
  return {
608
771
  connected: config !== null,
609
772
  apiUrl: config?.apiUrl ?? null,
610
773
  config: paths.config,
611
774
  configMode,
775
+ runtimeRequired,
612
776
  runtimeInstalled,
613
777
  queuedTurns,
614
- agents: [codex, claude],
778
+ agents: [claude, codex, opencode],
615
779
  };
616
780
  }
617
781
  async function statusCommand(args) {
@@ -621,9 +785,9 @@ async function statusCommand(args) {
621
785
  }
622
786
  const data = await statusData(getLorePaths());
623
787
  const agentLines = data.agents
624
- .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"}`)
625
789
  .join("\n");
626
- 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`);
627
791
  }
628
792
  async function disconnectCommand(args) {
629
793
  const parsed = parseOutputArguments(args, DISCONNECT_HELP, "disconnect");
@@ -634,13 +798,13 @@ async function disconnectCommand(args) {
634
798
  const now = new Date();
635
799
  const changedHookFiles = [];
636
800
  const backups = [];
637
- for (const agent of ["codex", "claude"]) {
638
- const path = hookPath(agent, paths);
801
+ for (const agent of CONFIGURED_AGENT_NAMES) {
802
+ const path = agentConfigPath(agent, paths);
639
803
  const document = await readJsonDocument(path);
640
804
  if (!document.exists) {
641
805
  continue;
642
806
  }
643
- const result = await writeMergedJson(path, document, removeLoreHooks(document.value), now);
807
+ const result = await writeMergedJson(path, document, removeAgentConfig(document.value, agent), now);
644
808
  if (result.changed) {
645
809
  changedHookFiles.push(path);
646
810
  }
@@ -659,28 +823,60 @@ async function disconnectCommand(args) {
659
823
  const result = { connected: false, changedHookFiles, backups };
660
824
  writeResult(result, parsed.json, `disconnected: yes\nchanged_hook_files: ${changedHookFiles.length}\n`);
661
825
  }
662
- async function apiHealth(config) {
663
- const url = `${config.apiUrl.replace(/\/+$/u, "")}/health`;
826
+ async function apiChecks(config) {
827
+ const readinessUrl = `${config.apiUrl}/health/ready`;
828
+ let readiness;
664
829
  try {
665
- const response = await fetch(url, {
666
- headers: { authorization: `Bearer ${config.token}` },
830
+ readiness = await fetch(readinessUrl, {
667
831
  signal: AbortSignal.timeout(3_000),
668
832
  });
669
- return response.ok
670
- ? { 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
+ }
671
855
  : {
672
- name: "api",
856
+ name: "api-readiness",
673
857
  status: "error",
674
- detail: `${url} returned ${response.status}`,
675
- };
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
+ });
676
868
  }
677
869
  catch (error) {
678
- return {
679
- name: "api",
870
+ const detail = error instanceof Error ? error.message : "Identity check failed";
871
+ checks.push({
872
+ name: "api-identity",
680
873
  status: "error",
681
- detail: error instanceof Error ? error.message : String(error),
682
- };
874
+ detail: detail.startsWith("Lore authentication failed")
875
+ ? "unauthorized: the configured credential was rejected"
876
+ : detail,
877
+ });
683
878
  }
879
+ return checks;
684
880
  }
685
881
  async function doctorCommand(args) {
686
882
  const parsed = parseOutputArguments(args, DOCTOR_HELP, "doctor");
@@ -717,11 +913,13 @@ async function doctorCommand(args) {
717
913
  status: status.configMode === "600" ? "ok" : "error",
718
914
  detail: status.configMode ?? "missing",
719
915
  });
720
- checks.push({
721
- name: "runtime",
722
- status: status.runtimeInstalled ? "ok" : "error",
723
- detail: IS_STANDALONE_BINARY ? process.execPath : paths.runtime,
724
- });
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
+ }
725
923
  for (const agent of status.agents.filter((item) => item.configured)) {
726
924
  checks.push({
727
925
  name: `${agent.agent}-executable`,
@@ -729,9 +927,9 @@ async function doctorCommand(args) {
729
927
  detail: agent.executable ? "found on PATH" : "not found on PATH",
730
928
  });
731
929
  checks.push({
732
- name: `${agent.agent}-hooks`,
733
- status: agent.installedHooks === agent.expectedHooks ? "ok" : "error",
734
- 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}`,
735
933
  });
736
934
  }
737
935
  checks.push({
@@ -740,7 +938,7 @@ async function doctorCommand(args) {
740
938
  detail: `${status.queuedTurns} queued turn(s)`,
741
939
  });
742
940
  if (config !== null) {
743
- checks.push(await apiHealth(config));
941
+ checks.push(...(await apiChecks(config)));
744
942
  }
745
943
  const errors = checks.filter((check) => check.status === "error").length;
746
944
  const warnings = checks.filter((check) => check.status === "warning").length;
@@ -784,6 +982,9 @@ export async function runCli(args = process.argv.slice(2)) {
784
982
  case "doctor":
785
983
  await doctorCommand(commandArgs);
786
984
  return;
985
+ case "self-host":
986
+ await runSelfHostCommand(commandArgs);
987
+ return;
787
988
  case "demo":
788
989
  await runDemoCommand(commandArgs, await readConnectorConfig(getLorePaths()));
789
990
  return;