gnosys 5.11.3 → 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 (244) hide show
  1. package/dist/cli.js +332 -5151
  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/ideMcpInstall.d.ts +30 -0
  74. package/dist/lib/ideMcpInstall.js +102 -0
  75. package/dist/lib/importCommand.d.ts +16 -0
  76. package/dist/lib/importCommand.js +89 -0
  77. package/dist/lib/importProjectCommand.d.ts +6 -0
  78. package/dist/lib/importProjectCommand.js +43 -0
  79. package/dist/lib/ingestCommand.d.ts +13 -0
  80. package/dist/lib/ingestCommand.js +95 -0
  81. package/dist/lib/installOutput.d.ts +36 -0
  82. package/dist/lib/installOutput.js +55 -0
  83. package/dist/lib/lensCommand.d.ts +20 -0
  84. package/dist/lib/lensCommand.js +61 -0
  85. package/dist/lib/lensing.d.ts +1 -0
  86. package/dist/lib/lensing.js +50 -9
  87. package/dist/lib/linksCommand.d.ts +7 -0
  88. package/dist/lib/linksCommand.js +48 -0
  89. package/dist/lib/listCommand.d.ts +8 -0
  90. package/dist/lib/listCommand.js +74 -0
  91. package/dist/lib/llm.d.ts +1 -1
  92. package/dist/lib/llm.js +26 -8
  93. package/dist/lib/localDiskCheck.d.ts +17 -0
  94. package/dist/lib/localDiskCheck.js +54 -0
  95. package/dist/lib/machineConfig.d.ts +11 -1
  96. package/dist/lib/machineConfig.js +16 -0
  97. package/dist/lib/machineRegistry.d.ts +61 -0
  98. package/dist/lib/machineRegistry.js +80 -0
  99. package/dist/lib/maintainCommand.d.ts +8 -0
  100. package/dist/lib/maintainCommand.js +34 -0
  101. package/dist/lib/masterLease.d.ts +20 -0
  102. package/dist/lib/masterLease.js +68 -0
  103. package/dist/lib/migrateCommand.d.ts +7 -0
  104. package/dist/lib/migrateCommand.js +158 -0
  105. package/dist/lib/migrateDbCommand.d.ts +9 -0
  106. package/dist/lib/migrateDbCommand.js +94 -0
  107. package/dist/lib/modelValidation.d.ts +5 -0
  108. package/dist/lib/modelValidation.js +27 -0
  109. package/dist/lib/openrouterTiers.d.ts +29 -0
  110. package/dist/lib/openrouterTiers.js +113 -0
  111. package/dist/lib/prefCommand.d.ts +10 -0
  112. package/dist/lib/prefCommand.js +118 -0
  113. package/dist/lib/projectsCommand.d.ts +8 -0
  114. package/dist/lib/projectsCommand.js +131 -0
  115. package/dist/lib/readCommand.d.ts +7 -0
  116. package/dist/lib/readCommand.js +62 -0
  117. package/dist/lib/recall.d.ts +3 -0
  118. package/dist/lib/recall.js +19 -4
  119. package/dist/lib/recallCommand.d.ts +11 -0
  120. package/dist/lib/recallCommand.js +112 -0
  121. package/dist/lib/reflectCommand.d.ts +8 -0
  122. package/dist/lib/reflectCommand.js +61 -0
  123. package/dist/lib/reindexCommand.d.ts +4 -0
  124. package/dist/lib/reindexCommand.js +34 -0
  125. package/dist/lib/reindexGraphCommand.d.ts +4 -0
  126. package/dist/lib/reindexGraphCommand.js +12 -0
  127. package/dist/lib/reinforceCommand.d.ts +8 -0
  128. package/dist/lib/reinforceCommand.js +40 -0
  129. package/dist/lib/remote.d.ts +5 -1
  130. package/dist/lib/remote.js +5 -1
  131. package/dist/lib/remoteWizard.d.ts +24 -5
  132. package/dist/lib/remoteWizard.js +308 -319
  133. package/dist/lib/restoreCommand.d.ts +5 -0
  134. package/dist/lib/restoreCommand.js +35 -0
  135. package/dist/lib/sandboxStartCommand.d.ts +6 -0
  136. package/dist/lib/sandboxStartCommand.js +25 -0
  137. package/dist/lib/sandboxStatusCommand.d.ts +4 -0
  138. package/dist/lib/sandboxStatusCommand.js +24 -0
  139. package/dist/lib/sandboxStopCommand.d.ts +4 -0
  140. package/dist/lib/sandboxStopCommand.js +21 -0
  141. package/dist/lib/searchCommand.d.ts +9 -0
  142. package/dist/lib/searchCommand.js +90 -0
  143. package/dist/lib/semanticSearchCommand.d.ts +8 -0
  144. package/dist/lib/semanticSearchCommand.js +52 -0
  145. package/dist/lib/setup/configSetRender.js +2 -0
  146. package/dist/lib/setup/providerGlyphs.d.ts +19 -0
  147. package/dist/lib/setup/providerGlyphs.js +42 -0
  148. package/dist/lib/setup/remoteRender.d.ts +31 -1
  149. package/dist/lib/setup/remoteRender.js +95 -4
  150. package/dist/lib/setup/sections/ides.d.ts +7 -0
  151. package/dist/lib/setup/sections/ides.js +24 -2
  152. package/dist/lib/setup/sections/providers.d.ts +17 -0
  153. package/dist/lib/setup/sections/providers.js +255 -0
  154. package/dist/lib/setup/sections/routing.d.ts +2 -6
  155. package/dist/lib/setup/sections/routing.js +33 -85
  156. package/dist/lib/setup/sections/taskRoutingEditor.d.ts +17 -0
  157. package/dist/lib/setup/sections/taskRoutingEditor.js +149 -0
  158. package/dist/lib/setup/summary.d.ts +9 -0
  159. package/dist/lib/setup/summary.js +51 -37
  160. package/dist/lib/setup/ui/status.d.ts +1 -0
  161. package/dist/lib/setup/ui/status.js +2 -0
  162. package/dist/lib/setup.d.ts +108 -3
  163. package/dist/lib/setup.js +813 -303
  164. package/dist/lib/setupKeys.d.ts +42 -0
  165. package/dist/lib/setupKeys.js +564 -0
  166. package/dist/lib/setupRemoteCommand.d.ts +4 -0
  167. package/dist/lib/setupRemoteCommand.js +28 -0
  168. package/dist/lib/setupRemotePullCommand.d.ts +5 -0
  169. package/dist/lib/setupRemotePullCommand.js +52 -0
  170. package/dist/lib/setupRemotePushCommand.d.ts +5 -0
  171. package/dist/lib/setupRemotePushCommand.js +57 -0
  172. package/dist/lib/setupRemoteResolveCommand.d.ts +4 -0
  173. package/dist/lib/setupRemoteResolveCommand.js +48 -0
  174. package/dist/lib/setupRemoteStatusCommand.d.ts +4 -0
  175. package/dist/lib/setupRemoteStatusCommand.js +73 -0
  176. package/dist/lib/setupRemoteSyncCommand.d.ts +6 -0
  177. package/dist/lib/setupRemoteSyncCommand.js +65 -0
  178. package/dist/lib/setupSyncProjectsCommand.d.ts +4 -0
  179. package/dist/lib/setupSyncProjectsCommand.js +292 -0
  180. package/dist/lib/staleCommand.d.ts +8 -0
  181. package/dist/lib/staleCommand.js +34 -0
  182. package/dist/lib/statsCommand.d.ts +6 -0
  183. package/dist/lib/statsCommand.js +142 -0
  184. package/dist/lib/statusCommand.d.ts +18 -0
  185. package/dist/lib/statusCommand.js +250 -0
  186. package/dist/lib/storesCommand.d.ts +2 -0
  187. package/dist/lib/storesCommand.js +4 -0
  188. package/dist/lib/syncClient.d.ts +47 -0
  189. package/dist/lib/syncClient.js +212 -0
  190. package/dist/lib/syncCommand.d.ts +6 -0
  191. package/dist/lib/syncCommand.js +57 -0
  192. package/dist/lib/syncDoctorCommand.d.ts +5 -0
  193. package/dist/lib/syncDoctorCommand.js +100 -0
  194. package/dist/lib/syncIngest.d.ts +19 -0
  195. package/dist/lib/syncIngest.js +152 -0
  196. package/dist/lib/syncIngestLaunchd.d.ts +8 -0
  197. package/dist/lib/syncIngestLaunchd.js +93 -0
  198. package/dist/lib/syncIngestStartup.d.ts +5 -0
  199. package/dist/lib/syncIngestStartup.js +29 -0
  200. package/dist/lib/syncIngestSystemd.d.ts +10 -0
  201. package/dist/lib/syncIngestSystemd.js +97 -0
  202. package/dist/lib/syncIngestTimer.d.ts +8 -0
  203. package/dist/lib/syncIngestTimer.js +27 -0
  204. package/dist/lib/syncIngestTimerCommand.d.ts +7 -0
  205. package/dist/lib/syncIngestTimerCommand.js +83 -0
  206. package/dist/lib/syncLock.d.ts +6 -0
  207. package/dist/lib/syncLock.js +74 -0
  208. package/dist/lib/syncSnapshot.d.ts +30 -0
  209. package/dist/lib/syncSnapshot.js +184 -0
  210. package/dist/lib/syncStaging.d.ts +81 -0
  211. package/dist/lib/syncStaging.js +239 -0
  212. package/dist/lib/tagsAddCommand.d.ts +8 -0
  213. package/dist/lib/tagsAddCommand.js +18 -0
  214. package/dist/lib/tagsCommand.d.ts +4 -0
  215. package/dist/lib/tagsCommand.js +16 -0
  216. package/dist/lib/timelineCommand.d.ts +7 -0
  217. package/dist/lib/timelineCommand.js +49 -0
  218. package/dist/lib/traceCommand.d.ts +6 -0
  219. package/dist/lib/traceCommand.js +39 -0
  220. package/dist/lib/traverseCommand.d.ts +6 -0
  221. package/dist/lib/traverseCommand.js +58 -0
  222. package/dist/lib/updateCommand.d.ts +13 -0
  223. package/dist/lib/updateCommand.js +67 -0
  224. package/dist/lib/updateStatusCommand.d.ts +5 -0
  225. package/dist/lib/updateStatusCommand.js +38 -0
  226. package/dist/lib/webAddCommand.d.ts +8 -0
  227. package/dist/lib/webAddCommand.js +55 -0
  228. package/dist/lib/webBuildCommand.d.ts +10 -0
  229. package/dist/lib/webBuildCommand.js +65 -0
  230. package/dist/lib/webBuildIndexCommand.d.ts +8 -0
  231. package/dist/lib/webBuildIndexCommand.js +37 -0
  232. package/dist/lib/webIngestCommand.d.ts +11 -0
  233. package/dist/lib/webIngestCommand.js +51 -0
  234. package/dist/lib/webInitCommand.d.ts +9 -0
  235. package/dist/lib/webInitCommand.js +167 -0
  236. package/dist/lib/webRemoveCommand.d.ts +5 -0
  237. package/dist/lib/webRemoveCommand.js +41 -0
  238. package/dist/lib/webStatusCommand.d.ts +5 -0
  239. package/dist/lib/webStatusCommand.js +94 -0
  240. package/dist/lib/webUpdateCommand.d.ts +7 -0
  241. package/dist/lib/webUpdateCommand.js +72 -0
  242. package/dist/lib/workingSetCommand.d.ts +6 -0
  243. package/dist/lib/workingSetCommand.js +37 -0
  244. package/package.json +2 -1
