@lore-co/cli 0.1.9 → 0.1.11

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,7 +5,7 @@ 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
+ import { NATIVE_CODING_AGENT_NAMES, WorkspaceIdentityResponseSchema, isNativeCodingAgent, } from "@lore-co/core";
9
9
  import { runHook, } from "./runtime.js";
10
10
  import { connectGithub, runGithubCommand } from "./github.js";
11
11
  import { runDevinCommand } from "./devin.js";
@@ -16,8 +16,19 @@ import { runSelfHostCommand } from "./self-host.js";
16
16
  import { IS_STANDALONE_BINARY, LORE_VERSION } from "./version.js";
17
17
  const LORE_OWNER_ARGUMENT = "--owner lore";
18
18
  export const LORE_OPENCODE_PLUGIN = "@lore-co/opencode";
19
- const CONFIGURED_AGENT_NAMES = ["claude", "codex", "opencode"];
19
+ export const DEFAULT_HOSTED_API_URL = "https://api.uselore.co";
20
+ export const DEFAULT_HOSTED_DASHBOARD_URL = "https://uselore.co";
21
+ const CONFIGURED_AGENT_NAMES = NATIVE_CODING_AGENT_NAMES;
20
22
  const HOOK_EVENTS = ["UserPromptSubmit", "Stop", "SessionEnd"];
23
+ const CURSOR_HOOK_EVENTS = [
24
+ "beforeSubmitPrompt",
25
+ "afterAgentResponse",
26
+ "sessionEnd",
27
+ ];
28
+ const POLYTOKEN_HOOK_EVENTS = [
29
+ "pre_user_prompt",
30
+ "post_model_turn",
31
+ ];
21
32
  const ROOT_HELP = `lore
22
33
  Connect local coding agents to Lore shared engineering memory.
23
34
 
@@ -46,7 +57,7 @@ Discover:
46
57
  lore host --help
47
58
 
48
59
  Examples:
49
- lore connect --url https://api.uselore.co --token "$LORE_WORKSPACE_TOKEN"
60
+ lore connect --token "$LORE_WORKSPACE_TOKEN"
50
61
  lore connect github --repo owner/repository
51
62
  lore status --json
52
63
  lore doctor
@@ -58,20 +69,21 @@ const CONNECT_HELP = `lore connect
58
69
  Store a workspace credential and idempotently install agent integrations.
59
70
 
60
71
  Usage:
61
- lore connect --url <url> --token <token> [options]
72
+ lore connect --token <token> [options]
62
73
 
63
74
  Options:
64
- --url <url> Lore API base URL (or LORE_API_URL)
65
- --dashboard-url <url> Lore dashboard URL used for receipt links
75
+ --url <url> Lore API base URL (default: https://api.uselore.co)
76
+ --dashboard-url <url> Lore dashboard URL (default: https://uselore.co for hosted Lore)
66
77
  --token <token> Workspace bearer token (or LORE_WORKSPACE_TOKEN/LORE_TOKEN)
67
- --agent <name> claude, codex, or opencode; repeat to override auto-detection
78
+ --agent <name> claude, codex, cursor, opencode, polytoken, or t3code; repeat to override auto-detection
68
79
  --timeout-ms <ms> Hook request timeout, 250-10000 (default: 2500)
69
80
  --json Print machine-readable output
70
81
  --help Show this command's help
71
82
 
72
83
  Examples:
73
- lore connect --url https://api.uselore.co --token "$LORE_WORKSPACE_TOKEN"
84
+ lore connect --token "$LORE_WORKSPACE_TOKEN" --agent claude
74
85
  lore connect --url http://localhost:3004 --token dev-token --agent codex
86
+ lore connect --token "$LORE_WORKSPACE_TOKEN" --agent cursor
75
87
  `;
76
88
  const STATUS_HELP = `lore status
77
89
  Show whether Lore is configured and each native integration is installed.
@@ -110,9 +122,14 @@ function isObject(value) {
110
122
  function cloneObject(value) {
111
123
  return structuredClone(value);
112
124
  }
113
- export function getLorePaths(home) {
114
- const resolvedHome = resolve(home ?? process.env.HOME ?? homedir());
125
+ export function getLorePaths(home, environment = process.env) {
126
+ const resolvedHome = resolve(home ?? environment.HOME ?? homedir());
115
127
  const loreDirectory = resolve(resolvedHome, ".lore");
128
+ const xdgConfigHome = environment.XDG_CONFIG_HOME?.trim() === undefined ||
129
+ environment.XDG_CONFIG_HOME.trim() === ""
130
+ ? resolve(resolvedHome, ".config")
131
+ : resolve(environment.XDG_CONFIG_HOME);
132
+ const t3Home = resolve(environment.T3CODE_HOME?.trim() || resolve(resolvedHome, ".t3"));
116
133
  return {
117
134
  home: resolvedHome,
118
135
  loreDirectory,
@@ -124,23 +141,30 @@ export function getLorePaths(home) {
124
141
  queue: resolve(loreDirectory, "queue"),
125
142
  codexHooks: resolve(resolvedHome, ".codex", "hooks.json"),
126
143
  claudeSettings: resolve(resolvedHome, ".claude", "settings.json"),
144
+ cursorHooks: resolve(resolvedHome, ".cursor", "hooks.json"),
145
+ cursorUserData: platform() === "darwin"
146
+ ? resolve(resolvedHome, "Library", "Application Support", "Cursor")
147
+ : resolve(resolvedHome, ".config", "Cursor"),
127
148
  openCodeConfig: resolve(resolvedHome, ".config", "opencode", "opencode.json"),
149
+ polytokenHooks: resolve(xdgConfigHome, "polytoken", "hooks.json"),
150
+ t3Settings: resolve(t3Home, "userdata", "settings.json"),
128
151
  };
129
152
  }
130
153
  function shellQuote(value) {
131
154
  return `'${value.replaceAll("'", "'\"'\"'")}'`;
132
155
  }
133
156
  function isConfiguredAgent(value) {
134
- return (value === "claude" || value === "codex" || value === "opencode");
157
+ return typeof value === "string" && isNativeCodingAgent(value);
135
158
  }
136
- function isCommandHookAgent(agent) {
137
- return agent === "claude" || agent === "codex";
159
+ function isConnectAgent(value) {
160
+ return isConfiguredAgent(value) || value === "t3code";
138
161
  }
139
162
  function hookCommand(agent, paths) {
163
+ const loreHome = `LORE_HOME=${shellQuote(paths.home)}`;
140
164
  if (IS_STANDALONE_BINARY) {
141
- return `env -u BUN_OPTIONS -u BUN_BE_BUN ${shellQuote(process.execPath)} hook --agent ${agent} ${LORE_OWNER_ARGUMENT}`;
165
+ return `env -u BUN_OPTIONS -u BUN_BE_BUN ${loreHome} ${shellQuote(process.execPath)} hook --agent ${agent} ${LORE_OWNER_ARGUMENT}`;
142
166
  }
143
- return `${shellQuote(process.execPath)} ${shellQuote(paths.runtime)} --agent ${agent} ${LORE_OWNER_ARGUMENT}`;
167
+ return `env ${loreHome} ${shellQuote(process.execPath)} ${shellQuote(paths.runtime)} --agent ${agent} ${LORE_OWNER_ARGUMENT}`;
144
168
  }
145
169
  function isLoreHook(value) {
146
170
  if (!isObject(value) || value.type !== "command") {
@@ -240,6 +264,113 @@ export function countLoreHooks(input) {
240
264
  }
241
265
  return count;
242
266
  }
267
+ function cursorEventHandler(event, paths) {
268
+ return {
269
+ type: "command",
270
+ command: hookCommand("cursor", paths),
271
+ timeout: event === "sessionEnd" ? 2 : event === "afterAgentResponse" ? 3 : 25,
272
+ };
273
+ }
274
+ function stripLoreFromCursorEvent(value) {
275
+ return Array.isArray(value) ? value.filter((hook) => !isLoreHook(hook)) : [];
276
+ }
277
+ export function mergeLoreCursorHooks(input, paths) {
278
+ const result = cloneObject(input);
279
+ if (result.version !== undefined && result.version !== 1) {
280
+ throw new Error('Cursor configuration field "version" must be 1');
281
+ }
282
+ if (result.hooks !== undefined && !isObject(result.hooks)) {
283
+ throw new Error('Cursor configuration field "hooks" must be a JSON object');
284
+ }
285
+ const hooks = isObject(result.hooks) ? { ...result.hooks } : {};
286
+ for (const event of CURSOR_HOOK_EVENTS) {
287
+ if (hooks[event] !== undefined && !Array.isArray(hooks[event])) {
288
+ throw new Error(`Cursor hook event "${event}" must be a JSON array`);
289
+ }
290
+ hooks[event] = [
291
+ ...stripLoreFromCursorEvent(hooks[event]),
292
+ cursorEventHandler(event, paths),
293
+ ];
294
+ }
295
+ result.version = 1;
296
+ result.hooks = hooks;
297
+ return result;
298
+ }
299
+ export function removeLoreCursorHooks(input) {
300
+ const result = cloneObject(input);
301
+ if (!isObject(result.hooks)) {
302
+ return result;
303
+ }
304
+ const hooks = { ...result.hooks };
305
+ for (const event of CURSOR_HOOK_EVENTS) {
306
+ if (!Array.isArray(hooks[event])) {
307
+ continue;
308
+ }
309
+ const remaining = stripLoreFromCursorEvent(hooks[event]);
310
+ if (remaining.length === 0) {
311
+ delete hooks[event];
312
+ }
313
+ else {
314
+ hooks[event] = remaining;
315
+ }
316
+ }
317
+ if (Object.keys(hooks).length === 0) {
318
+ delete result.hooks;
319
+ }
320
+ else {
321
+ result.hooks = hooks;
322
+ }
323
+ return result;
324
+ }
325
+ export function countLoreCursorHooks(input) {
326
+ if (!isObject(input.hooks)) {
327
+ return 0;
328
+ }
329
+ return CURSOR_HOOK_EVENTS.reduce((count, event) => {
330
+ const hooks = input.hooks;
331
+ if (!isObject(hooks) || !Array.isArray(hooks[event])) {
332
+ return count;
333
+ }
334
+ return count + hooks[event].filter(isLoreHook).length;
335
+ }, 0);
336
+ }
337
+ function polytokenEventHandler(event, paths) {
338
+ return {
339
+ name: `lore-${event.replaceAll("_", "-")}`,
340
+ event,
341
+ handler: { bash: hookCommand("polytoken", paths) },
342
+ };
343
+ }
344
+ function isLorePolytokenHook(value) {
345
+ if (!isObject(value) || !isObject(value.handler)) {
346
+ return false;
347
+ }
348
+ const command = value.handler.bash;
349
+ return (typeof command === "string" &&
350
+ (/(?:^|\s)--owner(?:=|\s+)lore(?:\s|$)/u.test(command) ||
351
+ command.includes("/.lore/bin/lore-hook.mjs")));
352
+ }
353
+ export function mergeLorePolytokenHooks(input, paths) {
354
+ if (!Array.isArray(input)) {
355
+ throw new Error("Polytoken hooks must contain a JSON array");
356
+ }
357
+ const retained = input.filter((hook) => !isLorePolytokenHook(hook));
358
+ return [
359
+ ...structuredClone(retained),
360
+ ...POLYTOKEN_HOOK_EVENTS.map((event) => polytokenEventHandler(event, paths)),
361
+ ];
362
+ }
363
+ export function removeLorePolytokenHooks(input) {
364
+ if (!Array.isArray(input)) {
365
+ throw new Error("Polytoken hooks must contain a JSON array");
366
+ }
367
+ return structuredClone(input.filter((hook) => !isLorePolytokenHook(hook)));
368
+ }
369
+ export function countLorePolytokenHooks(input) {
370
+ return Array.isArray(input)
371
+ ? input.filter((hook) => isLorePolytokenHook(hook)).length
372
+ : 0;
373
+ }
243
374
  function isLoreOpenCodePlugin(value) {
244
375
  return (typeof value === "string" &&
245
376
  /^@lore-co\/(?:opencode|opencode-plugin)(?:@[^\s]+)?$/u.test(value));
@@ -282,9 +413,6 @@ async function readJsonDocument(path) {
282
413
  stat(path),
283
414
  ]);
284
415
  const parsed = JSON.parse(raw);
285
- if (!isObject(parsed)) {
286
- throw new Error(`Expected a JSON object in ${path}`);
287
- }
288
416
  return {
289
417
  exists: true,
290
418
  value: parsed,
@@ -296,7 +424,7 @@ async function readJsonDocument(path) {
296
424
  ? error.code
297
425
  : undefined;
298
426
  if (code === "ENOENT") {
299
- return { exists: false, value: {}, mode: 0o600 };
427
+ return { exists: false, value: undefined, mode: 0o600 };
300
428
  }
301
429
  if (error instanceof SyntaxError) {
302
430
  throw new Error(`Refusing to overwrite invalid JSON in ${path}`, {
@@ -345,6 +473,25 @@ function parseConnectorConfig(value) {
345
473
  return null;
346
474
  }
347
475
  const agents = value.agents.filter((agent) => isConfiguredAgent(agent));
476
+ const parsedAgentConfigPaths = {};
477
+ if (isObject(value.agentConfigPaths)) {
478
+ for (const agent of CONFIGURED_AGENT_NAMES) {
479
+ const paths = value.agentConfigPaths[agent];
480
+ if (!Array.isArray(paths)) {
481
+ continue;
482
+ }
483
+ const valid = paths.flatMap((path) => {
484
+ const normalized = typeof path === "string" ? path.trim() : "";
485
+ return normalized === "" ? [] : [resolve(normalized)];
486
+ });
487
+ if (valid.length > 0) {
488
+ parsedAgentConfigPaths[agent] = [...new Set(valid)];
489
+ }
490
+ }
491
+ }
492
+ const agentConfigPaths = Object.keys(parsedAgentConfigPaths).length === 0
493
+ ? undefined
494
+ : parsedAgentConfigPaths;
348
495
  const timeoutMs = typeof value.timeoutMs === "number" &&
349
496
  Number.isInteger(value.timeoutMs) &&
350
497
  value.timeoutMs >= 250 &&
@@ -360,6 +507,10 @@ function parseConnectorConfig(value) {
360
507
  ...(dashboardUrl === undefined ? {} : { dashboardUrl }),
361
508
  token: value.token,
362
509
  agents,
510
+ ...(agentConfigPaths === undefined ||
511
+ Object.keys(agentConfigPaths).length === 0
512
+ ? {}
513
+ : { agentConfigPaths }),
363
514
  connectedAt: value.connectedAt,
364
515
  timeoutMs,
365
516
  };
@@ -391,6 +542,33 @@ function normalizeApiUrl(value) {
391
542
  }
392
543
  return parsed.href.replace(/\/+$/u, "");
393
544
  }
545
+ export function resolveConnectEndpoints(options = {}) {
546
+ const environment = options.environment ?? process.env;
547
+ const configuredApiUrl = options.apiUrl ??
548
+ environment.LORE_API_URL ??
549
+ environment.LORE_BASE_URL;
550
+ const apiUrl = normalizeApiUrl(configuredApiUrl ??
551
+ (options.preferHostedDefault
552
+ ? DEFAULT_HOSTED_API_URL
553
+ : (options.existing?.apiUrl ?? DEFAULT_HOSTED_API_URL)));
554
+ const configuredDashboardUrl = options.dashboardUrl ?? environment.LORE_DASHBOARD_URL;
555
+ const dashboardUrlValue = options.preferHostedDefault
556
+ ? (configuredDashboardUrl ??
557
+ (apiUrl === DEFAULT_HOSTED_API_URL
558
+ ? DEFAULT_HOSTED_DASHBOARD_URL
559
+ : undefined))
560
+ : (configuredDashboardUrl ??
561
+ options.existing?.dashboardUrl ??
562
+ (apiUrl === DEFAULT_HOSTED_API_URL
563
+ ? DEFAULT_HOSTED_DASHBOARD_URL
564
+ : undefined));
565
+ return {
566
+ apiUrl,
567
+ ...(dashboardUrlValue === undefined
568
+ ? {}
569
+ : { dashboardUrl: normalizeApiUrl(dashboardUrlValue) }),
570
+ };
571
+ }
394
572
  function configuredToken(explicit, existing) {
395
573
  if (explicit !== undefined) {
396
574
  return explicit;
@@ -489,20 +667,266 @@ async function pathExists(path) {
489
667
  return false;
490
668
  }
491
669
  }
670
+ async function cursorAvailable(paths) {
671
+ return ((await commandExists("cursor")) ||
672
+ (await pathExists(paths.cursorUserData)));
673
+ }
674
+ function objectConfiguration(input, agent) {
675
+ if (!isObject(input)) {
676
+ throw new Error(`${agent} configuration must contain a JSON object`);
677
+ }
678
+ return input;
679
+ }
680
+ const AGENT_TARGETS = {
681
+ claude: {
682
+ integration: "hooks",
683
+ expected: HOOK_EVENTS.length,
684
+ runtimeRequired: true,
685
+ emptyValue: () => ({}),
686
+ configPath: (paths) => paths.claudeSettings,
687
+ detect: async () => commandExists("claude"),
688
+ executable: async () => commandExists("claude"),
689
+ merge: (input, paths) => mergeLoreHooks(objectConfiguration(input, "claude"), "claude", paths),
690
+ remove: (input) => removeLoreHooks(objectConfiguration(input, "claude")),
691
+ count: (input) => countLoreHooks(objectConfiguration(input, "claude")),
692
+ },
693
+ codex: {
694
+ integration: "hooks",
695
+ expected: HOOK_EVENTS.length,
696
+ runtimeRequired: true,
697
+ emptyValue: () => ({}),
698
+ configPath: (paths) => paths.codexHooks,
699
+ detect: async () => commandExists("codex"),
700
+ executable: async () => commandExists("codex"),
701
+ merge: (input, paths) => mergeLoreHooks(objectConfiguration(input, "codex"), "codex", paths),
702
+ remove: (input) => removeLoreHooks(objectConfiguration(input, "codex")),
703
+ count: (input) => countLoreHooks(objectConfiguration(input, "codex")),
704
+ },
705
+ cursor: {
706
+ integration: "hooks",
707
+ expected: CURSOR_HOOK_EVENTS.length,
708
+ runtimeRequired: true,
709
+ emptyValue: () => ({}),
710
+ configPath: (paths) => paths.cursorHooks,
711
+ detect: async (paths) => (await cursorAvailable(paths)) || pathExists(paths.cursorHooks),
712
+ executable: cursorAvailable,
713
+ merge: (input, paths) => mergeLoreCursorHooks(objectConfiguration(input, "cursor"), paths),
714
+ remove: (input) => removeLoreCursorHooks(objectConfiguration(input, "cursor")),
715
+ count: (input) => countLoreCursorHooks(objectConfiguration(input, "cursor")),
716
+ },
717
+ opencode: {
718
+ integration: "plugin",
719
+ expected: 1,
720
+ runtimeRequired: false,
721
+ emptyValue: () => ({}),
722
+ configPath: (paths) => paths.openCodeConfig,
723
+ detect: async (paths) => (await commandExists("opencode")) || pathExists(paths.openCodeConfig),
724
+ executable: async () => commandExists("opencode"),
725
+ merge: (input) => mergeLoreOpenCodePlugin(objectConfiguration(input, "opencode")),
726
+ remove: (input) => removeLoreOpenCodePlugin(objectConfiguration(input, "opencode")),
727
+ count: (input) => countLoreOpenCodePlugins(objectConfiguration(input, "opencode")),
728
+ },
729
+ polytoken: {
730
+ integration: "hooks",
731
+ expected: POLYTOKEN_HOOK_EVENTS.length,
732
+ runtimeRequired: true,
733
+ emptyValue: () => [],
734
+ configPath: (paths) => paths.polytokenHooks,
735
+ detect: async (paths) => (await commandExists("polytoken")) || pathExists(paths.polytokenHooks),
736
+ executable: async () => commandExists("polytoken"),
737
+ merge: mergeLorePolytokenHooks,
738
+ remove: removeLorePolytokenHooks,
739
+ count: countLorePolytokenHooks,
740
+ },
741
+ };
742
+ function targetFor(agent) {
743
+ return AGENT_TARGETS[agent];
744
+ }
492
745
  async function detectAgents(paths) {
493
- const [codex, claude, openCodeExecutable, openCodeConfig] = await Promise.all([
494
- commandExists("codex"),
495
- commandExists("claude"),
496
- commandExists("opencode"),
497
- pathExists(paths.openCodeConfig),
746
+ const detected = await Promise.all(CONFIGURED_AGENT_NAMES.map(async (agent) => ({
747
+ agent,
748
+ detected: await targetFor(agent).detect(paths),
749
+ })));
750
+ return detected.flatMap(({ agent, detected: present }) => present ? [agent] : []);
751
+ }
752
+ function installationKey(installation) {
753
+ return `${installation.agent}\0${resolve(installation.configPath)}`;
754
+ }
755
+ function uniqueInstallations(installations) {
756
+ const byKey = new Map();
757
+ for (const installation of installations) {
758
+ const normalized = {
759
+ agent: installation.agent,
760
+ configPath: resolve(installation.configPath),
761
+ };
762
+ byKey.set(installationKey(normalized), normalized);
763
+ }
764
+ return [...byKey.values()].sort((left, right) => installationKey(left).localeCompare(installationKey(right)));
765
+ }
766
+ function defaultInstallation(agent, paths) {
767
+ return { agent, configPath: targetFor(agent).configPath(paths) };
768
+ }
769
+ function configuredInstallations(config, paths) {
770
+ return (config?.agents.flatMap((agent) => {
771
+ const configuredPaths = config.agentConfigPaths?.[agent];
772
+ return configuredPaths === undefined
773
+ ? [defaultInstallation(agent, paths)]
774
+ : configuredPaths.map((configPath) => ({ agent, configPath }));
775
+ }) ?? []);
776
+ }
777
+ function nestedObject(input, key) {
778
+ return isObject(input[key]) ? input[key] : {};
779
+ }
780
+ function firstString(...values) {
781
+ for (const value of values) {
782
+ if (typeof value === "string" && value.trim() !== "") {
783
+ return value.trim();
784
+ }
785
+ }
786
+ return undefined;
787
+ }
788
+ function t3ProviderAgent(key, input) {
789
+ const config = nestedObject(input, "config");
790
+ const signal = [
791
+ key,
792
+ firstString(input.driver),
793
+ firstString(input.provider),
794
+ firstString(input.type),
795
+ firstString(config.driver),
796
+ firstString(config.provider),
797
+ firstString(config.type),
798
+ ]
799
+ .filter((value) => value !== undefined)
800
+ .join(" ")
801
+ .toLocaleLowerCase();
802
+ if (signal.includes("opencode")) {
803
+ return "opencode";
804
+ }
805
+ if (signal.includes("cursor")) {
806
+ return "cursor";
807
+ }
808
+ if (signal.includes("claude")) {
809
+ return "claude";
810
+ }
811
+ if (signal.includes("codex")) {
812
+ return "codex";
813
+ }
814
+ return undefined;
815
+ }
816
+ function t3ProviderConfigPath(agent, input, paths) {
817
+ const config = nestedObject(input, "config");
818
+ const environment = nestedObject(input, "environment");
819
+ const configEnvironment = nestedObject(config, "environment");
820
+ const home = nestedObject(input, "home");
821
+ const configHome = nestedObject(config, "home");
822
+ const environmentValue = (key) => environment[key] ?? configEnvironment[key];
823
+ const homePath = firstString(input.homePath, config.homePath, home.path, configHome.path);
824
+ if (agent === "codex") {
825
+ const root = firstString(homePath, environmentValue("CODEX_HOME"));
826
+ return root === undefined
827
+ ? paths.codexHooks
828
+ : resolve(root, "hooks.json");
829
+ }
830
+ if (agent === "claude") {
831
+ const root = firstString(homePath, environmentValue("CLAUDE_CONFIG_DIR"));
832
+ return root === undefined
833
+ ? paths.claudeSettings
834
+ : resolve(root, "settings.json");
835
+ }
836
+ if (agent === "cursor") {
837
+ return paths.cursorHooks;
838
+ }
839
+ const serverUrl = firstString(input.serverUrl, config.serverUrl);
840
+ if (serverUrl !== undefined) {
841
+ return undefined;
842
+ }
843
+ const explicitConfig = firstString(input.configPath, config.configPath, environmentValue("OPENCODE_CONFIG"));
844
+ if (explicitConfig !== undefined) {
845
+ return resolve(explicitConfig);
846
+ }
847
+ const configDirectory = firstString(input.configDirectory, config.configDirectory, environmentValue("OPENCODE_CONFIG_DIR"));
848
+ if (configDirectory !== undefined) {
849
+ return resolve(configDirectory, "opencode.json");
850
+ }
851
+ const xdgConfigHome = firstString(environmentValue("XDG_CONFIG_HOME"));
852
+ return xdgConfigHome === undefined
853
+ ? paths.openCodeConfig
854
+ : resolve(xdgConfigHome, "opencode", "opencode.json");
855
+ }
856
+ function discoverT3CodeProviders(settings, paths) {
857
+ if (!isObject(settings)) {
858
+ throw new Error("T3 Code settings must contain a JSON object");
859
+ }
860
+ const records = [];
861
+ for (const field of ["providerInstances", "providers"]) {
862
+ const collection = settings[field];
863
+ if (!isObject(collection)) {
864
+ continue;
865
+ }
866
+ for (const [key, value] of Object.entries(collection)) {
867
+ if (isObject(value)) {
868
+ records.push([key, value]);
869
+ }
870
+ }
871
+ }
872
+ const warnings = [];
873
+ const installations = uniqueInstallations(records.flatMap(([key, value]) => {
874
+ if (value.enabled === false || value.disabled === true) {
875
+ return [];
876
+ }
877
+ const agent = t3ProviderAgent(key, value);
878
+ if (agent === undefined) {
879
+ return [];
880
+ }
881
+ const configPath = t3ProviderConfigPath(agent, value, paths);
882
+ if (agent === "opencode" && configPath === undefined) {
883
+ warnings.push(`T3 Code provider "${key}" uses an external OpenCode server; install and configure Lore on that server.`);
884
+ }
885
+ return configPath === undefined ? [] : [{ agent, configPath }];
886
+ }));
887
+ return { installations, warnings };
888
+ }
889
+ export function t3CodeProviderInstallations(settings, paths) {
890
+ return discoverT3CodeProviders(settings, paths).installations;
891
+ }
892
+ async function readT3CodeProviderInstallations(paths) {
893
+ try {
894
+ return discoverT3CodeProviders(JSON.parse(await readFile(paths.t3Settings, "utf8")), paths);
895
+ }
896
+ catch (error) {
897
+ const code = typeof error === "object" && error !== null && "code" in error
898
+ ? error.code
899
+ : undefined;
900
+ if (code === "ENOENT") {
901
+ return { installations: [], warnings: [] };
902
+ }
903
+ if (error instanceof SyntaxError) {
904
+ throw new Error(`Refusing to use invalid T3 Code settings in ${paths.t3Settings}`, { cause: error });
905
+ }
906
+ throw error;
907
+ }
908
+ }
909
+ async function expandConnectInstallations(requested, paths) {
910
+ const direct = requested.flatMap((agent) => agent === "t3code" ? [] : [defaultInstallation(agent, paths)]);
911
+ if (!requested.includes("t3code")) {
912
+ return { installations: direct, warnings: [] };
913
+ }
914
+ const configured = await readT3CodeProviderInstallations(paths);
915
+ const t3Providers = ["claude", "codex", "cursor", "opencode"];
916
+ const detected = await Promise.all(t3Providers.map(async (agent) => ({
917
+ agent,
918
+ available: await targetFor(agent).detect(paths),
919
+ })));
920
+ const routed = detected.flatMap(({ agent, available }) => available ? [defaultInstallation(agent, paths)] : []);
921
+ const installations = uniqueInstallations([
922
+ ...direct,
923
+ ...configured.installations,
924
+ ...routed,
498
925
  ]);
499
- return [
500
- ...(codex ? ["codex"] : []),
501
- ...(claude ? ["claude"] : []),
502
- ...(openCodeExecutable || openCodeConfig
503
- ? ["opencode"]
504
- : []),
505
- ];
926
+ if (installations.length === 0) {
927
+ throw new Error(`T3 Code delegates to provider CLIs, but no supported local Claude, Codex, Cursor, or OpenCode provider was found in ${paths.t3Settings}. Install a provider, set T3CODE_HOME, or connect it explicitly.`);
928
+ }
929
+ return { installations, warnings: configured.warnings };
506
930
  }
507
931
  async function installRuntime(paths) {
508
932
  if (IS_STANDALONE_BINARY) {
@@ -524,28 +948,19 @@ async function installRuntime(paths) {
524
948
  atomicWrite(paths.runtimePackage, '{"type":"module"}\n', 0o600),
525
949
  ]);
526
950
  }
527
- function hookPath(agent, paths) {
528
- return agent === "codex" ? paths.codexHooks : paths.claudeSettings;
529
- }
530
951
  function agentConfigPath(agent, paths) {
531
- return agent === "opencode"
532
- ? paths.openCodeConfig
533
- : hookPath(agent, paths);
952
+ return targetFor(agent).configPath(paths);
534
953
  }
535
954
  function mergeAgentConfig(input, agent, paths) {
536
- return agent === "opencode"
537
- ? mergeLoreOpenCodePlugin(input)
538
- : mergeLoreHooks(input, agent, paths);
955
+ const target = targetFor(agent);
956
+ return target.merge(input === undefined ? target.emptyValue() : input, paths);
539
957
  }
540
958
  function removeAgentConfig(input, agent) {
541
- return agent === "opencode"
542
- ? removeLoreOpenCodePlugin(input)
543
- : removeLoreHooks(input);
959
+ return targetFor(agent).remove(input);
544
960
  }
545
961
  function countAgentIntegrations(input, agent) {
546
- return agent === "opencode"
547
- ? countLoreOpenCodePlugins(input)
548
- : countLoreHooks(input);
962
+ const target = targetFor(agent);
963
+ return target.count(input === undefined ? target.emptyValue() : input);
549
964
  }
550
965
  function parseInteger(value, flag) {
551
966
  const parsed = Number(value);
@@ -598,11 +1013,11 @@ function parseConnectArguments(args) {
598
1013
  }
599
1014
  parsed.timeoutMs = timeoutMs;
600
1015
  }
601
- else if (isConfiguredAgent(value)) {
1016
+ else if (isConnectAgent(value)) {
602
1017
  parsed.agents.push(value);
603
1018
  }
604
1019
  else {
605
- throw new Error("--agent must be claude, codex, or opencode");
1020
+ throw new Error("--agent must be claude, codex, cursor, opencode, polytoken, or t3code");
606
1021
  }
607
1022
  }
608
1023
  return parsed;
@@ -635,69 +1050,81 @@ async function connectCommand(args) {
635
1050
  }
636
1051
  const paths = getLorePaths();
637
1052
  const existing = await readConnectorConfig(paths);
638
- const apiUrlValue = parsed.apiUrl ??
639
- process.env.LORE_API_URL ??
640
- process.env.LORE_BASE_URL ??
641
- existing?.apiUrl;
1053
+ const hasProvidedToken = parsed.token !== undefined ||
1054
+ (process.env.LORE_WORKSPACE_TOKEN?.trim() ?? "") !== "" ||
1055
+ (process.env.LORE_TOKEN?.trim() ?? "") !== "";
1056
+ const { apiUrl, dashboardUrl } = resolveConnectEndpoints({
1057
+ ...(parsed.apiUrl === undefined ? {} : { apiUrl: parsed.apiUrl }),
1058
+ ...(parsed.dashboardUrl === undefined
1059
+ ? {}
1060
+ : { dashboardUrl: parsed.dashboardUrl }),
1061
+ environment: process.env,
1062
+ existing,
1063
+ preferHostedDefault: hasProvidedToken,
1064
+ });
642
1065
  const token = configuredToken(parsed.token, existing);
643
- const dashboardUrlValue = parsed.dashboardUrl ??
644
- process.env.LORE_DASHBOARD_URL ??
645
- existing?.dashboardUrl;
646
- if (apiUrlValue === undefined || apiUrlValue.trim() === "") {
647
- throw new Error("Lore API URL is required. Use --url <url> or LORE_API_URL.");
648
- }
649
1066
  if (token === undefined || token.trim() === "") {
650
1067
  throw new Error("Workspace token is required. Use --token <token>, LORE_WORKSPACE_TOKEN, or LORE_TOKEN.");
651
1068
  }
652
- const apiUrl = normalizeApiUrl(apiUrlValue);
653
1069
  const timeoutMs = parsed.timeoutMs ?? existing?.timeoutMs ?? 2_500;
654
1070
  const identity = await authenticatedIdentity(apiUrl, token.trim(), timeoutMs);
655
- const detected = parsed.agents.length === 0 ? await detectAgents(paths) : [];
1071
+ const requestedExpansion = parsed.agents.length === 0
1072
+ ? { installations: [], warnings: [] }
1073
+ : await expandConnectInstallations(parsed.agents, paths);
1074
+ const detectedAgents = parsed.agents.length === 0 ? await detectAgents(paths) : [];
1075
+ const installations = uniqueInstallations([
1076
+ ...configuredInstallations(existing, paths),
1077
+ ...requestedExpansion.installations,
1078
+ ...detectedAgents.map((agent) => defaultInstallation(agent, paths)),
1079
+ ]);
656
1080
  const agents = [
657
- ...new Set([
658
- ...(existing?.agents ?? []),
659
- ...parsed.agents,
660
- ...detected,
661
- ]),
1081
+ ...new Set(installations.map((installation) => installation.agent)),
662
1082
  ].sort();
663
1083
  if (agents.length === 0) {
664
- throw new Error("No Claude, Codex, or OpenCode installation detected. Use --agent <name>.");
1084
+ throw new Error("No Claude, Codex, Cursor, OpenCode, or Polytoken installation detected. Use --agent <name>; T3 Code users can use --agent t3code.");
665
1085
  }
666
1086
  const now = new Date();
667
1087
  const documents = new Map();
668
1088
  const mergedDocuments = new Map();
669
- for (const agent of agents) {
670
- const document = await readJsonDocument(agentConfigPath(agent, paths));
671
- documents.set(agent, document);
672
- mergedDocuments.set(agent, mergeAgentConfig(document.value, agent, paths));
1089
+ for (const installation of installations) {
1090
+ const key = installationKey(installation);
1091
+ const document = await readJsonDocument(installation.configPath);
1092
+ documents.set(key, document);
1093
+ mergedDocuments.set(key, mergeAgentConfig(document.value, installation.agent, paths));
673
1094
  }
674
- if (agents.some(isCommandHookAgent)) {
1095
+ if (installations.some((installation) => targetFor(installation.agent).runtimeRequired)) {
675
1096
  await installRuntime(paths);
676
1097
  }
677
1098
  const changedHookFiles = [];
678
1099
  const backups = [];
679
- for (const agent of agents) {
680
- const document = documents.get(agent);
681
- const merged = mergedDocuments.get(agent);
1100
+ for (const installation of installations) {
1101
+ const key = installationKey(installation);
1102
+ const document = documents.get(key);
1103
+ const merged = mergedDocuments.get(key);
682
1104
  if (document === undefined || merged === undefined) {
683
1105
  continue;
684
1106
  }
685
- const result = await writeMergedJson(agentConfigPath(agent, paths), document, merged, now);
1107
+ const result = await writeMergedJson(installation.configPath, document, merged, now);
686
1108
  if (result.changed) {
687
- changedHookFiles.push(agentConfigPath(agent, paths));
1109
+ changedHookFiles.push(installation.configPath);
688
1110
  }
689
1111
  if (result.backup !== undefined) {
690
1112
  backups.push(result.backup);
691
1113
  }
692
1114
  }
1115
+ const agentConfigPaths = {};
1116
+ for (const agent of agents) {
1117
+ agentConfigPaths[agent] = installations
1118
+ .filter((installation) => installation.agent === agent)
1119
+ .map((installation) => installation.configPath);
1120
+ }
693
1121
  const config = {
694
1122
  version: 1,
695
1123
  apiUrl,
696
- ...(dashboardUrlValue === undefined
697
- ? {}
698
- : { dashboardUrl: normalizeApiUrl(dashboardUrlValue) }),
1124
+ ...(dashboardUrl === undefined ? {} : { dashboardUrl }),
699
1125
  token: token.trim(),
700
1126
  agents,
1127
+ agentConfigPaths,
701
1128
  connectedAt: existing?.connectedAt ?? now.toISOString(),
702
1129
  timeoutMs,
703
1130
  };
@@ -710,8 +1137,9 @@ async function connectCommand(args) {
710
1137
  config: paths.config,
711
1138
  changedHookFiles,
712
1139
  backups,
1140
+ warnings: requestedExpansion.warnings,
713
1141
  };
714
- writeResult(result, parsed.json, `connected: ${agents.join(", ")}\napi_url: ${config.apiUrl}\nconfig: ${paths.config}\n`);
1142
+ writeResult(result, parsed.json, `connected: ${agents.join(", ")}\napi_url: ${config.apiUrl}\nconfig: ${paths.config}\n${requestedExpansion.warnings.map((warning) => `warning: ${warning}\n`).join("")}`);
715
1143
  }
716
1144
  async function queueCount(paths) {
717
1145
  try {
@@ -722,23 +1150,33 @@ async function queueCount(paths) {
722
1150
  }
723
1151
  }
724
1152
  async function getAgentStatus(agent, config, paths) {
725
- const path = agentConfigPath(agent, paths);
1153
+ const target = targetFor(agent);
1154
+ const configuredPaths = config?.agentConfigPaths?.[agent];
1155
+ const configPaths = config?.agents.includes(agent) === true && configuredPaths !== undefined
1156
+ ? configuredPaths
1157
+ : [agentConfigPath(agent, paths)];
726
1158
  let installed = 0;
727
- try {
728
- installed = countAgentIntegrations((await readJsonDocument(path)).value, agent);
729
- }
730
- catch {
731
- installed = 0;
1159
+ let existing = 0;
1160
+ for (const path of configPaths) {
1161
+ try {
1162
+ const document = await readJsonDocument(path);
1163
+ installed += countAgentIntegrations(document.value, agent);
1164
+ existing += document.exists ? 1 : 0;
1165
+ }
1166
+ catch {
1167
+ // A malformed target config is reported as missing integration state.
1168
+ }
732
1169
  }
733
1170
  return {
734
1171
  agent,
735
1172
  configured: config?.agents.includes(agent) ?? false,
736
- executable: await commandExists(agent),
737
- configExists: await pathExists(path),
738
- configFile: path,
739
- integration: agent === "opencode" ? "plugin" : "hooks",
1173
+ executable: await target.executable(paths),
1174
+ configExists: existing === configPaths.length,
1175
+ configFile: configPaths[0] ?? agentConfigPath(agent, paths),
1176
+ ...(configPaths.length > 1 ? { configFiles: configPaths } : {}),
1177
+ integration: target.integration,
740
1178
  installed,
741
- expected: agent === "opencode" ? 1 : HOOK_EVENTS.length,
1179
+ expected: target.expected * configPaths.length,
742
1180
  };
743
1181
  }
744
1182
  async function statusData(paths) {
@@ -750,7 +1188,7 @@ async function statusData(paths) {
750
1188
  catch {
751
1189
  // Missing configuration is represented as disconnected.
752
1190
  }
753
- const runtimeRequired = config?.agents.some(isCommandHookAgent) ?? false;
1191
+ const runtimeRequired = config?.agents.some((agent) => targetFor(agent).runtimeRequired) ?? false;
754
1192
  const runtimeInstalledCheck = runtimeRequired
755
1193
  ? Promise.all(IS_STANDALONE_BINARY
756
1194
  ? [access(process.execPath, fsConstants.R_OK | fsConstants.X_OK)]
@@ -760,12 +1198,10 @@ async function statusData(paths) {
760
1198
  access(paths.runtimePackage, fsConstants.R_OK),
761
1199
  ]).then(() => true, () => false)
762
1200
  : Promise.resolve(false);
763
- const [runtimeInstalled, queuedTurns, claude, codex, opencode] = await Promise.all([
1201
+ const [runtimeInstalled, queuedTurns, agents] = await Promise.all([
764
1202
  runtimeInstalledCheck,
765
1203
  queueCount(paths),
766
- getAgentStatus("claude", config, paths),
767
- getAgentStatus("codex", config, paths),
768
- getAgentStatus("opencode", config, paths),
1204
+ Promise.all(CONFIGURED_AGENT_NAMES.map(async (agent) => getAgentStatus(agent, config, paths))),
769
1205
  ]);
770
1206
  return {
771
1207
  connected: config !== null,
@@ -775,7 +1211,7 @@ async function statusData(paths) {
775
1211
  runtimeRequired,
776
1212
  runtimeInstalled,
777
1213
  queuedTurns,
778
- agents: [claude, codex, opencode],
1214
+ agents,
779
1215
  };
780
1216
  }
781
1217
  async function statusCommand(args) {
@@ -795,16 +1231,21 @@ async function disconnectCommand(args) {
795
1231
  return;
796
1232
  }
797
1233
  const paths = getLorePaths();
1234
+ const config = await readConnectorConfig(paths);
798
1235
  const now = new Date();
799
1236
  const changedHookFiles = [];
800
1237
  const backups = [];
801
- for (const agent of CONFIGURED_AGENT_NAMES) {
802
- const path = agentConfigPath(agent, paths);
1238
+ const installations = uniqueInstallations([
1239
+ ...CONFIGURED_AGENT_NAMES.map((agent) => defaultInstallation(agent, paths)),
1240
+ ...configuredInstallations(config, paths),
1241
+ ]);
1242
+ for (const installation of installations) {
1243
+ const path = installation.configPath;
803
1244
  const document = await readJsonDocument(path);
804
1245
  if (!document.exists) {
805
1246
  continue;
806
1247
  }
807
- const result = await writeMergedJson(path, document, removeAgentConfig(document.value, agent), now);
1248
+ const result = await writeMergedJson(path, document, removeAgentConfig(document.value, installation.agent), now);
808
1249
  if (result.changed) {
809
1250
  changedHookFiles.push(path);
810
1251
  }
@@ -924,7 +1365,7 @@ async function doctorCommand(args) {
924
1365
  checks.push({
925
1366
  name: `${agent.agent}-executable`,
926
1367
  status: agent.executable ? "ok" : "warning",
927
- detail: agent.executable ? "found on PATH" : "not found on PATH",
1368
+ detail: agent.executable ? "detected" : "not detected",
928
1369
  });
929
1370
  checks.push({
930
1371
  name: `${agent.agent}-${agent.integration}`,