gnosys 5.11.4 → 5.12.0

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.
Files changed (240) hide show
  1. package/dist/cli.js +324 -5150
  2. package/dist/index.js +364 -235
  3. package/dist/lib/addCommand.d.ts +9 -0
  4. package/dist/lib/addCommand.js +103 -0
  5. package/dist/lib/addStructuredCommand.d.ts +16 -0
  6. package/dist/lib/addStructuredCommand.js +103 -0
  7. package/dist/lib/ambiguityCommand.d.ts +4 -0
  8. package/dist/lib/ambiguityCommand.js +36 -0
  9. package/dist/lib/apiKeyVault.d.ts +78 -0
  10. package/dist/lib/apiKeyVault.js +447 -0
  11. package/dist/lib/askCommand.d.ts +13 -0
  12. package/dist/lib/askCommand.js +145 -0
  13. package/dist/lib/audioExtract.js +4 -1
  14. package/dist/lib/auditCommand.d.ts +7 -0
  15. package/dist/lib/auditCommand.js +27 -0
  16. package/dist/lib/backupCommand.d.ts +6 -0
  17. package/dist/lib/backupCommand.js +54 -0
  18. package/dist/lib/bootstrapCommand.d.ts +15 -0
  19. package/dist/lib/bootstrapCommand.js +51 -0
  20. package/dist/lib/briefingCommand.d.ts +7 -0
  21. package/dist/lib/briefingCommand.js +92 -0
  22. package/dist/lib/centralizeCommand.d.ts +5 -0
  23. package/dist/lib/centralizeCommand.js +16 -0
  24. package/dist/lib/chatCommand.d.ts +12 -0
  25. package/dist/lib/chatCommand.js +46 -0
  26. package/dist/lib/checkCommand.d.ts +4 -0
  27. package/dist/lib/checkCommand.js +133 -0
  28. package/dist/lib/clientReadOverlay.d.ts +27 -0
  29. package/dist/lib/clientReadOverlay.js +73 -0
  30. package/dist/lib/clientReadResolve.d.ts +32 -0
  31. package/dist/lib/clientReadResolve.js +84 -0
  32. package/dist/lib/commitContextCommand.d.ts +9 -0
  33. package/dist/lib/commitContextCommand.js +142 -0
  34. package/dist/lib/config.d.ts +43 -3
  35. package/dist/lib/config.js +58 -57
  36. package/dist/lib/configCommand.d.ts +10 -0
  37. package/dist/lib/configCommand.js +321 -0
  38. package/dist/lib/connectCommand.d.ts +8 -0
  39. package/dist/lib/connectCommand.js +19 -0
  40. package/dist/lib/db.d.ts +52 -0
  41. package/dist/lib/db.js +169 -1
  42. package/dist/lib/dearchiveCommand.d.ts +7 -0
  43. package/dist/lib/dearchiveCommand.js +41 -0
  44. package/dist/lib/discoverCommand.d.ts +9 -0
  45. package/dist/lib/discoverCommand.js +87 -0
  46. package/dist/lib/doctorCommand.d.ts +6 -0
  47. package/dist/lib/doctorCommand.js +256 -0
  48. package/dist/lib/dream.d.ts +42 -2
  49. package/dist/lib/dream.js +290 -30
  50. package/dist/lib/dreamCommand.d.ts +10 -0
  51. package/dist/lib/dreamCommand.js +195 -0
  52. package/dist/lib/dreamLaunchd.d.ts +2 -0
  53. package/dist/lib/dreamLaunchd.js +72 -0
  54. package/dist/lib/dreamLogCommand.d.ts +10 -0
  55. package/dist/lib/dreamLogCommand.js +58 -0
  56. package/dist/lib/dreamReport.d.ts +7 -0
  57. package/dist/lib/dreamReport.js +114 -0
  58. package/dist/lib/dreamRunLog.d.ts +121 -0
  59. package/dist/lib/dreamRunLog.js +212 -0
  60. package/dist/lib/embeddings.js +3 -0
  61. package/dist/lib/exportCommand.d.ts +18 -0
  62. package/dist/lib/exportCommand.js +101 -0
  63. package/dist/lib/fsearchCommand.d.ts +8 -0
  64. package/dist/lib/fsearchCommand.js +44 -0
  65. package/dist/lib/graphCommand.d.ts +4 -0
  66. package/dist/lib/graphCommand.js +68 -0
  67. package/dist/lib/helperGenerateCommand.d.ts +5 -0
  68. package/dist/lib/helperGenerateCommand.js +27 -0
  69. package/dist/lib/historyCommand.d.ts +5 -0
  70. package/dist/lib/historyCommand.js +51 -0
  71. package/dist/lib/hybridSearchCommand.d.ts +12 -0
  72. package/dist/lib/hybridSearchCommand.js +95 -0
  73. package/dist/lib/importCommand.d.ts +16 -0
  74. package/dist/lib/importCommand.js +89 -0
  75. package/dist/lib/importProjectCommand.d.ts +6 -0
  76. package/dist/lib/importProjectCommand.js +43 -0
  77. package/dist/lib/ingestCommand.d.ts +13 -0
  78. package/dist/lib/ingestCommand.js +95 -0
  79. package/dist/lib/installOutput.d.ts +36 -0
  80. package/dist/lib/installOutput.js +55 -0
  81. package/dist/lib/lensCommand.d.ts +20 -0
  82. package/dist/lib/lensCommand.js +61 -0
  83. package/dist/lib/lensing.d.ts +1 -0
  84. package/dist/lib/lensing.js +50 -9
  85. package/dist/lib/linksCommand.d.ts +7 -0
  86. package/dist/lib/linksCommand.js +48 -0
  87. package/dist/lib/listCommand.d.ts +8 -0
  88. package/dist/lib/listCommand.js +74 -0
  89. package/dist/lib/llm.d.ts +1 -1
  90. package/dist/lib/llm.js +26 -8
  91. package/dist/lib/localDiskCheck.d.ts +17 -0
  92. package/dist/lib/localDiskCheck.js +54 -0
  93. package/dist/lib/machineConfig.d.ts +11 -1
  94. package/dist/lib/machineConfig.js +16 -0
  95. package/dist/lib/machineRegistry.d.ts +61 -0
  96. package/dist/lib/machineRegistry.js +80 -0
  97. package/dist/lib/maintainCommand.d.ts +8 -0
  98. package/dist/lib/maintainCommand.js +34 -0
  99. package/dist/lib/masterLease.d.ts +20 -0
  100. package/dist/lib/masterLease.js +68 -0
  101. package/dist/lib/migrateCommand.d.ts +7 -0
  102. package/dist/lib/migrateCommand.js +158 -0
  103. package/dist/lib/migrateDbCommand.d.ts +9 -0
  104. package/dist/lib/migrateDbCommand.js +94 -0
  105. package/dist/lib/modelValidation.d.ts +5 -0
  106. package/dist/lib/modelValidation.js +27 -0
  107. package/dist/lib/openrouterTiers.d.ts +29 -0
  108. package/dist/lib/openrouterTiers.js +113 -0
  109. package/dist/lib/prefCommand.d.ts +10 -0
  110. package/dist/lib/prefCommand.js +118 -0
  111. package/dist/lib/projectsCommand.d.ts +8 -0
  112. package/dist/lib/projectsCommand.js +131 -0
  113. package/dist/lib/readCommand.d.ts +7 -0
  114. package/dist/lib/readCommand.js +62 -0
  115. package/dist/lib/recall.d.ts +3 -0
  116. package/dist/lib/recall.js +19 -4
  117. package/dist/lib/recallCommand.d.ts +11 -0
  118. package/dist/lib/recallCommand.js +112 -0
  119. package/dist/lib/reflectCommand.d.ts +8 -0
  120. package/dist/lib/reflectCommand.js +61 -0
  121. package/dist/lib/reindexCommand.d.ts +4 -0
  122. package/dist/lib/reindexCommand.js +34 -0
  123. package/dist/lib/reindexGraphCommand.d.ts +4 -0
  124. package/dist/lib/reindexGraphCommand.js +12 -0
  125. package/dist/lib/reinforceCommand.d.ts +8 -0
  126. package/dist/lib/reinforceCommand.js +40 -0
  127. package/dist/lib/remote.d.ts +5 -1
  128. package/dist/lib/remote.js +5 -1
  129. package/dist/lib/remoteWizard.d.ts +24 -5
  130. package/dist/lib/remoteWizard.js +308 -319
  131. package/dist/lib/restoreCommand.d.ts +5 -0
  132. package/dist/lib/restoreCommand.js +35 -0
  133. package/dist/lib/sandboxStartCommand.d.ts +6 -0
  134. package/dist/lib/sandboxStartCommand.js +25 -0
  135. package/dist/lib/sandboxStatusCommand.d.ts +4 -0
  136. package/dist/lib/sandboxStatusCommand.js +24 -0
  137. package/dist/lib/sandboxStopCommand.d.ts +4 -0
  138. package/dist/lib/sandboxStopCommand.js +21 -0
  139. package/dist/lib/searchCommand.d.ts +9 -0
  140. package/dist/lib/searchCommand.js +90 -0
  141. package/dist/lib/semanticSearchCommand.d.ts +8 -0
  142. package/dist/lib/semanticSearchCommand.js +52 -0
  143. package/dist/lib/setup/configSetRender.js +2 -0
  144. package/dist/lib/setup/providerGlyphs.d.ts +19 -0
  145. package/dist/lib/setup/providerGlyphs.js +42 -0
  146. package/dist/lib/setup/remoteRender.d.ts +31 -1
  147. package/dist/lib/setup/remoteRender.js +95 -4
  148. package/dist/lib/setup/sections/providers.d.ts +17 -0
  149. package/dist/lib/setup/sections/providers.js +255 -0
  150. package/dist/lib/setup/sections/routing.d.ts +2 -6
  151. package/dist/lib/setup/sections/routing.js +33 -85
  152. package/dist/lib/setup/sections/taskRoutingEditor.d.ts +17 -0
  153. package/dist/lib/setup/sections/taskRoutingEditor.js +149 -0
  154. package/dist/lib/setup/summary.d.ts +9 -0
  155. package/dist/lib/setup/summary.js +51 -37
  156. package/dist/lib/setup/ui/status.d.ts +1 -0
  157. package/dist/lib/setup/ui/status.js +2 -0
  158. package/dist/lib/setup.d.ts +108 -3
  159. package/dist/lib/setup.js +762 -157
  160. package/dist/lib/setupKeys.d.ts +42 -0
  161. package/dist/lib/setupKeys.js +564 -0
  162. package/dist/lib/setupRemoteCommand.d.ts +4 -0
  163. package/dist/lib/setupRemoteCommand.js +28 -0
  164. package/dist/lib/setupRemotePullCommand.d.ts +5 -0
  165. package/dist/lib/setupRemotePullCommand.js +52 -0
  166. package/dist/lib/setupRemotePushCommand.d.ts +5 -0
  167. package/dist/lib/setupRemotePushCommand.js +57 -0
  168. package/dist/lib/setupRemoteResolveCommand.d.ts +4 -0
  169. package/dist/lib/setupRemoteResolveCommand.js +48 -0
  170. package/dist/lib/setupRemoteStatusCommand.d.ts +4 -0
  171. package/dist/lib/setupRemoteStatusCommand.js +73 -0
  172. package/dist/lib/setupRemoteSyncCommand.d.ts +6 -0
  173. package/dist/lib/setupRemoteSyncCommand.js +65 -0
  174. package/dist/lib/setupSyncProjectsCommand.d.ts +4 -0
  175. package/dist/lib/setupSyncProjectsCommand.js +292 -0
  176. package/dist/lib/staleCommand.d.ts +8 -0
  177. package/dist/lib/staleCommand.js +34 -0
  178. package/dist/lib/statsCommand.d.ts +6 -0
  179. package/dist/lib/statsCommand.js +142 -0
  180. package/dist/lib/statusCommand.d.ts +18 -0
  181. package/dist/lib/statusCommand.js +250 -0
  182. package/dist/lib/storesCommand.d.ts +2 -0
  183. package/dist/lib/storesCommand.js +4 -0
  184. package/dist/lib/syncClient.d.ts +47 -0
  185. package/dist/lib/syncClient.js +212 -0
  186. package/dist/lib/syncCommand.d.ts +6 -0
  187. package/dist/lib/syncCommand.js +57 -0
  188. package/dist/lib/syncDoctorCommand.d.ts +5 -0
  189. package/dist/lib/syncDoctorCommand.js +100 -0
  190. package/dist/lib/syncIngest.d.ts +19 -0
  191. package/dist/lib/syncIngest.js +152 -0
  192. package/dist/lib/syncIngestLaunchd.d.ts +8 -0
  193. package/dist/lib/syncIngestLaunchd.js +93 -0
  194. package/dist/lib/syncIngestStartup.d.ts +5 -0
  195. package/dist/lib/syncIngestStartup.js +29 -0
  196. package/dist/lib/syncIngestSystemd.d.ts +10 -0
  197. package/dist/lib/syncIngestSystemd.js +97 -0
  198. package/dist/lib/syncIngestTimer.d.ts +8 -0
  199. package/dist/lib/syncIngestTimer.js +27 -0
  200. package/dist/lib/syncIngestTimerCommand.d.ts +7 -0
  201. package/dist/lib/syncIngestTimerCommand.js +83 -0
  202. package/dist/lib/syncLock.d.ts +6 -0
  203. package/dist/lib/syncLock.js +74 -0
  204. package/dist/lib/syncSnapshot.d.ts +30 -0
  205. package/dist/lib/syncSnapshot.js +184 -0
  206. package/dist/lib/syncStaging.d.ts +81 -0
  207. package/dist/lib/syncStaging.js +239 -0
  208. package/dist/lib/tagsAddCommand.d.ts +8 -0
  209. package/dist/lib/tagsAddCommand.js +18 -0
  210. package/dist/lib/tagsCommand.d.ts +4 -0
  211. package/dist/lib/tagsCommand.js +16 -0
  212. package/dist/lib/timelineCommand.d.ts +7 -0
  213. package/dist/lib/timelineCommand.js +49 -0
  214. package/dist/lib/traceCommand.d.ts +6 -0
  215. package/dist/lib/traceCommand.js +39 -0
  216. package/dist/lib/traverseCommand.d.ts +6 -0
  217. package/dist/lib/traverseCommand.js +58 -0
  218. package/dist/lib/updateCommand.d.ts +13 -0
  219. package/dist/lib/updateCommand.js +67 -0
  220. package/dist/lib/updateStatusCommand.d.ts +5 -0
  221. package/dist/lib/updateStatusCommand.js +38 -0
  222. package/dist/lib/webAddCommand.d.ts +8 -0
  223. package/dist/lib/webAddCommand.js +55 -0
  224. package/dist/lib/webBuildCommand.d.ts +10 -0
  225. package/dist/lib/webBuildCommand.js +65 -0
  226. package/dist/lib/webBuildIndexCommand.d.ts +8 -0
  227. package/dist/lib/webBuildIndexCommand.js +37 -0
  228. package/dist/lib/webIngestCommand.d.ts +11 -0
  229. package/dist/lib/webIngestCommand.js +51 -0
  230. package/dist/lib/webInitCommand.d.ts +9 -0
  231. package/dist/lib/webInitCommand.js +167 -0
  232. package/dist/lib/webRemoveCommand.d.ts +5 -0
  233. package/dist/lib/webRemoveCommand.js +41 -0
  234. package/dist/lib/webStatusCommand.d.ts +5 -0
  235. package/dist/lib/webStatusCommand.js +94 -0
  236. package/dist/lib/webUpdateCommand.d.ts +7 -0
  237. package/dist/lib/webUpdateCommand.js +72 -0
  238. package/dist/lib/workingSetCommand.d.ts +6 -0
  239. package/dist/lib/workingSetCommand.js +37 -0
  240. package/package.json +2 -1