package/dist/lib/setup.js CHANGED
@@ -14,11 +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";
25
+ import { gnosysStdioMcpEntry, installStdioMcpJson, normalizeIdeKey, resolveGnosysMcpCommand, cursorMcpPaths, runCli, removeTomlSection, } from "./ideMcpInstall.js";
26
+ import { runKeysSetup } from "./setupKeys.js";
22
27
  // ─── ANSI Colors ────────────────────────────────────────────────────────────
23
28
  const BOLD = "\x1b[1m";
24
29
  const DIM = "\x1b[2m";
@@ -75,6 +80,7 @@ export const PROVIDER_TIERS = {
75
80
  lmstudio: [
76
81
  { name: "Default", model: "default", input: 0, output: 0, recommended: true },
77
82
  ],
83
+ openrouter: OPENROUTER_STATIC_TIERS,
78
84
  custom: [],
79
85
  };
80
86
  // ─── Dynamic Model Fetching (OpenRouter) ─────────────────────────────────────
@@ -94,7 +100,7 @@ const OPENROUTER_PREFIX_MAP = {
94
100
  * Fetch models from OpenRouter, cache for 24 hours, fall back to hardcoded.
95
101
  * Returns updated PROVIDER_TIERS for cloud providers only.
96
102
  */
97
- async function fetchDynamicModels() {
103
+ export async function fetchDynamicModels() {
98
104
  // Check cache first
99
105
  try {
100
106
  const stat = await fs.stat(CACHE_FILE);
@@ -249,6 +255,7 @@ async function fetchDynamicModels() {
249
255
  }
250
256
  result[ourProvider] = tiers;
251
257
  }
258
+ result.openrouter = buildOpenRouterTiers(data.data);
252
259
  // Cache the result
253
260
  try {
254
261
  await fs.mkdir(path.dirname(CACHE_FILE), { recursive: true });
@@ -283,6 +290,7 @@ const PROVIDER_DISPLAY = {
283
290
  xai: "xAI (Grok)",
284
291
  mistral: "Mistral",
285
292
  lmstudio: "LM Studio (local, free)",
293
+ openrouter: "OpenRouter (free + paid models)",
286
294
  custom: "Custom (any OpenAI-compatible API)",
287
295
  };
288
296
  const PROVIDER_ENV_VAR = {
@@ -291,6 +299,7 @@ const PROVIDER_ENV_VAR = {
291
299
  groq: "GNOSYS_GROQ_KEY",
292
300
  xai: "GNOSYS_XAI_KEY",
293
301
  mistral: "GNOSYS_MISTRAL_KEY",
302
+ openrouter: "GNOSYS_OPENROUTER_KEY",
294
303
  custom: "GNOSYS_CUSTOM_KEY",
295
304
  };
296
305
  // Ordered list for the menu
@@ -302,16 +311,40 @@ const PROVIDER_ORDER = [
302
311
  "xai",
303
312
  "mistral",
304
313
  "lmstudio",
314
+ "openrouter",
305
315
  "custom",
306
316
  ];
307
317
  // Task descriptions for display
308
- const TASK_DESCRIPTIONS = {
318
+ export const TASK_DESCRIPTIONS = {
309
319
  structuring: "adding memories, tagging",
310
320
  synthesis: "Q&A answers",
321
+ chat: "interactive chat TUI",
311
322
  vision: "images, PDFs",
312
323
  transcription: "audio files",
313
324
  dream: "overnight consolidation",
314
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
+ }
315
348
  // ─── Exported Helpers ───────────────────────────────────────────────────────
316
349
  /**
317
350
  * Returns the cheapest capable model for structuring tasks.
@@ -333,8 +366,10 @@ export function getStructuringModel(provider, chosenModel) {
333
366
  * Creates the directory and file if they don't exist.
334
367
  * Replaces an existing key line if found, otherwise appends.
335
368
  */
336
- export async function writeApiKey(provider, key) {
337
- 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];
338
373
  if (!envVar)
339
374
  return;
340
375
  const configDir = path.join(os.homedir(), ".config", "gnosys");
@@ -417,6 +452,68 @@ function hasSecretTool() {
417
452
  return false;
418
453
  }
419
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
+ }
420
517
  /**
421
518
  * Detect where an existing API key is stored.
422
519
  * Returns description like "macOS Keychain", "env var", ".env file", or empty string.
@@ -567,24 +664,6 @@ export async function detectIDEs(projectDir) {
567
664
  }
568
665
  return detected;
569
666
  }
570
- /** Remove a single `[section]` block from Grok TOML (hand-rolled; see upsertGrokMcpBlock). */
571
- function removeGrokTomlSection(existing, sectionHeader) {
572
- const lines = existing.split("\n");
573
- const headerIdx = lines.findIndex((line) => line.trim() === sectionHeader);
574
- if (headerIdx === -1)
575
- return existing;
576
- let endIdx = lines.length;
577
- for (let i = headerIdx + 1; i < lines.length; i++) {
578
- if (/^\s*\[/.test(lines[i])) {
579
- endIdx = i;
580
- break;
581
- }
582
- }
583
- const before = lines.slice(0, headerIdx).join("\n");
584
- const after = lines.slice(endIdx).join("\n");
585
- const merged = [before, after].filter((s) => s.length > 0).join("\n");
586
- return merged.length > 0 ? `${merged}\n` : "";
587
- }
588
667
  /**
589
668
  * Replace (or append) a `[mcp_servers.<name>]` block inside Grok Build's
590
669
  * `~/.grok/config.toml`. Preserves every line outside that block (deci-046).
@@ -596,7 +675,7 @@ function removeGrokTomlSection(existing, sectionHeader) {
596
675
  */
597
676
  export function upsertGrokMcpBlock(existing, name, entry) {
598
677
  // Drop mistaken v5.9.4 `[mcp.<name>]` sections so we don't leave dead config.
599
- let content = removeGrokTomlSection(existing, `[mcp.${name}]`);
678
+ let content = removeTomlSection(existing, `[mcp.${name}]`);
600
679
  const sectionHeader = `[mcp_servers.${name}]`;
601
680
  const lines = content.split("\n");
602
681
  const headerIdx = lines.findIndex((line) => line.trim() === sectionHeader);
@@ -632,25 +711,6 @@ export function upsertGrokMcpBlock(existing, name, entry) {
632
711
  const afterBlock = afterLines.join("\n");
633
712
  return `${head}${sectionHeader}\n${blockBody}${gap}${afterBlock}`;
634
713
  }
635
- /**
636
- * Absolute path to the `gnosys-mcp` stdio entry (dist/index.js).
637
- * Prefer this over `gnosys serve` — v5.11.0 `gnosys serve` imported index.js but
638
- * did not call startMcpServer(), so MCP hosts saw "connection closed" on init.
639
- */
640
- function resolveGnosysMcpCommand() {
641
- try {
642
- const p = execSync("command -v gnosys-mcp", { encoding: "utf-8" }).trim();
643
- if (p)
644
- return p;
645
- }
646
- catch {
647
- // Fall back to bare name on PATH.
648
- }
649
- return "gnosys-mcp";
650
- }
651
- function gnosysMcpServerEntry() {
652
- return { command: resolveGnosysMcpCommand(), args: [] };
653
- }
654
714
  function renderGrokMcpBlock(entry) {
655
715
  const argsStr = `[${entry.args.map((a) => JSON.stringify(a)).join(", ")}]`;
656
716
  const lines = [
@@ -666,48 +726,45 @@ function renderGrokMcpBlock(entry) {
666
726
  * Set up Gnosys MCP integration for a specific IDE.
667
727
  */
668
728
  export async function setupIDE(ide, projectDir) {
729
+ const canonical = normalizeIdeKey(ide);
730
+ if (!canonical) {
731
+ return { success: false, message: `Unknown IDE: ${ide}` };
732
+ }
669
733
  try {
670
- switch (ide) {
734
+ switch (canonical) {
671
735
  case "claude": {
672
736
  const mcpCmd = resolveGnosysMcpCommand();
737
+ let codeMsg = `Claude Code MCP registered (${mcpCmd})`;
673
738
  try {
674
- try {
675
- execSync("claude mcp remove gnosys", { stdio: "pipe" });
676
- }
677
- catch {
678
- // Not registered yet — fine.
679
- }
680
- execSync(`claude mcp add -s user gnosys -- ${mcpCmd}`, {
681
- stdio: "pipe",
682
- });
739
+ runCli("claude", ["mcp", "remove", "gnosys"], { allowFailure: true });
740
+ runCli("claude", ["mcp", "add", "-s", "user", "gnosys", "--", mcpCmd]);
683
741
  }
684
742
  catch (e) {
685
743
  const msg = e instanceof Error ? e.message : String(e);
686
744
  if (msg.includes("already exists")) {
687
- return { success: true, message: "Claude Code MCP server already configured" };
745
+ codeMsg = "Claude Code MCP server already configured";
746
+ }
747
+ else {
748
+ codeMsg = `Claude Code MCP skipped (is \`claude\` on PATH?): ${msg}`;
688
749
  }
689
- throw e;
690
750
  }
691
- return { success: true, message: `Claude Code MCP server registered (${mcpCmd})` };
751
+ const desktop = await setupIDE("claude-desktop", projectDir);
752
+ return {
753
+ success: desktop.success,
754
+ message: desktop.success
755
+ ? `${codeMsg}; ${desktop.message}`
756
+ : `${codeMsg}; Claude Desktop: ${desktop.message}`,
757
+ };
692
758
  }
693
759
  case "cursor": {
694
- const cursorDir = path.join(projectDir, ".cursor");
695
- const mcpPath = path.join(cursorDir, "mcp.json");
696
- await fs.mkdir(cursorDir, { recursive: true });
697
- let config = {};
698
- try {
699
- const existing = await fs.readFile(mcpPath, "utf-8");
700
- config = JSON.parse(existing);
701
- }
702
- catch {
703
- // File doesn't exist or is invalid — start fresh
704
- }
705
- // Merge gnosys entry
706
- const servers = (config.mcpServers ?? {});
707
- servers.gnosys = gnosysMcpServerEntry();
708
- config.mcpServers = servers;
709
- await fs.writeFile(mcpPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
710
- return { success: true, message: "Cursor MCP config updated (.cursor/mcp.json)" };
760
+ const paths = cursorMcpPaths(projectDir);
761
+ await fs.mkdir(path.dirname(paths.project), { recursive: true });
762
+ await installStdioMcpJson(paths.project);
763
+ await installStdioMcpJson(paths.user);
764
+ return {
765
+ success: true,
766
+ message: "Cursor MCP updated (project .cursor/mcp.json and ~/.cursor/mcp.json)",
767
+ };
711
768
  }
712
769
  case "codex": {
713
770
  // v5.8.5: register via `codex mcp add` (the real Codex CLI registration
@@ -719,19 +776,13 @@ export async function setupIDE(ide, projectDir) {
719
776
  //
720
777
  // We also migrate away from those legacy blocks in
721
778
  // `~/.codex/config.toml` so users on stale configs get cleaned up.
722
- const os = await import("os");
723
- // 1. Migrate (strip) legacy hand-written blocks in ~/.codex/config.toml.
779
+ // 1. Strip legacy hand-written sections in ~/.codex/config.toml.
724
780
  const userCodexConfig = path.join(os.homedir(), ".codex", "config.toml");
725
781
  try {
726
782
  let existing = await fs.readFile(userCodexConfig, "utf-8");
727
- const before = existing;
728
- // Old shape (pre-v5.8.4): [gnosys] command/args
729
- existing = existing.replace(/\n?\[gnosys\][^[]*?command\s*=\s*"gnosys"[^[]*?args\s*=\s*\[[^\]]*\]\s*\n?/, "\n");
730
- // v5.8.4 shape: [mcp.gnosys] type/command
731
- existing = existing.replace(/\n?\[mcp\.gnosys\][^[]*?type\s*=\s*"local"[^[]*?command\s*=\s*\[[^\]]*\]\s*\n?/, "\n");
732
- if (existing !== before) {
733
- existing = existing.replace(/\n{3,}/g, "\n\n");
734
- await fs.writeFile(userCodexConfig, existing, "utf-8");
783
+ const cleaned = removeTomlSection(removeTomlSection(existing, "[mcp.gnosys]"), "[gnosys]");
784
+ if (cleaned !== existing) {
785
+ await fs.writeFile(userCodexConfig, cleaned, "utf-8");
735
786
  }
736
787
  }
737
788
  catch {
@@ -744,25 +795,16 @@ export async function setupIDE(ide, projectDir) {
744
795
  // remove and re-add.
745
796
  let alreadyCorrect = false;
746
797
  try {
747
- const existing = execSync("codex mcp get gnosys 2>/dev/null", {
748
- encoding: "utf-8",
749
- });
798
+ const existing = runCli("codex", ["mcp", "get", "gnosys"], { allowFailure: true });
750
799
  if (existing && existing.includes(gnosysCmd) && !existing.includes(" serve")) {
751
800
  alreadyCorrect = true;
752
801
  }
753
802
  else if (existing) {
754
- // Different command — remove so we can re-add with the right one.
755
- try {
756
- execSync("codex mcp remove gnosys", { stdio: "pipe" });
757
- }
758
- catch {
759
- // Non-fatal — `mcp add` below will overwrite or fail loudly.
760
- }
803
+ runCli("codex", ["mcp", "remove", "gnosys"], { allowFailure: true });
761
804
  }
762
805
  }
763
806
  catch {
764
- // `codex mcp get` returns non-zero when the server isn't registered —
765
- // that's the common case on first install; proceed to add.
807
+ // `codex mcp get` failed proceed to add.
766
808
  }
767
809
  if (alreadyCorrect) {
768
810
  return {
@@ -772,7 +814,7 @@ export async function setupIDE(ide, projectDir) {
772
814
  }
773
815
  // 4. Register via the canonical Codex CLI command.
774
816
  try {
775
- execSync(`codex mcp add gnosys -- ${gnosysCmd}`, { stdio: "pipe" });
817
+ runCli("codex", ["mcp", "add", "gnosys", "--", gnosysCmd]);
776
818
  }
777
819
  catch (err) {
778
820
  const msg = err instanceof Error ? err.message : String(err);
@@ -789,43 +831,19 @@ export async function setupIDE(ide, projectDir) {
789
831
  };
790
832
  }
791
833
  case "gemini-cli": {
792
- // Gemini CLI reads MCP servers from ~/.gemini/settings.json (user-level)
793
- const geminiDir = path.join(os.homedir(), ".gemini");
794
- const settingsPath = path.join(geminiDir, "settings.json");
795
- await fs.mkdir(geminiDir, { recursive: true });
796
- let config = {};
797
- try {
798
- const existing = await fs.readFile(settingsPath, "utf-8");
799
- config = JSON.parse(existing);
800
- }
801
- catch {
802
- // File doesn't exist or is invalid — start fresh
803
- }
804
- const servers = (config.mcpServers ?? {});
805
- servers.gnosys = gnosysMcpServerEntry();
806
- config.mcpServers = servers;
807
- await fs.writeFile(settingsPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
834
+ const settingsPath = path.join(os.homedir(), ".gemini", "settings.json");
835
+ await fs.mkdir(path.dirname(settingsPath), { recursive: true });
836
+ await installStdioMcpJson(settingsPath);
808
837
  return { success: true, message: "Gemini CLI MCP config updated (~/.gemini/settings.json)" };
809
838
  }
810
839
  case "antigravity": {
811
- // Antigravity reads MCP servers from ~/.gemini/antigravity/mcp_config.json
812
- // (separate file from Gemini CLI's settings.json, even though they share the parent dir)
813
- const antigravityDir = path.join(os.homedir(), ".gemini", "antigravity");
814
- const configPath = path.join(antigravityDir, "mcp_config.json");
815
- await fs.mkdir(antigravityDir, { recursive: true });
816
- let config = {};
817
- try {
818
- const existing = await fs.readFile(configPath, "utf-8");
819
- config = JSON.parse(existing);
820
- }
821
- catch {
822
- // File doesn't exist or is invalid — start fresh
823
- }
824
- const servers = (config.mcpServers ?? {});
825
- servers.gnosys = gnosysMcpServerEntry();
826
- config.mcpServers = servers;
827
- await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
828
- return { success: true, message: "Antigravity MCP config updated (~/.gemini/antigravity/mcp_config.json)" };
840
+ const configPath = path.join(os.homedir(), ".gemini", "antigravity", "mcp_config.json");
841
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
842
+ await installStdioMcpJson(configPath);
843
+ return {
844
+ success: true,
845
+ message: "Antigravity MCP config updated (~/.gemini/antigravity/mcp_config.json)",
846
+ };
829
847
  }
830
848
  case "grok-build": {
831
849
  // v5.9.4 Bug 12 — Grok Build reads MCP from `[mcp_servers.<name>]`
@@ -842,32 +860,16 @@ export async function setupIDE(ide, projectDir) {
842
860
  // File doesn't exist yet — start fresh
843
861
  }
844
862
  const updated = upsertGrokMcpBlock(existing, "gnosys", {
845
- ...gnosysMcpServerEntry(),
863
+ ...gnosysStdioMcpEntry(),
846
864
  startup_timeout_sec: 90,
847
865
  });
848
866
  await fs.writeFile(configPath, updated, "utf-8");
849
867
  return { success: true, message: "Grok Build MCP config updated (~/.grok/config.toml)" };
850
868
  }
851
869
  case "claude-desktop": {
852
- // Claude Desktop reads MCP servers from claude_desktop_config.json
853
- // in a platform-specific app data directory. Distinct from Claude
854
- // Code CLI which uses `claude mcp add`.
855
870
  const configPath = getClaudeDesktopConfigPath();
856
- const configDir = path.dirname(configPath);
857
- await fs.mkdir(configDir, { recursive: true });
858
- let config = {};
859
- try {
860
- const existing = await fs.readFile(configPath, "utf-8");
861
- config = JSON.parse(existing);
862
- }
863
- catch {
864
- // File doesn't exist or is invalid — start fresh
865
- }
866
- const servers = (config.mcpServers ?? {});
867
- servers.gnosys = gnosysMcpServerEntry();
868
- config.mcpServers = servers;
869
- await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
870
- // Display path with ~ prefix when inside HOME for clarity
871
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
872
+ await installStdioMcpJson(configPath);
871
873
  const home = os.homedir();
872
874
  const displayPath = configPath.startsWith(home)
873
875
  ? configPath.replace(home, "~")
@@ -877,9 +879,8 @@ export async function setupIDE(ide, projectDir) {
877
879
  message: `Claude Desktop MCP config updated (${displayPath}). Restart Claude Desktop for the change to take effect.`,
878
880
  };
879
881
  }
880
- default:
881
- return { success: false, message: `Unknown IDE: ${ide}` };
882
882
  }
883
+ return { success: false, message: `Unhandled IDE: ${canonical}` };
883
884
  }
884
885
  catch (err) {
885
886
  const msg = err instanceof Error ? err.message : String(err);
@@ -912,16 +913,20 @@ async function askChoice(rl, question, options) {
912
913
  /**
913
914
  * Read a single line of input with an optional default value.
914
915
  */
915
- async function askInput(rl, prompt, opts) {
916
+ export async function askInput(rl, prompt, opts) {
916
917
  const suffix = opts?.default ? ` ${DIM}(${opts.default})${RESET}` : "";
917
918
  const answer = await safeQuestion(rl, `${prompt}${suffix}: `);
918
919
  const trimmed = answer.trim();
919
920
  return trimmed || opts?.default || "";
920
921
  }
922
+ export function printInfo(text, meta) {
923
+ const suffix = meta ? ` ${DIM}${meta}${RESET}` : "";
924
+ console.log(` ${text}${suffix}`);
925
+ }
921
926
  /**
922
927
  * Y/n prompt. Returns true for yes.
923
928
  */
924
- async function askYesNo(rl, question, defaultYes = true) {
929
+ export async function askYesNo(rl, question, defaultYes = true) {
925
930
  const hint = defaultYes ? "Y/n" : "y/N";
926
931
  const answer = await safeQuestion(rl, `${question} [${hint}] `);
927
932
  const trimmed = answer.trim().toLowerCase();
@@ -929,6 +934,78 @@ async function askYesNo(rl, question, defaultYes = true) {
929
934
  return defaultYes;
930
935
  return trimmed === "y" || trimmed === "yes";
931
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
+ }
932
1009
  /**
933
1010
  * Format a price for display: "$0.80" or "free".
934
1011
  */
@@ -1021,7 +1098,7 @@ async function loadExistingConfig(projectDir) {
1021
1098
  * Returns the provider name or "skip".
1022
1099
  * If currentProvider is given, shows it as the current value.
1023
1100
  */
1024
- async function pickProvider(rl, dynamicModels, stepLabel, currentProvider) {
1101
+ export async function pickProvider(rl, dynamicModels, stepLabel, currentProvider) {
1025
1102
  const currentHint = currentProvider ? ` ${DIM}(current: ${currentProvider})${RESET}` : "";
1026
1103
  const providerOptions = PROVIDER_ORDER.map((key) => {
1027
1104
  const tiers = dynamicModels[key] ?? PROVIDER_TIERS[key];
@@ -1042,7 +1119,7 @@ async function pickProvider(rl, dynamicModels, stepLabel, currentProvider) {
1042
1119
  * Returns the model string. Includes a "Custom (enter model name)"
1043
1120
  * option so users can type any model ID not in the curated list.
1044
1121
  */
1045
- async function pickModel(rl, provider, dynamicModels, stepLabel, currentModel) {
1122
+ export async function pickModel(rl, provider, dynamicModels, stepLabel, currentModel) {
1046
1123
  const tiers = dynamicModels[provider] ?? PROVIDER_TIERS[provider];
1047
1124
  if (!tiers || tiers.length === 0) {
1048
1125
  // No tiers available — fall back to direct entry
@@ -1066,8 +1143,10 @@ async function pickModel(rl, provider, dynamicModels, stepLabel, currentModel) {
1066
1143
  }
1067
1144
  return tiers[tierIndex].model;
1068
1145
  }
1069
- // ─── Main Setup Wizard ──────────────────────────────────────────────────────
1070
1146
  export async function runSetup(opts) {
1147
+ if (opts.section === "keys") {
1148
+ return runKeysSetup();
1149
+ }
1071
1150
  const version = getVersion();
1072
1151
  const projectDir = opts.directory ? path.resolve(opts.directory) : process.cwd();
1073
1152
  // ─── Non-interactive mode ─────────────────────────────────────────────
@@ -1294,6 +1373,7 @@ export async function runSetup(opts) {
1294
1373
  groq: "GROQ_API_KEY",
1295
1374
  xai: "XAI_API_KEY",
1296
1375
  mistral: "MISTRAL_API_KEY",
1376
+ openrouter: "OPENROUTER_API_KEY",
1297
1377
  };
1298
1378
  const legacyEnvVar = legacyEnvVars[provider] ?? "";
1299
1379
  if (needsKey) {
@@ -1468,26 +1548,27 @@ export async function runSetup(opts) {
1468
1548
  console.log();
1469
1549
  console.log(`${DIM}Testing ${provider}/${model}...${RESET}`);
1470
1550
  try {
1471
- const { validateModel } = await import("./modelValidation.js");
1472
1551
  const customBaseUrl = provider === "custom"
1473
1552
  ? process.env.GNOSYS_LLM_BASE_URL
1474
1553
  : undefined;
1475
- const result = await validateModel(provider, model, capturedApiKey, { customBaseUrl });
1476
- if (result.ok) {
1477
- console.log(` ${CHECK} Model validated (${result.latencyMs}ms)`);
1478
- }
1479
- else {
1480
- console.log(` ${WARN} Model test failed: ${result.error}`);
1481
- const proceed = await askYesNo(rl, " Continue anyway?", true);
1482
- if (!proceed) {
1483
- console.log(` ${DIM}Setup paused. Re-run when ready: gnosys setup${RESET}`);
1484
- setupCompleted = true;
1485
- rl.close();
1486
- return {
1487
- provider, model, structuringModel: "",
1488
- apiKeyWritten, ides: [], mode: "agent", upgraded,
1489
- };
1490
- }
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
+ };
1491
1572
  }
1492
1573
  }
1493
1574
  catch (err) {
@@ -1766,6 +1847,18 @@ export async function runSetup(opts) {
1766
1847
  await updateConfig(storePath, configUpdates);
1767
1848
  console.log();
1768
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
+ }
1769
1862
  }
1770
1863
  catch (err) {
1771
1864
  const msg = err instanceof Error ? err.message : String(err);
@@ -1935,6 +2028,166 @@ export async function runProviderOnlySetup(opts = {}) {
1935
2028
  rl.close();
1936
2029
  }
1937
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
+ }
1938
2191
  /**
1939
2192
  * Models-only configuration — prompts for provider, model, and key (or accepts
1940
2193
  * them via options for non-interactive use). Validates the model against the
@@ -1945,7 +2198,6 @@ export async function runModelsSetup(opts = {}) {
1945
2198
  const ownsRl = !opts.rl;
1946
2199
  const rl = opts.rl ?? createInterface({ input: stdin, output: stdout });
1947
2200
  try {
1948
- // v5.9.3 Screen 3 — Header + Title at the top.
1949
2201
  const { Header } = await import("./setup/ui/header.js");
1950
2202
  const { Title } = await import("./setup/ui/title.js");
1951
2203
  const { Spinner } = await import("./setup/ui/spinner.js");
@@ -1954,16 +2206,13 @@ export async function runModelsSetup(opts = {}) {
1954
2206
  console.log();
1955
2207
  console.log(Header(["gnosys", "setup", "models"]));
1956
2208
  console.log();
1957
- 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"));
1958
2210
  console.log();
1959
2211
  const existingConfig = await loadExistingConfig(projectDir);
1960
2212
  const currentProvider = existingConfig?.llm.defaultProvider;
1961
2213
  const currentModel = existingConfig
1962
2214
  ? getProviderModel(existingConfig, existingConfig.llm.defaultProvider)
1963
2215
  : undefined;
1964
- // Step 1: provider (or use --provider flag). v5.9.3: animate the
1965
- // OpenRouter pricing fetch under a Spinner so the user gets feedback
1966
- // on what would otherwise feel like a hang.
1967
2216
  const pricingSpin = Spinner("fetching latest pricing from openrouter…");
1968
2217
  const fetchStart = Date.now();
1969
2218
  const dynamicModels = await fetchDynamicModels();
@@ -1973,8 +2222,6 @@ export async function runModelsSetup(opts = {}) {
1973
2222
  pricingSpin.ok(`pricing loaded · ${modelCount} models cached`, `${fetchMs} ms`);
1974
2223
  }
1975
2224
  else {
1976
- // No-op fallback (cache miss + network fail) — keep the hardcoded
1977
- // tiers but signal that we're running offline.
1978
2225
  pricingSpin.fail("pricing fetch failed", "using bundled tiers");
1979
2226
  }
1980
2227
  console.log();
@@ -1990,103 +2237,339 @@ export async function runModelsSetup(opts = {}) {
1990
2237
  else {
1991
2238
  provider = await pickProvider(rl, dynamicModels, "Choose your LLM provider", currentProvider);
1992
2239
  }
1993
- // Step 2: model (or use --model flag)
1994
- let model;
1995
- if (opts.model) {
1996
- model = opts.model;
1997
- 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");
1998
2320
  }
1999
2321
  else {
2000
- const tiers = dynamicModels[provider] ?? PROVIDER_TIERS[provider];
2001
- if (provider === "custom" || !tiers || tiers.length === 0) {
2002
- model = await askInput(rl, "Model name");
2003
- }
2004
- else {
2005
- const showCurrent = currentProvider === provider ? currentModel : undefined;
2006
- model = await pickModel(rl, provider, dynamicModels, "Choose model", showCurrent);
2007
- }
2322
+ const showCurrent = ctx.currentProvider === provider ? ctx.currentModel : undefined;
2323
+ model = await pickModel(rl, provider, dynamicModels, "Choose model", showCurrent);
2324
+ }
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.`);
2008
2338
  }
2009
- if (!model) {
2010
- printStatus("fail", "no model selected · aborting");
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");
2011
2358
  return;
2012
2359
  }
2013
- // Step 3: load API key from existing storage (if available)
2014
- const envVarName = provider === "custom" ? "GNOSYS_CUSTOM_KEY" :
2015
- `GNOSYS_${provider.toUpperCase()}_KEY`;
2016
- const legacyEnvVars = {
2017
- anthropic: "ANTHROPIC_API_KEY",
2018
- openai: "OPENAI_API_KEY",
2019
- groq: "GROQ_API_KEY",
2020
- xai: "XAI_API_KEY",
2021
- mistral: "MISTRAL_API_KEY",
2022
- };
2023
- const legacyEnvVar = legacyEnvVars[provider] ?? "";
2024
- let apiKey = process.env[envVarName] || (legacyEnvVar ? process.env[legacyEnvVar] : "") || "";
2025
- if (!apiKey && process.platform === "darwin") {
2026
- try {
2027
- apiKey = execSync(`security find-generic-password -a "$USER" -s "${envVarName}" -w`, { stdio: "pipe", encoding: "utf-8" }).trim();
2028
- }
2029
- catch {
2030
- // No key in keychain
2031
- }
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;
2032
2420
  }
2033
- if (!apiKey && provider !== "ollama" && provider !== "lmstudio") {
2034
- console.log(`${WARN} No API key found for ${provider}. Run 'gnosys setup' to configure one.`);
2035
- // Continue anyway — user might just want to update the model in config
2421
+ if (parsed === "none") {
2422
+ selectedIndices = [];
2423
+ break;
2036
2424
  }
2037
- // Step 4: validate (default: true) — v5.9.3 Screen 3: animated
2038
- // Spinner with latency reported on success.
2039
- const shouldValidate = opts.validate !== false;
2040
- const isLocalProvider = provider === "ollama" || provider === "lmstudio";
2041
- if (shouldValidate && (apiKey || isLocalProvider)) {
2425
+ if (parsed && parsed.length > 0) {
2426
+ selectedIndices = parsed;
2427
+ break;
2428
+ }
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;
2042
2456
  console.log();
2043
- const validateSpin = Spinner(`validating ${provider} / ${model}…`);
2044
- const customBaseUrl = provider === "custom"
2045
- ? process.env.GNOSYS_LLM_BASE_URL
2046
- : undefined;
2047
- const result = await validateModel(provider, model, apiKey, { customBaseUrl });
2048
- if (result.ok) {
2049
- 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;
2050
2462
  }
2051
- else {
2052
- validateSpin.fail("model test failed", result.error);
2053
- const proceed = await askYesNo(rl, "Save config anyway?", false);
2054
- if (!proceed) {
2055
- printStatus("warn", "cancelled · no changes written");
2056
- 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);
2057
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;
2517
+ }
2518
+ if (retryChoice === 1) {
2519
+ continue;
2058
2520
  }
2521
+ accepted[task] = cur;
2522
+ break;
2059
2523
  }
2060
- // Step 5: write config (v5.9.4 Bug 10 — unified store resolution).
2061
- const storePath = ensureActiveStorePath(projectDir);
2062
- const existingLlm = existingConfig?.llm;
2063
- const existingProviderConfig = existingLlm
2064
- ? existingLlm[provider]
2065
- : undefined;
2066
- const providerConfigBase = (typeof existingProviderConfig === "object" && existingProviderConfig !== null)
2067
- ? existingProviderConfig
2068
- : {};
2069
- await updateConfig(storePath, {
2070
- llm: {
2071
- ...(existingLlm ?? {}),
2072
- defaultProvider: provider,
2073
- [provider]: {
2074
- ...providerConfigBase,
2075
- model,
2076
- },
2077
- },
2078
- });
2079
- // v5.9.3 Screen 3 — Diff() before the saved confirmation. Shows what
2080
- // landed in gnosys.json.
2081
- const { buildModelsDiffRows } = await import("./setup/modelsRender.js");
2082
- console.log();
2083
- printDiff(buildModelsDiffRows(currentProvider, currentModel, provider, model));
2084
- printStatus("ok", `saved · ${storePath}/gnosys.json`);
2085
2524
  }
2086
- finally {
2087
- if (ownsRl)
2088
- 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}`);
2089
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;
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`);
2090
2573
  }
2091
2574
  /**
2092
2575
  * Lightweight model-management command. Supports three operations:
@@ -2238,8 +2721,12 @@ export async function runDreamSetup(opts = {}) {
2238
2721
  dream: { ...(existingDream ?? {}), enabled: false },
2239
2722
  });
2240
2723
  clearDreamMachineEverywhere();
2724
+ const { uninstallDreamLaunchAgent } = await import("./dreamLaunchd.js");
2725
+ const launchdPath = uninstallDreamLaunchAgent();
2241
2726
  console.log();
2242
2727
  printStatus("ok", "dream mode disabled · designation cleared");
2728
+ if (launchdPath)
2729
+ printStatus("ok", "launchd agent removed", launchdPath);
2243
2730
  localDb.close();
2244
2731
  remoteDb?.close();
2245
2732
  return;
@@ -2296,26 +2783,27 @@ export async function runDreamSetup(opts = {}) {
2296
2783
  }
2297
2784
  // Validate — animated Spinner with the model latency reported.
2298
2785
  if (dreamProvider !== "skip") {
2299
- const apiKey = await getApiKeyForProvider(dreamProvider);
2786
+ const apiKey = await getApiKeyForProvider(dreamProvider, { task: "dream" });
2300
2787
  const isLocalProvider = dreamProvider === "ollama" || dreamProvider === "lmstudio";
2301
2788
  if (apiKey || isLocalProvider) {
2302
2789
  console.log();
2303
- const validateSpin = Spinner(`validating ${dreamProvider} / ${dreamModel}…`);
2304
2790
  try {
2305
2791
  const customBaseUrl = dreamProvider === "custom" ? process.env.GNOSYS_LLM_BASE_URL : undefined;
2306
- const result = await validateModel(dreamProvider, dreamModel, apiKey, { customBaseUrl });
2307
- if (result.ok) {
2308
- validateSpin.ok("model validated", `${result.latencyMs} ms · ${dreamProvider} / ${dreamModel}`);
2309
- }
2310
- else {
2311
- validateSpin.fail("could not reach model", result.error);
2312
- const proceed = await askYesNo(rl, "save config anyway?", true);
2313
- if (!proceed) {
2314
- printStatus("warn", "setup cancelled · no changes written");
2315
- localDb.close();
2316
- remoteDb?.close();
2317
- return;
2318
- }
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;
2319
2807
  }
2320
2808
  }
2321
2809
  catch (err) {
@@ -2323,7 +2811,7 @@ export async function runDreamSetup(opts = {}) {
2323
2811
  }
2324
2812
  }
2325
2813
  else {
2326
- 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`");
2327
2815
  }
2328
2816
  }
2329
2817
  // ─── 7.2 Thresholds + sub-tasks ───────────────────────────────────
@@ -2336,6 +2824,12 @@ export async function runDreamSetup(opts = {}) {
2336
2824
  const dIdle = existingDream?.idleMinutes ?? 10;
2337
2825
  const dRuntime = existingDream?.maxRuntimeMinutes ?? 30;
2338
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;
2339
2833
  const dSelfCritique = existingDream?.selfCritique ?? true;
2340
2834
  const dGenSummaries = existingDream?.generateSummaries ?? true;
2341
2835
  const dDiscover = existingDream?.discoverRelationships ?? true;
@@ -2352,6 +2846,12 @@ export async function runDreamSetup(opts = {}) {
2352
2846
  let idleMinutes = dIdle;
2353
2847
  let maxRuntimeMinutes = dRuntime;
2354
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;
2355
2855
  let selfCritique = dSelfCritique;
2356
2856
  let generateSummaries = dGenSummaries;
2357
2857
  let discoverRelationships = dDiscover;
@@ -2362,6 +2862,18 @@ export async function runDreamSetup(opts = {}) {
2362
2862
  maxRuntimeMinutes = Math.max(1, parseInt(runtimeAns) || dRuntime);
2363
2863
  const minMemAns = await askInput(rl, "minimum memories before activating", { default: String(dMinMem) });
2364
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);
2365
2877
  selfCritique = await askYesNo(rl, "self-critique (rule + LLM-based review flagging)", dSelfCritique);
2366
2878
  generateSummaries = await askYesNo(rl, "generate summaries (LLM)", dGenSummaries);
2367
2879
  discoverRelationships = await askYesNo(rl, "discover relationships (LLM)", dDiscover);
@@ -2376,6 +2888,11 @@ export async function runDreamSetup(opts = {}) {
2376
2888
  minMemories,
2377
2889
  provider: dreamProvider,
2378
2890
  model: dreamModel || undefined,
2891
+ schedule: { startHour: scheduleStartHour, endHour: scheduleEndHour },
2892
+ systemIdleMinutes,
2893
+ minNewMemoriesToDream,
2894
+ minHoursBetweenRuns,
2895
+ maxLLMCallsPerRun,
2379
2896
  selfCritique,
2380
2897
  generateSummaries,
2381
2898
  discoverRelationships,
@@ -2384,6 +2901,8 @@ export async function runDreamSetup(opts = {}) {
2384
2901
  // Reset consecutive failure counter on a fresh setup so Layer 4
2385
2902
  // doesn't fire immediately based on stale history.
2386
2903
  localDb.resetDreamConsecutiveFailures();
2904
+ const { installDreamLaunchAgent } = await import("./dreamLaunchd.js");
2905
+ const launchdPath = installDreamLaunchAgent();
2387
2906
  localDb.close();
2388
2907
  remoteDb?.close();
2389
2908
  // Final Diff block per the design — provider/machine + the two
@@ -2411,7 +2930,9 @@ export async function runDreamSetup(opts = {}) {
2411
2930
  discoverRelationships,
2412
2931
  }));
2413
2932
  const dreamerName = designate ? localMachine : (designatedMachine ?? "the designated machine");
2414
- 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);
2415
2936
  printStatus("progress", "check status anytime with `gnosys status --system`");
2416
2937
  }
2417
2938
  finally {
@@ -2474,29 +2995,18 @@ async function openRemoteDbIfConfigured(localDb) {
2474
2995
  * Best-effort lookup of the API key for a provider. Used by the dream setup
2475
2996
  * wizard to power the validation step. Mirrors the resolveApiKey precedence.
2476
2997
  */
2477
- export async function getApiKeyForProvider(provider) {
2478
- if (provider === "ollama" || provider === "lmstudio" || provider === "skip")
2998
+ export async function getApiKeyForProvider(provider, opts) {
2999
+ if (provider === "ollama" || provider === "lmstudio" || provider === "skip") {
2479
3000
  return "";
2480
- const envVarName = provider === "custom" ? "GNOSYS_CUSTOM_KEY" : `GNOSYS_${provider.toUpperCase()}_KEY`;
2481
- const legacyVars = {
2482
- anthropic: "ANTHROPIC_API_KEY",
2483
- openai: "OPENAI_API_KEY",
2484
- groq: "GROQ_API_KEY",
2485
- xai: "XAI_API_KEY",
2486
- mistral: "MISTRAL_API_KEY",
2487
- };
2488
- const fromEnv = process.env[envVarName] || (legacyVars[provider] && process.env[legacyVars[provider]]) || "";
2489
- if (fromEnv)
2490
- return fromEnv;
2491
- if (process.platform === "darwin") {
2492
- try {
2493
- return execSync(`security find-generic-password -a "$USER" -s "${envVarName}" -w 2>/dev/null`, {
2494
- stdio: "pipe", encoding: "utf-8", timeout: 2000,
2495
- }).trim();
2496
- }
2497
- catch {
2498
- // 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;
2499
3008
  }
2500
3009
  }
2501
- return "";
3010
+ return readFirstInChain(provider) ?? "";
2502
3011
  }
3012
+ export { getApiKeyForProviderFromConfig } from "./apiKeyVault.js";