package/dist/lib/setup.js CHANGED
@@ -14,12 +14,16 @@ import fsSync from "fs";
14
14
  import path from "path";
15
15
  import os from "os";
16
16
  import { execSync } from "child_process";
17
- import { loadConfig, updateConfig, getProviderModel, } from "./config.js";
18
- import { validateModel } from "./modelValidation.js";
17
+ import { loadConfig, updateConfig, resolveTaskModel, getProviderModel, } from "./config.js";
18
+ import { isApiKeyValidationError, validateModel } from "./modelValidation.js";
19
+ import { buildOpenRouterTiers, OPENROUTER_STATIC_TIERS, } from "./openrouterTiers.js";
20
+ import { apiKeyServiceName, buildApiKeyRequirementsFromConfig, ensureApiKeys, getApiKeyForProviderFromConfig, providerNeedsApiKey, readFirstInChain, readStoredSecret, storeApiKeySecret, } from "./apiKeyVault.js";
19
21
  import { resolveActiveStorePath, ensureActiveStorePath } from "./setup/storePath.js";
20
22
  import { safeQuestion } from "./setup/ui/safePrompt.js";
23
+ export { printStatus } from "./setup/ui/status.js";
21
24
  import { getClaudeDesktopConfigPath, getApiKeySkipHints } from "./platform.js";
22
25
  import { gnosysStdioMcpEntry, installStdioMcpJson, normalizeIdeKey, resolveGnosysMcpCommand, cursorMcpPaths, runCli, removeTomlSection, } from "./ideMcpInstall.js";
26
+ import { runKeysSetup } from "./setupKeys.js";
23
27
  // ─── ANSI Colors ────────────────────────────────────────────────────────────
24
28
  const BOLD = "\x1b[1m";
25
29
  const DIM = "\x1b[2m";
@@ -76,6 +80,7 @@ export const PROVIDER_TIERS = {
76
80
  lmstudio: [
77
81
  { name: "Default", model: "default", input: 0, output: 0, recommended: true },
78
82
  ],
83
+ openrouter: OPENROUTER_STATIC_TIERS,
79
84
  custom: [],
80
85
  };
81
86
  // ─── Dynamic Model Fetching (OpenRouter) ─────────────────────────────────────
@@ -95,7 +100,7 @@ const OPENROUTER_PREFIX_MAP = {
95
100
  * Fetch models from OpenRouter, cache for 24 hours, fall back to hardcoded.
96
101
  * Returns updated PROVIDER_TIERS for cloud providers only.
97
102
  */
98
- async function fetchDynamicModels() {
103
+ export async function fetchDynamicModels() {
99
104
  // Check cache first
100
105
  try {
101
106
  const stat = await fs.stat(CACHE_FILE);
@@ -250,6 +255,7 @@ async function fetchDynamicModels() {
250
255
  }
251
256
  result[ourProvider] = tiers;
252
257
  }
258
+ result.openrouter = buildOpenRouterTiers(data.data);
253
259
  // Cache the result
254
260
  try {
255
261
  await fs.mkdir(path.dirname(CACHE_FILE), { recursive: true });
@@ -284,6 +290,7 @@ const PROVIDER_DISPLAY = {
284
290
  xai: "xAI (Grok)",
285
291
  mistral: "Mistral",
286
292
  lmstudio: "LM Studio (local, free)",
293
+ openrouter: "OpenRouter (free + paid models)",
287
294
  custom: "Custom (any OpenAI-compatible API)",
288
295
  };
289
296
  const PROVIDER_ENV_VAR = {
@@ -292,6 +299,7 @@ const PROVIDER_ENV_VAR = {
292
299
  groq: "GNOSYS_GROQ_KEY",
293
300
  xai: "GNOSYS_XAI_KEY",
294
301
  mistral: "GNOSYS_MISTRAL_KEY",
302
+ openrouter: "GNOSYS_OPENROUTER_KEY",
295
303
  custom: "GNOSYS_CUSTOM_KEY",
296
304
  };
297
305
  // Ordered list for the menu
@@ -303,16 +311,40 @@ const PROVIDER_ORDER = [
303
311
  "xai",
304
312
  "mistral",
305
313
  "lmstudio",
314
+ "openrouter",
306
315
  "custom",
307
316
  ];
308
317
  // Task descriptions for display
309
- const TASK_DESCRIPTIONS = {
318
+ export const TASK_DESCRIPTIONS = {
310
319
  structuring: "adding memories, tagging",
311
320
  synthesis: "Q&A answers",
321
+ chat: "interactive chat TUI",
312
322
  vision: "images, PDFs",
313
323
  transcription: "audio files",
314
324
  dream: "overnight consolidation",
315
325
  };
326
+ /** Tasks routed via taskModels in gnosys.json */
327
+ const ROUTABLE_TASK_LIST = [
328
+ "structuring",
329
+ "synthesis",
330
+ "vision",
331
+ "transcription",
332
+ "chat",
333
+ ];
334
+ export const ASSIGNABLE_TASK_LIST = [
335
+ ...ROUTABLE_TASK_LIST,
336
+ "dream",
337
+ ];
338
+ export function getAssignableRouting(cfg, task) {
339
+ if (task === "dream") {
340
+ const dreamProvider = (cfg.dream?.provider ?? "ollama");
341
+ return {
342
+ provider: dreamProvider,
343
+ model: cfg.dream?.model ?? getProviderModel(cfg, dreamProvider),
344
+ };
345
+ }
346
+ return resolveTaskModel(cfg, task);
347
+ }
316
348
  // ─── Exported Helpers ───────────────────────────────────────────────────────
317
349
  /**
318
350
  * Returns the cheapest capable model for structuring tasks.
@@ -334,8 +366,10 @@ export function getStructuringModel(provider, chosenModel) {
334
366
  * Creates the directory and file if they don't exist.
335
367
  * Replaces an existing key line if found, otherwise appends.
336
368
  */
337
- export async function writeApiKey(provider, key) {
338
- const envVar = PROVIDER_ENV_VAR[provider];
369
+ export async function writeApiKey(provider, key, opts) {
370
+ const envVar = opts?.scope === "global"
371
+ ? apiKeyServiceName(provider, "global")
372
+ : PROVIDER_ENV_VAR[provider];
339
373
  if (!envVar)
340
374
  return;
341
375
  const configDir = path.join(os.homedir(), ".config", "gnosys");
@@ -418,6 +452,68 @@ function hasSecretTool() {
418
452
  return false;
419
453
  }
420
454
  }
455
+ /**
456
+ * Prompt for a replacement API key after validation fails, storing it in the
457
+ * platform secure store when available (Keychain / GNOME Keyring), else .env.
458
+ */
459
+ async function promptAndStoreProviderApiKey(rl, provider, scope = "provider") {
460
+ if (provider === "ollama" || provider === "lmstudio" || provider === "skip") {
461
+ return null;
462
+ }
463
+ const service = apiKeyServiceName(provider, scope);
464
+ console.log();
465
+ console.log(` ${WARN} Your API key may be expired or invalid.`);
466
+ console.log(` ${DIM}Enter a new key — saved as ${service}${RESET}`);
467
+ console.log();
468
+ const key = await askInput(rl, `Enter your ${provider} API key`);
469
+ if (!key) {
470
+ return null;
471
+ }
472
+ if (storeApiKeySecret(service, key, provider)) {
473
+ const store = process.platform === "darwin" ? "macOS Keychain" : "GNOME Keyring";
474
+ console.log(` ${CHECK} Key saved to ${store} (${maskKey(key)})`);
475
+ return key;
476
+ }
477
+ console.log(` ${CROSS} Failed to write to secure store. Saving to ~/.config/gnosys/.env instead.`);
478
+ await writeApiKey(provider, key);
479
+ console.log(` ${CHECK} Key saved to ~/.config/gnosys/.env (${maskKey(key)})`);
480
+ return key;
481
+ }
482
+ /**
483
+ * Run model validation; on API-key auth failures, offer to rotate the key and
484
+ * retry once before asking whether to save config anyway.
485
+ */
486
+ async function validateModelWithKeyRotation(opts) {
487
+ const { Spinner } = await import("./setup/ui/spinner.js");
488
+ let apiKey = opts.apiKey;
489
+ const validateSpin = Spinner(`validating ${opts.provider} / ${opts.model}…`);
490
+ let result = await validateModel(opts.provider, opts.model, apiKey, {
491
+ customBaseUrl: opts.customBaseUrl,
492
+ });
493
+ if (result.ok) {
494
+ validateSpin.ok("model validated", `${result.latencyMs} ms · ${opts.provider} / ${opts.model}`);
495
+ return { proceed: true, apiKey };
496
+ }
497
+ validateSpin.fail("model test failed", result.error);
498
+ if (!opts.isLocalProvider &&
499
+ isApiKeyValidationError(result.error)) {
500
+ const newKey = await promptAndStoreProviderApiKey(opts.rl, opts.provider, opts.keyScope ?? "global");
501
+ if (newKey) {
502
+ apiKey = newKey;
503
+ const retrySpin = Spinner(`re-validating ${opts.provider} / ${opts.model}…`);
504
+ result = await validateModel(opts.provider, opts.model, apiKey, {
505
+ customBaseUrl: opts.customBaseUrl,
506
+ });
507
+ if (result.ok) {
508
+ retrySpin.ok("model validated", `${result.latencyMs} ms · ${opts.provider} / ${opts.model}`);
509
+ return { proceed: true, apiKey };
510
+ }
511
+ retrySpin.fail("model test failed", result.error);
512
+ }
513
+ }
514
+ const proceed = await askYesNo(opts.rl, opts.saveAnywayPrompt, opts.saveAnywayDefault);
515
+ return { proceed, apiKey };
516
+ }
421
517
  /**
422
518
  * Detect where an existing API key is stored.
423
519
  * Returns description like "macOS Keychain", "env var", ".env file", or empty string.
@@ -817,16 +913,20 @@ async function askChoice(rl, question, options) {
817
913
  /**
818
914
  * Read a single line of input with an optional default value.
819
915
  */
820
- async function askInput(rl, prompt, opts) {
916
+ export async function askInput(rl, prompt, opts) {
821
917
  const suffix = opts?.default ? ` ${DIM}(${opts.default})${RESET}` : "";
822
918
  const answer = await safeQuestion(rl, `${prompt}${suffix}: `);
823
919
  const trimmed = answer.trim();
824
920
  return trimmed || opts?.default || "";
825
921
  }
922
+ export function printInfo(text, meta) {
923
+ const suffix = meta ? ` ${DIM}${meta}${RESET}` : "";
924
+ console.log(` ${text}${suffix}`);
925
+ }
826
926
  /**
827
927
  * Y/n prompt. Returns true for yes.
828
928
  */
829
- async function askYesNo(rl, question, defaultYes = true) {
929
+ export async function askYesNo(rl, question, defaultYes = true) {
830
930
  const hint = defaultYes ? "Y/n" : "y/N";
831
931
  const answer = await safeQuestion(rl, `${question} [${hint}] `);
832
932
  const trimmed = answer.trim().toLowerCase();
@@ -834,6 +934,78 @@ async function askYesNo(rl, question, defaultYes = true) {
834
934
  return defaultYes;
835
935
  return trimmed === "y" || trimmed === "yes";
836
936
  }
937
+ /**
938
+ * Read a password/API key without echoing it back to the terminal.
939
+ */
940
+ export async function askPassword(rl, prompt) {
941
+ if (!stdin.isTTY || typeof stdin.setRawMode !== "function") {
942
+ return askInput(rl, prompt);
943
+ }
944
+ return new Promise((resolve) => {
945
+ let value = "";
946
+ stdout.write(`${prompt}: `);
947
+ stdin.setRawMode(true);
948
+ stdin.resume();
949
+ const cleanup = () => {
950
+ stdin.setRawMode(false);
951
+ stdin.off("data", onData);
952
+ stdout.write("\n");
953
+ };
954
+ const onData = (chunk) => {
955
+ const input = chunk.toString("utf-8");
956
+ for (const char of input) {
957
+ const code = char.charCodeAt(0);
958
+ if (code === 3) {
959
+ cleanup();
960
+ try {
961
+ rl.close();
962
+ }
963
+ catch {
964
+ // already closed
965
+ }
966
+ process.exit(130);
967
+ }
968
+ if (code === 13 || code === 10) {
969
+ cleanup();
970
+ resolve(value.trim());
971
+ return;
972
+ }
973
+ if (code === 127 || code === 8) {
974
+ value = value.slice(0, -1);
975
+ continue;
976
+ }
977
+ value += char;
978
+ }
979
+ };
980
+ stdin.on("data", onData);
981
+ });
982
+ }
983
+ /** Parse `1,3,5`, `all`, or `none` into 0-based task indices. */
984
+ export function parseCommaSeparatedTaskSelection(input, count) {
985
+ const trimmed = input.trim().toLowerCase();
986
+ if (!trimmed)
987
+ return null;
988
+ if (trimmed === "all" || trimmed === "*") {
989
+ return "all";
990
+ }
991
+ if (trimmed === "none" || trimmed === "-") {
992
+ return "none";
993
+ }
994
+ const indices = [];
995
+ for (const part of trimmed.split(/[,;\s]+/)) {
996
+ const n = parseInt(part.trim(), 10);
997
+ if (Number.isNaN(n) || n < 1 || n > count) {
998
+ return null;
999
+ }
1000
+ if (!indices.includes(n - 1)) {
1001
+ indices.push(n - 1);
1002
+ }
1003
+ }
1004
+ return indices.length > 0 ? indices : null;
1005
+ }
1006
+ export function modelForTaskAssignment(task, provider, model) {
1007
+ return task === "structuring" ? getStructuringModel(provider, model) : model;
1008
+ }
837
1009
  /**
838
1010
  * Format a price for display: "$0.80" or "free".
839
1011
  */
@@ -926,7 +1098,7 @@ async function loadExistingConfig(projectDir) {
926
1098
  * Returns the provider name or "skip".
927
1099
  * If currentProvider is given, shows it as the current value.
928
1100
  */
929
- async function pickProvider(rl, dynamicModels, stepLabel, currentProvider) {
1101
+ export async function pickProvider(rl, dynamicModels, stepLabel, currentProvider) {
930
1102
  const currentHint = currentProvider ? ` ${DIM}(current: ${currentProvider})${RESET}` : "";
931
1103
  const providerOptions = PROVIDER_ORDER.map((key) => {
932
1104
  const tiers = dynamicModels[key] ?? PROVIDER_TIERS[key];
@@ -947,7 +1119,7 @@ async function pickProvider(rl, dynamicModels, stepLabel, currentProvider) {
947
1119
  * Returns the model string. Includes a "Custom (enter model name)"
948
1120
  * option so users can type any model ID not in the curated list.
949
1121
  */
950
- async function pickModel(rl, provider, dynamicModels, stepLabel, currentModel) {
1122
+ export async function pickModel(rl, provider, dynamicModels, stepLabel, currentModel) {
951
1123
  const tiers = dynamicModels[provider] ?? PROVIDER_TIERS[provider];
952
1124
  if (!tiers || tiers.length === 0) {
953
1125
  // No tiers available — fall back to direct entry
@@ -971,8 +1143,10 @@ async function pickModel(rl, provider, dynamicModels, stepLabel, currentModel) {
971
1143
  }
972
1144
  return tiers[tierIndex].model;
973
1145
  }
974
- // ─── Main Setup Wizard ──────────────────────────────────────────────────────
975
1146
  export async function runSetup(opts) {
1147
+ if (opts.section === "keys") {
1148
+ return runKeysSetup();
1149
+ }
976
1150
  const version = getVersion();
977
1151
  const projectDir = opts.directory ? path.resolve(opts.directory) : process.cwd();
978
1152
  // ─── Non-interactive mode ─────────────────────────────────────────────
@@ -1199,6 +1373,7 @@ export async function runSetup(opts) {
1199
1373
  groq: "GROQ_API_KEY",
1200
1374
  xai: "XAI_API_KEY",
1201
1375
  mistral: "MISTRAL_API_KEY",
1376
+ openrouter: "OPENROUTER_API_KEY",
1202
1377
  };
1203
1378
  const legacyEnvVar = legacyEnvVars[provider] ?? "";
1204
1379
  if (needsKey) {
@@ -1373,26 +1548,27 @@ export async function runSetup(opts) {
1373
1548
  console.log();
1374
1549
  console.log(`${DIM}Testing ${provider}/${model}...${RESET}`);
1375
1550
  try {
1376
- const { validateModel } = await import("./modelValidation.js");
1377
1551
  const customBaseUrl = provider === "custom"
1378
1552
  ? process.env.GNOSYS_LLM_BASE_URL
1379
1553
  : undefined;
1380
- const result = await validateModel(provider, model, capturedApiKey, { customBaseUrl });
1381
- if (result.ok) {
1382
- console.log(` ${CHECK} Model validated (${result.latencyMs}ms)`);
1383
- }
1384
- else {
1385
- console.log(` ${WARN} Model test failed: ${result.error}`);
1386
- const proceed = await askYesNo(rl, " Continue anyway?", true);
1387
- if (!proceed) {
1388
- console.log(` ${DIM}Setup paused. Re-run when ready: gnosys setup${RESET}`);
1389
- setupCompleted = true;
1390
- rl.close();
1391
- return {
1392
- provider, model, structuringModel: "",
1393
- apiKeyWritten, ides: [], mode: "agent", upgraded,
1394
- };
1395
- }
1554
+ const { proceed } = await validateModelWithKeyRotation({
1555
+ rl,
1556
+ provider,
1557
+ model,
1558
+ apiKey: capturedApiKey,
1559
+ customBaseUrl,
1560
+ isLocalProvider,
1561
+ saveAnywayPrompt: " Continue anyway?",
1562
+ saveAnywayDefault: true,
1563
+ });
1564
+ if (!proceed) {
1565
+ console.log(` ${DIM}Setup paused. Re-run when ready: gnosys setup${RESET}`);
1566
+ setupCompleted = true;
1567
+ rl.close();
1568
+ return {
1569
+ provider, model, structuringModel: "",
1570
+ apiKeyWritten, ides: [], mode: "agent", upgraded,
1571
+ };
1396
1572
  }
1397
1573
  }
1398
1574
  catch (err) {
@@ -1671,6 +1847,18 @@ export async function runSetup(opts) {
1671
1847
  await updateConfig(storePath, configUpdates);
1672
1848
  console.log();
1673
1849
  console.log(` ${CHECK} Config written to ${storePath}/gnosys.json`);
1850
+ const savedCfg = await loadConfig(storePath);
1851
+ const keyReqs = buildApiKeyRequirementsFromConfig(savedCfg);
1852
+ if (keyReqs.length > 0) {
1853
+ await ensureApiKeys(rl, keyReqs, askInput, {
1854
+ warn: WARN,
1855
+ check: CHECK,
1856
+ cross: CROSS,
1857
+ dim: DIM,
1858
+ reset: RESET,
1859
+ maskKey,
1860
+ });
1861
+ }
1674
1862
  }
1675
1863
  catch (err) {
1676
1864
  const msg = err instanceof Error ? err.message : String(err);
@@ -1840,6 +2028,166 @@ export async function runProviderOnlySetup(opts = {}) {
1840
2028
  rl.close();
1841
2029
  }
1842
2030
  }
2031
+ /**
2032
+ * Build API key requirements from the selected task set only (§3.7).
2033
+ * One global key per distinct cloud provider in the selection.
2034
+ */
2035
+ export function buildInlineKeyRequirements(selectedTasks, providerForTask) {
2036
+ const providers = [
2037
+ ...new Set(selectedTasks
2038
+ .map((t) => providerForTask(t))
2039
+ .filter((p) => providerNeedsApiKey(p))
2040
+ .map((p) => p)),
2041
+ ];
2042
+ return providers.map((provider) => ({ provider, scope: "global" }));
2043
+ }
2044
+ /** Write or replace a single `NAME=value` line in ~/.config/gnosys/.env. */
2045
+ export async function writeServiceKeyToEnv(service, key) {
2046
+ const configDir = path.join(os.homedir(), ".config", "gnosys");
2047
+ await fs.mkdir(configDir, { recursive: true, mode: 0o700 });
2048
+ await fs.chmod(configDir, 0o700);
2049
+ const envPath = path.join(configDir, ".env");
2050
+ let lines = [];
2051
+ try {
2052
+ lines = (await fs.readFile(envPath, "utf-8")).split("\n");
2053
+ }
2054
+ catch {
2055
+ // fresh file
2056
+ }
2057
+ const prefix = `${service}=`;
2058
+ let replaced = false;
2059
+ lines = lines.map((line) => {
2060
+ if (line.startsWith(prefix)) {
2061
+ replaced = true;
2062
+ return `${service}=${key}`;
2063
+ }
2064
+ return line;
2065
+ });
2066
+ if (!replaced) {
2067
+ if (lines.length > 0 && lines[lines.length - 1] !== "") {
2068
+ lines.push("");
2069
+ }
2070
+ lines.push(`${service}=${key}`);
2071
+ }
2072
+ await fs.writeFile(envPath, lines.join("\n"), { mode: 0o600 });
2073
+ await fs.chmod(envPath, 0o600);
2074
+ }
2075
+ /**
2076
+ * Validate a provider/model/key triple without persisting the key (§3.8).
2077
+ * On auth failure, re-prompt for a replacement key in memory only.
2078
+ */
2079
+ export async function validateTaskCombo(opts) {
2080
+ const { Spinner } = await import("./setup/ui/spinner.js");
2081
+ const reprompt = opts.repromptKey ??
2082
+ (async (rl, provider) => {
2083
+ console.log();
2084
+ console.log(` ${WARN} Your API key may be expired or invalid.`);
2085
+ const key = await askInput(rl, `Enter your ${provider} API key`);
2086
+ return key || null;
2087
+ });
2088
+ let apiKey = opts.apiKey;
2089
+ const validateSpin = Spinner(`validating ${opts.provider} / ${opts.model}…`);
2090
+ let result = await validateModel(opts.provider, opts.model, apiKey, {
2091
+ customBaseUrl: opts.customBaseUrl,
2092
+ });
2093
+ if (result.ok) {
2094
+ validateSpin.ok("model validated", `${result.latencyMs} ms · ${opts.provider} / ${opts.model}`);
2095
+ return { proceed: true, apiKey };
2096
+ }
2097
+ validateSpin.fail("model test failed", result.error);
2098
+ if (!opts.isLocalProvider && isApiKeyValidationError(result.error)) {
2099
+ const newKey = await reprompt(opts.rl, opts.provider);
2100
+ if (newKey) {
2101
+ apiKey = newKey;
2102
+ const retrySpin = Spinner(`re-validating ${opts.provider} / ${opts.model}…`);
2103
+ result = await validateModel(opts.provider, opts.model, apiKey, {
2104
+ customBaseUrl: opts.customBaseUrl,
2105
+ });
2106
+ if (result.ok) {
2107
+ retrySpin.ok("model validated", `${result.latencyMs} ms · ${opts.provider} / ${opts.model}`);
2108
+ return { proceed: true, apiKey };
2109
+ }
2110
+ retrySpin.fail("model test failed", result.error);
2111
+ }
2112
+ }
2113
+ const proceed = await askYesNo(opts.rl, opts.saveAnywayPrompt ?? "Save routing anyway?", opts.saveAnywayDefault ?? false);
2114
+ return { proceed, apiKey };
2115
+ }
2116
+ const LEGACY_PROVIDER_ENV = {
2117
+ anthropic: "ANTHROPIC_API_KEY",
2118
+ openai: "OPENAI_API_KEY",
2119
+ groq: "GROQ_API_KEY",
2120
+ xai: "XAI_API_KEY",
2121
+ mistral: "MISTRAL_API_KEY",
2122
+ openrouter: "OPENROUTER_API_KEY",
2123
+ };
2124
+ /** Ask where to store a validated key; persist or print env-var hints (§3.10). */
2125
+ export async function promptKeyDestinationAndPersist(opts) {
2126
+ const isMac = process.platform === "darwin";
2127
+ const isLinux = process.platform === "linux";
2128
+ const hasSecret = isLinux && hasSecretTool();
2129
+ const options = [];
2130
+ if (isMac) {
2131
+ options.push("OS secure store (macOS Keychain) — recommended");
2132
+ }
2133
+ else if (hasSecret) {
2134
+ options.push("OS secure store (GNOME Keyring) — recommended");
2135
+ }
2136
+ else {
2137
+ options.push("OS secure store — recommended");
2138
+ }
2139
+ options.push("Config file (~/.config/gnosys/.env)");
2140
+ options.push("Don't store — I'll set it as an environment variable myself");
2141
+ const choice = opts.destinationChoice ??
2142
+ (await askChoice(opts.rl, "Where should I store this key?", options));
2143
+ const secureIdx = 0;
2144
+ const dotenvIdx = 1;
2145
+ const noneIdx = 2;
2146
+ if (choice === secureIdx) {
2147
+ if (storeApiKeySecret(opts.service, opts.key, opts.provider)) {
2148
+ const store = process.platform === "darwin" ? "macOS Keychain" : "GNOME Keyring";
2149
+ console.log(` ${CHECK} Key saved to ${store} (${maskKey(opts.key)})`);
2150
+ return "secure";
2151
+ }
2152
+ console.log(` ${CROSS} Failed to write to secure store. Saving to ~/.config/gnosys/.env instead.`);
2153
+ await writeServiceKeyToEnv(opts.service, opts.key);
2154
+ console.log(` ${CHECK} Key saved to ~/.config/gnosys/.env (${maskKey(opts.key)})`);
2155
+ return "dotenv";
2156
+ }
2157
+ if (choice === dotenvIdx) {
2158
+ await writeServiceKeyToEnv(opts.service, opts.key);
2159
+ console.log(` ${CHECK} Key saved to ~/.config/gnosys/.env (${maskKey(opts.key)})`);
2160
+ return "dotenv";
2161
+ }
2162
+ const legacy = LEGACY_PROVIDER_ENV[opts.provider];
2163
+ const providerSvc = apiKeyServiceName(opts.provider, "provider");
2164
+ console.log();
2165
+ console.log(` ${DIM}Set one of these environment variables:${RESET}`);
2166
+ console.log(` ${GREEN}export ${opts.service}=your-key-here${RESET}`);
2167
+ if (opts.service !== providerSvc) {
2168
+ console.log(` ${DIM}or provider fallback:${RESET} ${GREEN}export ${providerSvc}=…${RESET}`);
2169
+ }
2170
+ if (legacy) {
2171
+ console.log(` ${DIM}or legacy:${RESET} ${GREEN}export ${legacy}=…${RESET}`);
2172
+ }
2173
+ return "none";
2174
+ }
2175
+ /** Build taskModels patch — only selected routable tasks that changed. */
2176
+ export function buildTaskModelsPatchFromAccepted(accepted, currentByTask, selectedSet) {
2177
+ const patch = {};
2178
+ for (const task of ROUTABLE_TASK_LIST) {
2179
+ if (!selectedSet.has(task))
2180
+ continue;
2181
+ const next = accepted[task];
2182
+ if (!next)
2183
+ continue;
2184
+ const cur = currentByTask[task];
2185
+ if (next.provider !== cur.provider || next.model !== cur.model) {
2186
+ patch[task] = next;
2187
+ }
2188
+ }
2189
+ return Object.keys(patch).length > 0 ? patch : undefined;
2190
+ }
1843
2191
  /**
1844
2192
  * Models-only configuration — prompts for provider, model, and key (or accepts
1845
2193
  * them via options for non-interactive use). Validates the model against the
@@ -1850,7 +2198,6 @@ export async function runModelsSetup(opts = {}) {
1850
2198
  const ownsRl = !opts.rl;
1851
2199
  const rl = opts.rl ?? createInterface({ input: stdin, output: stdout });
1852
2200
  try {
1853
- // v5.9.3 Screen 3 — Header + Title at the top.
1854
2201
  const { Header } = await import("./setup/ui/header.js");
1855
2202
  const { Title } = await import("./setup/ui/title.js");
1856
2203
  const { Spinner } = await import("./setup/ui/spinner.js");
@@ -1859,16 +2206,13 @@ export async function runModelsSetup(opts = {}) {
1859
2206
  console.log();
1860
2207
  console.log(Header(["gnosys", "setup", "models"]));
1861
2208
  console.log();
1862
- console.log(Title("Model configuration", "pick a provider and model we'll validate it before saving"));
2209
+ console.log(Title("Model configuration", "pick a provider — then default or per-task routing"));
1863
2210
  console.log();
1864
2211
  const existingConfig = await loadExistingConfig(projectDir);
1865
2212
  const currentProvider = existingConfig?.llm.defaultProvider;
1866
2213
  const currentModel = existingConfig
1867
2214
  ? getProviderModel(existingConfig, existingConfig.llm.defaultProvider)
1868
2215
  : undefined;
1869
- // Step 1: provider (or use --provider flag). v5.9.3: animate the
1870
- // OpenRouter pricing fetch under a Spinner so the user gets feedback
1871
- // on what would otherwise feel like a hang.
1872
2216
  const pricingSpin = Spinner("fetching latest pricing from openrouter…");
1873
2217
  const fetchStart = Date.now();
1874
2218
  const dynamicModels = await fetchDynamicModels();
@@ -1878,8 +2222,6 @@ export async function runModelsSetup(opts = {}) {
1878
2222
  pricingSpin.ok(`pricing loaded · ${modelCount} models cached`, `${fetchMs} ms`);
1879
2223
  }
1880
2224
  else {
1881
- // No-op fallback (cache miss + network fail) — keep the hardcoded
1882
- // tiers but signal that we're running offline.
1883
2225
  pricingSpin.fail("pricing fetch failed", "using bundled tiers");
1884
2226
  }
1885
2227
  console.log();
@@ -1895,103 +2237,339 @@ export async function runModelsSetup(opts = {}) {
1895
2237
  else {
1896
2238
  provider = await pickProvider(rl, dynamicModels, "Choose your LLM provider", currentProvider);
1897
2239
  }
1898
- // Step 2: model (or use --model flag)
1899
- let model;
1900
- if (opts.model) {
1901
- model = opts.model;
1902
- printStatus("ok", "model", model);
2240
+ const storePath = ensureActiveStorePath(projectDir);
2241
+ const cfgBefore = existingConfig ?? (await loadConfig(storePath));
2242
+ const nonInteractiveDefault = !!(opts.provider || opts.model);
2243
+ if (nonInteractiveDefault) {
2244
+ await runModelsDefaultOnlySetup({
2245
+ rl,
2246
+ opts,
2247
+ provider,
2248
+ dynamicModels,
2249
+ storePath,
2250
+ existingConfig,
2251
+ currentProvider,
2252
+ currentModel,
2253
+ printDiff,
2254
+ printStatus,
2255
+ });
2256
+ return;
2257
+ }
2258
+ console.log();
2259
+ const intentChoice = await askChoice(rl, "What do you want to configure?", [
2260
+ "Default only — set default provider and model",
2261
+ "Assign to specific tasks — route selected tasks only (does not change default)",
2262
+ ]);
2263
+ if (intentChoice === 0) {
2264
+ await runModelsDefaultOnlySetup({
2265
+ rl,
2266
+ opts,
2267
+ provider,
2268
+ dynamicModels,
2269
+ storePath,
2270
+ existingConfig,
2271
+ currentProvider,
2272
+ currentModel,
2273
+ printDiff,
2274
+ printStatus,
2275
+ });
2276
+ return;
2277
+ }
2278
+ await runModelsTaskRoutingSetup({
2279
+ rl,
2280
+ opts,
2281
+ provider,
2282
+ dynamicModels,
2283
+ storePath,
2284
+ cfgBefore,
2285
+ printStatus,
2286
+ });
2287
+ }
2288
+ finally {
2289
+ if (ownsRl)
2290
+ rl.close();
2291
+ }
2292
+ }
2293
+ async function resolveKeyInMemory(opts) {
2294
+ if (!providerNeedsApiKey(opts.provider))
2295
+ return "";
2296
+ const cached = opts.keyCache.get(opts.provider);
2297
+ if (cached)
2298
+ return cached;
2299
+ const stored = readStoredSecret(apiKeyServiceName(opts.provider, "global"));
2300
+ if (stored) {
2301
+ opts.keyCache.set(opts.provider, stored);
2302
+ return stored;
2303
+ }
2304
+ const key = await askInput(opts.rl, `API key for ${opts.provider} (shared across selected tasks)`);
2305
+ opts.keyCache.set(opts.provider, key);
2306
+ return key;
2307
+ }
2308
+ async function runModelsDefaultOnlySetup(ctx) {
2309
+ const { rl, opts, provider, dynamicModels, storePath, printDiff, printStatus } = ctx;
2310
+ const isLocalProvider = provider === "ollama" || provider === "lmstudio";
2311
+ let model;
2312
+ if (opts.model) {
2313
+ model = opts.model;
2314
+ printStatus("ok", "model", model);
2315
+ }
2316
+ else {
2317
+ const tiers = dynamicModels[provider] ?? PROVIDER_TIERS[provider];
2318
+ if (provider === "custom" || !tiers || tiers.length === 0) {
2319
+ model = await askInput(rl, "Model name");
1903
2320
  }
1904
2321
  else {
1905
- const tiers = dynamicModels[provider] ?? PROVIDER_TIERS[provider];
1906
- if (provider === "custom" || !tiers || tiers.length === 0) {
1907
- model = await askInput(rl, "Model name");
1908
- }
1909
- else {
1910
- const showCurrent = currentProvider === provider ? currentModel : undefined;
1911
- model = await pickModel(rl, provider, dynamicModels, "Choose model", showCurrent);
1912
- }
2322
+ const showCurrent = ctx.currentProvider === provider ? ctx.currentModel : undefined;
2323
+ model = await pickModel(rl, provider, dynamicModels, "Choose model", showCurrent);
1913
2324
  }
1914
- if (!model) {
1915
- printStatus("fail", "no model selected · aborting");
2325
+ }
2326
+ if (!model) {
2327
+ printStatus("fail", "no model selected · aborting");
2328
+ return;
2329
+ }
2330
+ let apiKey = readFirstInChain(provider) ?? "";
2331
+ let keyEnteredThisRun = false;
2332
+ if (!apiKey && !isLocalProvider) {
2333
+ console.log();
2334
+ apiKey = await askInput(rl, `API key for ${provider}`);
2335
+ keyEnteredThisRun = !!apiKey;
2336
+ if (!apiKey) {
2337
+ console.log(`${WARN} No API key found for ${provider}. Validation may be skipped.`);
2338
+ }
2339
+ }
2340
+ const shouldValidate = opts.validate !== false;
2341
+ if (shouldValidate && (apiKey || isLocalProvider)) {
2342
+ console.log();
2343
+ const customBaseUrl = provider === "custom" ? process.env.GNOSYS_LLM_BASE_URL : undefined;
2344
+ const { proceed, apiKey: validatedKey } = await validateModelWithKeyRotation({
2345
+ rl,
2346
+ provider,
2347
+ model,
2348
+ apiKey,
2349
+ customBaseUrl,
2350
+ isLocalProvider,
2351
+ saveAnywayPrompt: "Save config anyway?",
2352
+ saveAnywayDefault: false,
2353
+ keyScope: "provider",
2354
+ });
2355
+ apiKey = validatedKey;
2356
+ if (!proceed) {
2357
+ printStatus("warn", "cancelled · no changes written");
1916
2358
  return;
1917
2359
  }
1918
- // Step 3: load API key from existing storage (if available)
1919
- const envVarName = provider === "custom" ? "GNOSYS_CUSTOM_KEY" :
1920
- `GNOSYS_${provider.toUpperCase()}_KEY`;
1921
- const legacyEnvVars = {
1922
- anthropic: "ANTHROPIC_API_KEY",
1923
- openai: "OPENAI_API_KEY",
1924
- groq: "GROQ_API_KEY",
1925
- xai: "XAI_API_KEY",
1926
- mistral: "MISTRAL_API_KEY",
1927
- };
1928
- const legacyEnvVar = legacyEnvVars[provider] ?? "";
1929
- let apiKey = process.env[envVarName] || (legacyEnvVar ? process.env[legacyEnvVar] : "") || "";
1930
- if (!apiKey && process.platform === "darwin") {
1931
- try {
1932
- apiKey = execSync(`security find-generic-password -a "$USER" -s "${envVarName}" -w`, { stdio: "pipe", encoding: "utf-8" }).trim();
1933
- }
1934
- catch {
1935
- // No key in keychain
1936
- }
2360
+ if (keyEnteredThisRun && apiKey && !isLocalProvider) {
2361
+ const service = apiKeyServiceName(provider, "provider");
2362
+ await promptKeyDestinationAndPersist({
2363
+ rl,
2364
+ service,
2365
+ provider,
2366
+ key: apiKey,
2367
+ scope: "provider",
2368
+ });
2369
+ }
2370
+ }
2371
+ const existingLlm = ctx.existingConfig?.llm;
2372
+ const existingProviderConfig = existingLlm
2373
+ ? existingLlm[provider]
2374
+ : undefined;
2375
+ const providerConfigBase = typeof existingProviderConfig === "object" && existingProviderConfig !== null
2376
+ ? existingProviderConfig
2377
+ : {};
2378
+ await updateConfig(storePath, {
2379
+ llm: {
2380
+ ...(existingLlm ?? {}),
2381
+ defaultProvider: provider,
2382
+ [provider]: {
2383
+ ...providerConfigBase,
2384
+ model,
2385
+ },
2386
+ },
2387
+ });
2388
+ const { buildModelsDiffRows } = await import("./setup/modelsRender.js");
2389
+ console.log();
2390
+ printDiff(buildModelsDiffRows(ctx.currentProvider, ctx.currentModel, provider, model));
2391
+ printStatus("ok", `saved · ${storePath}/gnosys.json`);
2392
+ }
2393
+ async function runModelsTaskRoutingSetup(ctx) {
2394
+ const { rl, opts, provider: initialProvider, dynamicModels, storePath, cfgBefore, printStatus } = ctx;
2395
+ const tasks = ASSIGNABLE_TASK_LIST;
2396
+ const currentByTask = {};
2397
+ for (const task of tasks) {
2398
+ currentByTask[task] = getAssignableRouting(cfgBefore, task);
2399
+ }
2400
+ const dreamEnabledBefore = !!cfgBefore.dream?.enabled;
2401
+ console.log();
2402
+ console.log(`${BOLD}Which tasks should use ${initialProvider}?${RESET}`);
2403
+ console.log(`${DIM}Enter numbers (comma-separated), ${BOLD}all${RESET}${DIM}, or ${BOLD}none${RESET}${DIM}. Unlisted tasks keep their current routing.${RESET}`);
2404
+ console.log();
2405
+ for (let i = 0; i < tasks.length; i++) {
2406
+ const task = tasks[i];
2407
+ const cur = currentByTask[task];
2408
+ const desc = TASK_DESCRIPTIONS[task] ?? "";
2409
+ const off = task === "dream" && !dreamEnabledBefore ? ` ${DIM}[off]${RESET}` : "";
2410
+ console.log(` ${BOLD}${i + 1}.${RESET} ${task.padEnd(14)} ${DIM}now:${RESET} ${cur.provider} / ${cur.model} ${DIM}(${desc})${RESET}${off}`);
2411
+ }
2412
+ console.log();
2413
+ let selectedIndices = [];
2414
+ while (true) {
2415
+ const raw = await askInput(rl, `Tasks for ${initialProvider} (e.g. 5,6 or all)`, { default: "all" });
2416
+ const parsed = parseCommaSeparatedTaskSelection(raw, tasks.length);
2417
+ if (parsed === "all") {
2418
+ selectedIndices = tasks.map((_, i) => i);
2419
+ break;
2420
+ }
2421
+ if (parsed === "none") {
2422
+ selectedIndices = [];
2423
+ break;
1937
2424
  }
1938
- if (!apiKey && provider !== "ollama" && provider !== "lmstudio") {
1939
- console.log(`${WARN} No API key found for ${provider}. Run 'gnosys setup' to configure one.`);
1940
- // Continue anyway — user might just want to update the model in config
2425
+ if (parsed && parsed.length > 0) {
2426
+ selectedIndices = parsed;
2427
+ break;
1941
2428
  }
1942
- // Step 4: validate (default: true) — v5.9.3 Screen 3: animated
1943
- // Spinner with latency reported on success.
1944
- const shouldValidate = opts.validate !== false;
1945
- const isLocalProvider = provider === "ollama" || provider === "lmstudio";
1946
- if (shouldValidate && (apiKey || isLocalProvider)) {
2429
+ console.log(`${RED}Enter numbers 1-${tasks.length}, comma-separated, or 'all'.${RESET}`);
2430
+ }
2431
+ const selectedSet = new Set(selectedIndices.map((i) => tasks[i]));
2432
+ if (selectedSet.size === 0) {
2433
+ printStatus("warn", "no tasks selected · no changes written");
2434
+ return;
2435
+ }
2436
+ const selectedTasks = [...selectedSet];
2437
+ const keyRequirements = buildInlineKeyRequirements(selectedTasks, () => initialProvider);
2438
+ if (keyRequirements.length > 0) {
2439
+ console.log();
2440
+ console.log(` ${DIM}Keys needed for:${RESET}`);
2441
+ for (const req of keyRequirements) {
2442
+ const svc = apiKeyServiceName(req.provider, req.scope);
2443
+ console.log(` ${DIM} all selected → ${req.provider} (${svc})${RESET}`);
2444
+ }
2445
+ console.log();
2446
+ }
2447
+ const keyCache = new Map();
2448
+ const persistedServices = new Set();
2449
+ const accepted = {};
2450
+ for (const task of selectedTasks) {
2451
+ let taskProvider = initialProvider;
2452
+ const cur = currentByTask[task];
2453
+ while (true) {
2454
+ const isLocal = taskProvider === "ollama" || taskProvider === "lmstudio";
2455
+ const showCurrent = cur.provider === taskProvider ? cur.model : undefined;
1947
2456
  console.log();
1948
- const validateSpin = Spinner(`validating ${provider} / ${model}…`);
1949
- const customBaseUrl = provider === "custom"
1950
- ? process.env.GNOSYS_LLM_BASE_URL
1951
- : undefined;
1952
- const result = await validateModel(provider, model, apiKey, { customBaseUrl });
1953
- if (result.ok) {
1954
- validateSpin.ok("model validated", `${result.latencyMs} ms · ${provider} / ${model}`);
2457
+ const model = await pickModel(rl, taskProvider, dynamicModels, `Model for ${task}`, showCurrent);
2458
+ if (!model) {
2459
+ printStatus("fail", `no model for ${task} · skipping`);
2460
+ accepted[task] = cur;
2461
+ break;
1955
2462
  }
1956
- else {
1957
- validateSpin.fail("model test failed", result.error);
1958
- const proceed = await askYesNo(rl, "Save config anyway?", false);
1959
- if (!proceed) {
1960
- printStatus("warn", "cancelled · no changes written");
1961
- return;
2463
+ const apiKey = await resolveKeyInMemory({
2464
+ rl,
2465
+ provider: taskProvider,
2466
+ keyCache,
2467
+ });
2468
+ const shouldValidate = opts.validate !== false;
2469
+ let proceed = true;
2470
+ let validatedKey = apiKey;
2471
+ if (shouldValidate && (apiKey || isLocal)) {
2472
+ const customBaseUrl = taskProvider === "custom" ? process.env.GNOSYS_LLM_BASE_URL : undefined;
2473
+ const result = await validateTaskCombo({
2474
+ rl,
2475
+ provider: taskProvider,
2476
+ model,
2477
+ apiKey,
2478
+ customBaseUrl,
2479
+ isLocalProvider: isLocal,
2480
+ saveAnywayPrompt: `Save ${task} routing anyway?`,
2481
+ saveAnywayDefault: false,
2482
+ });
2483
+ proceed = result.proceed;
2484
+ validatedKey = result.apiKey;
2485
+ }
2486
+ if (proceed) {
2487
+ accepted[task] = {
2488
+ provider: taskProvider,
2489
+ model,
2490
+ };
2491
+ if (validatedKey && !isLocal) {
2492
+ const service = apiKeyServiceName(taskProvider, "global");
2493
+ if (!persistedServices.has(service)) {
2494
+ await promptKeyDestinationAndPersist({
2495
+ rl,
2496
+ service,
2497
+ provider: taskProvider,
2498
+ key: validatedKey,
2499
+ scope: "global",
2500
+ });
2501
+ persistedServices.add(service);
2502
+ }
2503
+ keyCache.set(taskProvider, validatedKey);
1962
2504
  }
2505
+ break;
2506
+ }
2507
+ const retryChoice = await askChoice(rl, "Validation failed. What next?", [
2508
+ "Pick a different provider + model",
2509
+ "Keep provider, pick a new model",
2510
+ "Skip this task (keep current routing)",
2511
+ ]);
2512
+ if (retryChoice === 0) {
2513
+ const picked = await pickProvider(rl, dynamicModels, `Provider for ${task}`, taskProvider);
2514
+ if (picked)
2515
+ taskProvider = picked;
2516
+ continue;
1963
2517
  }
2518
+ if (retryChoice === 1) {
2519
+ continue;
2520
+ }
2521
+ accepted[task] = cur;
2522
+ break;
1964
2523
  }
1965
- // Step 5: write config (v5.9.4 Bug 10 — unified store resolution).
1966
- const storePath = ensureActiveStorePath(projectDir);
1967
- const existingLlm = existingConfig?.llm;
1968
- const existingProviderConfig = existingLlm
1969
- ? existingLlm[provider]
1970
- : undefined;
1971
- const providerConfigBase = (typeof existingProviderConfig === "object" && existingProviderConfig !== null)
1972
- ? existingProviderConfig
1973
- : {};
1974
- await updateConfig(storePath, {
1975
- llm: {
1976
- ...(existingLlm ?? {}),
1977
- defaultProvider: provider,
1978
- [provider]: {
1979
- ...providerConfigBase,
1980
- model,
1981
- },
1982
- },
1983
- });
1984
- // v5.9.3 Screen 3 — Diff() before the saved confirmation. Shows what
1985
- // landed in gnosys.json.
1986
- const { buildModelsDiffRows } = await import("./setup/modelsRender.js");
1987
- console.log();
1988
- printDiff(buildModelsDiffRows(currentProvider, currentModel, provider, model));
1989
- printStatus("ok", `saved · ${storePath}/gnosys.json`);
1990
2524
  }
1991
- finally {
1992
- if (ownsRl)
1993
- rl.close();
2525
+ const planned = { ...currentByTask };
2526
+ for (const task of selectedTasks) {
2527
+ if (accepted[task])
2528
+ planned[task] = accepted[task];
2529
+ }
2530
+ console.log();
2531
+ console.log(`${BOLD}Planned task routing${RESET}`);
2532
+ console.log(` ${"Task".padEnd(16)}${"Provider / model".padEnd(42)}${RESET}`);
2533
+ console.log(` ${"\u2500".repeat(56)}`);
2534
+ for (const task of tasks) {
2535
+ const p = planned[task];
2536
+ const marker = selectedSet.has(task) ? `${CYAN}*${RESET} ` : " ";
2537
+ const off = task === "dream" && !dreamEnabledBefore
2538
+ ? ` ${DIM}(dream off)${RESET}`
2539
+ : "";
2540
+ console.log(`${marker}${task.padEnd(14)}${p.provider} / ${p.model}${off}`);
2541
+ }
2542
+ console.log(`${DIM} * = changed in this run${RESET}`);
2543
+ console.log();
2544
+ const confirmed = await askYesNo(rl, "Save this routing?", true);
2545
+ if (!confirmed) {
2546
+ printStatus("warn", "cancelled · no changes written");
2547
+ return;
1994
2548
  }
2549
+ const taskModelsPatch = buildTaskModelsPatchFromAccepted(accepted, currentByTask, selectedSet);
2550
+ let dreamPatch;
2551
+ if (selectedSet.has("dream") && accepted.dream) {
2552
+ dreamPatch = {
2553
+ ...(cfgBefore.dream ?? {}),
2554
+ provider: accepted.dream.provider,
2555
+ model: accepted.dream.model,
2556
+ };
2557
+ if (!dreamEnabledBefore &&
2558
+ (await askYesNo(rl, "Enable dream mode with this routing?", true))) {
2559
+ dreamPatch.enabled = true;
2560
+ }
2561
+ }
2562
+ const updatePayload = {};
2563
+ if (taskModelsPatch)
2564
+ updatePayload.taskModels = taskModelsPatch;
2565
+ if (dreamPatch)
2566
+ updatePayload.dream = dreamPatch;
2567
+ if (!taskModelsPatch && !dreamPatch) {
2568
+ printStatus("warn", "no routing changes to save");
2569
+ return;
2570
+ }
2571
+ await updateConfig(storePath, updatePayload);
2572
+ printStatus("ok", `saved task routing · ${storePath}/gnosys.json`);
1995
2573
  }
1996
2574
  /**
1997
2575
  * Lightweight model-management command. Supports three operations:
@@ -2143,8 +2721,12 @@ export async function runDreamSetup(opts = {}) {
2143
2721
  dream: { ...(existingDream ?? {}), enabled: false },
2144
2722
  });
2145
2723
  clearDreamMachineEverywhere();
2724
+ const { uninstallDreamLaunchAgent } = await import("./dreamLaunchd.js");
2725
+ const launchdPath = uninstallDreamLaunchAgent();
2146
2726
  console.log();
2147
2727
  printStatus("ok", "dream mode disabled · designation cleared");
2728
+ if (launchdPath)
2729
+ printStatus("ok", "launchd agent removed", launchdPath);
2148
2730
  localDb.close();
2149
2731
  remoteDb?.close();
2150
2732
  return;
@@ -2201,26 +2783,27 @@ export async function runDreamSetup(opts = {}) {
2201
2783
  }
2202
2784
  // Validate — animated Spinner with the model latency reported.
2203
2785
  if (dreamProvider !== "skip") {
2204
- const apiKey = await getApiKeyForProvider(dreamProvider);
2786
+ const apiKey = await getApiKeyForProvider(dreamProvider, { task: "dream" });
2205
2787
  const isLocalProvider = dreamProvider === "ollama" || dreamProvider === "lmstudio";
2206
2788
  if (apiKey || isLocalProvider) {
2207
2789
  console.log();
2208
- const validateSpin = Spinner(`validating ${dreamProvider} / ${dreamModel}…`);
2209
2790
  try {
2210
2791
  const customBaseUrl = dreamProvider === "custom" ? process.env.GNOSYS_LLM_BASE_URL : undefined;
2211
- const result = await validateModel(dreamProvider, dreamModel, apiKey, { customBaseUrl });
2212
- if (result.ok) {
2213
- validateSpin.ok("model validated", `${result.latencyMs} ms · ${dreamProvider} / ${dreamModel}`);
2214
- }
2215
- else {
2216
- validateSpin.fail("could not reach model", result.error);
2217
- const proceed = await askYesNo(rl, "save config anyway?", true);
2218
- if (!proceed) {
2219
- printStatus("warn", "setup cancelled · no changes written");
2220
- localDb.close();
2221
- remoteDb?.close();
2222
- return;
2223
- }
2792
+ const { proceed } = await validateModelWithKeyRotation({
2793
+ rl,
2794
+ provider: dreamProvider,
2795
+ model: dreamModel,
2796
+ apiKey,
2797
+ customBaseUrl,
2798
+ isLocalProvider,
2799
+ saveAnywayPrompt: "save config anyway?",
2800
+ saveAnywayDefault: true,
2801
+ });
2802
+ if (!proceed) {
2803
+ printStatus("warn", "setup cancelled · no changes written");
2804
+ localDb.close();
2805
+ remoteDb?.close();
2806
+ return;
2224
2807
  }
2225
2808
  }
2226
2809
  catch (err) {
@@ -2228,7 +2811,7 @@ export async function runDreamSetup(opts = {}) {
2228
2811
  }
2229
2812
  }
2230
2813
  else {
2231
- printStatus("warn", `no API key for ${dreamProvider}`, "set one via `gnosys setup models`");
2814
+ printStatus("warn", `no API key for ${dreamProvider}`, "set one via `gnosys setup providers`");
2232
2815
  }
2233
2816
  }
2234
2817
  // ─── 7.2 Thresholds + sub-tasks ───────────────────────────────────
@@ -2241,6 +2824,12 @@ export async function runDreamSetup(opts = {}) {
2241
2824
  const dIdle = existingDream?.idleMinutes ?? 10;
2242
2825
  const dRuntime = existingDream?.maxRuntimeMinutes ?? 30;
2243
2826
  const dMinMem = existingDream?.minMemories ?? 10;
2827
+ const dScheduleStart = existingDream?.schedule?.startHour ?? 2;
2828
+ const dScheduleEnd = existingDream?.schedule?.endHour ?? 5;
2829
+ const dSystemIdle = existingDream?.systemIdleMinutes ?? 30;
2830
+ const dMinNewMemories = existingDream?.minNewMemoriesToDream ?? 10;
2831
+ const dMinHours = existingDream?.minHoursBetweenRuns ?? 20;
2832
+ const dMaxCalls = existingDream?.maxLLMCallsPerRun ?? 12;
2244
2833
  const dSelfCritique = existingDream?.selfCritique ?? true;
2245
2834
  const dGenSummaries = existingDream?.generateSummaries ?? true;
2246
2835
  const dDiscover = existingDream?.discoverRelationships ?? true;
@@ -2257,6 +2846,12 @@ export async function runDreamSetup(opts = {}) {
2257
2846
  let idleMinutes = dIdle;
2258
2847
  let maxRuntimeMinutes = dRuntime;
2259
2848
  let minMemories = dMinMem;
2849
+ let scheduleStartHour = dScheduleStart;
2850
+ let scheduleEndHour = dScheduleEnd;
2851
+ let systemIdleMinutes = dSystemIdle;
2852
+ let minNewMemoriesToDream = dMinNewMemories;
2853
+ let minHoursBetweenRuns = dMinHours;
2854
+ let maxLLMCallsPerRun = dMaxCalls;
2260
2855
  let selfCritique = dSelfCritique;
2261
2856
  let generateSummaries = dGenSummaries;
2262
2857
  let discoverRelationships = dDiscover;
@@ -2267,6 +2862,18 @@ export async function runDreamSetup(opts = {}) {
2267
2862
  maxRuntimeMinutes = Math.max(1, parseInt(runtimeAns) || dRuntime);
2268
2863
  const minMemAns = await askInput(rl, "minimum memories before activating", { default: String(dMinMem) });
2269
2864
  minMemories = Math.max(1, parseInt(minMemAns) || dMinMem);
2865
+ const scheduleStartAns = await askInput(rl, "night window start hour (0-23)", { default: String(dScheduleStart) });
2866
+ scheduleStartHour = Math.min(23, Math.max(0, parseInt(scheduleStartAns) || dScheduleStart));
2867
+ const scheduleEndAns = await askInput(rl, "night window end hour (0-23)", { default: String(dScheduleEnd) });
2868
+ scheduleEndHour = Math.min(23, Math.max(0, parseInt(scheduleEndAns) || dScheduleEnd));
2869
+ const systemIdleAns = await askInput(rl, "real machine idle minutes required", { default: String(dSystemIdle) });
2870
+ systemIdleMinutes = Math.max(1, parseInt(systemIdleAns) || dSystemIdle);
2871
+ const minNewAns = await askInput(rl, "minimum changed memories before dreaming", { default: String(dMinNewMemories) });
2872
+ minNewMemoriesToDream = Math.max(0, parseInt(minNewAns) || dMinNewMemories);
2873
+ const minHoursAns = await askInput(rl, "minimum hours between successful dreams", { default: String(dMinHours) });
2874
+ minHoursBetweenRuns = Math.max(0, parseInt(minHoursAns) || dMinHours);
2875
+ const maxCallsAns = await askInput(rl, "maximum LLM calls per dream run", { default: String(dMaxCalls) });
2876
+ maxLLMCallsPerRun = Math.max(0, parseInt(maxCallsAns) || dMaxCalls);
2270
2877
  selfCritique = await askYesNo(rl, "self-critique (rule + LLM-based review flagging)", dSelfCritique);
2271
2878
  generateSummaries = await askYesNo(rl, "generate summaries (LLM)", dGenSummaries);
2272
2879
  discoverRelationships = await askYesNo(rl, "discover relationships (LLM)", dDiscover);
@@ -2281,6 +2888,11 @@ export async function runDreamSetup(opts = {}) {
2281
2888
  minMemories,
2282
2889
  provider: dreamProvider,
2283
2890
  model: dreamModel || undefined,
2891
+ schedule: { startHour: scheduleStartHour, endHour: scheduleEndHour },
2892
+ systemIdleMinutes,
2893
+ minNewMemoriesToDream,
2894
+ minHoursBetweenRuns,
2895
+ maxLLMCallsPerRun,
2284
2896
  selfCritique,
2285
2897
  generateSummaries,
2286
2898
  discoverRelationships,
@@ -2289,6 +2901,8 @@ export async function runDreamSetup(opts = {}) {
2289
2901
  // Reset consecutive failure counter on a fresh setup so Layer 4
2290
2902
  // doesn't fire immediately based on stale history.
2291
2903
  localDb.resetDreamConsecutiveFailures();
2904
+ const { installDreamLaunchAgent } = await import("./dreamLaunchd.js");
2905
+ const launchdPath = installDreamLaunchAgent();
2292
2906
  localDb.close();
2293
2907
  remoteDb?.close();
2294
2908
  // Final Diff block per the design — provider/machine + the two
@@ -2316,7 +2930,9 @@ export async function runDreamSetup(opts = {}) {
2316
2930
  discoverRelationships,
2317
2931
  }));
2318
2932
  const dreamerName = designate ? localMachine : (designatedMachine ?? "the designated machine");
2319
- printStatus("progress", `first cycle runs after ${dreamerName} is idle for ${idleMinutes} min`);
2933
+ printStatus("progress", `scheduled dream checks run nightly (${scheduleStartHour}:00-${scheduleEndHour}:00) on ${dreamerName}`);
2934
+ if (launchdPath)
2935
+ printStatus("ok", "launchd agent installed", launchdPath);
2320
2936
  printStatus("progress", "check status anytime with `gnosys status --system`");
2321
2937
  }
2322
2938
  finally {
@@ -2379,29 +2995,18 @@ async function openRemoteDbIfConfigured(localDb) {
2379
2995
  * Best-effort lookup of the API key for a provider. Used by the dream setup
2380
2996
  * wizard to power the validation step. Mirrors the resolveApiKey precedence.
2381
2997
  */
2382
- export async function getApiKeyForProvider(provider) {
2383
- if (provider === "ollama" || provider === "lmstudio" || provider === "skip")
2998
+ export async function getApiKeyForProvider(provider, opts) {
2999
+ if (provider === "ollama" || provider === "lmstudio" || provider === "skip") {
2384
3000
  return "";
2385
- const envVarName = provider === "custom" ? "GNOSYS_CUSTOM_KEY" : `GNOSYS_${provider.toUpperCase()}_KEY`;
2386
- const legacyVars = {
2387
- anthropic: "ANTHROPIC_API_KEY",
2388
- openai: "OPENAI_API_KEY",
2389
- groq: "GROQ_API_KEY",
2390
- xai: "XAI_API_KEY",
2391
- mistral: "MISTRAL_API_KEY",
2392
- };
2393
- const fromEnv = process.env[envVarName] || (legacyVars[provider] && process.env[legacyVars[provider]]) || "";
2394
- if (fromEnv)
2395
- return fromEnv;
2396
- if (process.platform === "darwin") {
2397
- try {
2398
- return execSync(`security find-generic-password -a "$USER" -s "${envVarName}" -w 2>/dev/null`, {
2399
- stdio: "pipe", encoding: "utf-8", timeout: 2000,
2400
- }).trim();
2401
- }
2402
- catch {
2403
- // fall through
3001
+ }
3002
+ if (opts?.directory) {
3003
+ const cfg = await loadExistingConfig(opts.directory);
3004
+ if (cfg) {
3005
+ const fromCfg = getApiKeyForProviderFromConfig(cfg, provider);
3006
+ if (fromCfg)
3007
+ return fromCfg;
2404
3008
  }
2405
3009
  }
2406
- return "";
3010
+ return readFirstInChain(provider) ?? "";
2407
3011
  }
3012
+ export { getApiKeyForProviderFromConfig } from "./apiKeyVault.js";