gnosys 5.11.4 → 5.12.2

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 (265) hide show
  1. package/dist/cli.js +377 -5162
  2. package/dist/index.js +542 -244
  3. package/dist/lib/addCommand.d.ts +9 -0
  4. package/dist/lib/addCommand.js +102 -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/archive.js +0 -2
  12. package/dist/lib/askCommand.d.ts +13 -0
  13. package/dist/lib/askCommand.js +145 -0
  14. package/dist/lib/attachCommand.d.ts +17 -0
  15. package/dist/lib/attachCommand.js +66 -0
  16. package/dist/lib/attachments.d.ts +43 -2
  17. package/dist/lib/attachments.js +81 -2
  18. package/dist/lib/audioExtract.js +4 -1
  19. package/dist/lib/auditCommand.d.ts +7 -0
  20. package/dist/lib/auditCommand.js +27 -0
  21. package/dist/lib/backupCommand.d.ts +6 -0
  22. package/dist/lib/backupCommand.js +54 -0
  23. package/dist/lib/bootstrapCommand.d.ts +15 -0
  24. package/dist/lib/bootstrapCommand.js +51 -0
  25. package/dist/lib/briefingCommand.d.ts +7 -0
  26. package/dist/lib/briefingCommand.js +92 -0
  27. package/dist/lib/centralizeCommand.d.ts +5 -0
  28. package/dist/lib/centralizeCommand.js +16 -0
  29. package/dist/lib/chat/choose.js +2 -2
  30. package/dist/lib/chatCommand.d.ts +12 -0
  31. package/dist/lib/chatCommand.js +46 -0
  32. package/dist/lib/checkCommand.d.ts +4 -0
  33. package/dist/lib/checkCommand.js +133 -0
  34. package/dist/lib/clientReadOverlay.d.ts +27 -0
  35. package/dist/lib/clientReadOverlay.js +76 -0
  36. package/dist/lib/clientReadResolve.d.ts +32 -0
  37. package/dist/lib/clientReadResolve.js +84 -0
  38. package/dist/lib/commitContextCommand.d.ts +9 -0
  39. package/dist/lib/commitContextCommand.js +142 -0
  40. package/dist/lib/config.d.ts +41 -48
  41. package/dist/lib/config.js +58 -57
  42. package/dist/lib/configCommand.d.ts +10 -0
  43. package/dist/lib/configCommand.js +321 -0
  44. package/dist/lib/connectCommand.d.ts +8 -0
  45. package/dist/lib/connectCommand.js +19 -0
  46. package/dist/lib/db.d.ts +68 -1
  47. package/dist/lib/db.js +385 -120
  48. package/dist/lib/dbWrite.d.ts +1 -1
  49. package/dist/lib/dearchiveCommand.d.ts +7 -0
  50. package/dist/lib/dearchiveCommand.js +41 -0
  51. package/dist/lib/discoverCommand.d.ts +9 -0
  52. package/dist/lib/discoverCommand.js +87 -0
  53. package/dist/lib/doctorCommand.d.ts +6 -0
  54. package/dist/lib/doctorCommand.js +256 -0
  55. package/dist/lib/docxExtract.js +1 -1
  56. package/dist/lib/dream.d.ts +50 -2
  57. package/dist/lib/dream.js +324 -30
  58. package/dist/lib/dreamCommand.d.ts +10 -0
  59. package/dist/lib/dreamCommand.js +195 -0
  60. package/dist/lib/dreamLaunchd.d.ts +2 -0
  61. package/dist/lib/dreamLaunchd.js +72 -0
  62. package/dist/lib/dreamLogCommand.d.ts +10 -0
  63. package/dist/lib/dreamLogCommand.js +58 -0
  64. package/dist/lib/dreamReport.d.ts +7 -0
  65. package/dist/lib/dreamReport.js +114 -0
  66. package/dist/lib/dreamRunLog.d.ts +121 -0
  67. package/dist/lib/dreamRunLog.js +234 -0
  68. package/dist/lib/embeddings.js +3 -3
  69. package/dist/lib/exportCommand.d.ts +18 -0
  70. package/dist/lib/exportCommand.js +101 -0
  71. package/dist/lib/exportProject.d.ts +3 -2
  72. package/dist/lib/exportProject.js +2 -1
  73. package/dist/lib/federated.js +1 -1
  74. package/dist/lib/fsearchCommand.d.ts +8 -0
  75. package/dist/lib/fsearchCommand.js +44 -0
  76. package/dist/lib/graphCommand.d.ts +4 -0
  77. package/dist/lib/graphCommand.js +68 -0
  78. package/dist/lib/helperGenerateCommand.d.ts +5 -0
  79. package/dist/lib/helperGenerateCommand.js +27 -0
  80. package/dist/lib/historyCommand.d.ts +5 -0
  81. package/dist/lib/historyCommand.js +51 -0
  82. package/dist/lib/hybridSearchCommand.d.ts +12 -0
  83. package/dist/lib/hybridSearchCommand.js +95 -0
  84. package/dist/lib/importCommand.d.ts +16 -0
  85. package/dist/lib/importCommand.js +89 -0
  86. package/dist/lib/importProject.js +2 -1
  87. package/dist/lib/importProjectCommand.d.ts +6 -0
  88. package/dist/lib/importProjectCommand.js +43 -0
  89. package/dist/lib/ingestCommand.d.ts +13 -0
  90. package/dist/lib/ingestCommand.js +95 -0
  91. package/dist/lib/installOutput.d.ts +36 -0
  92. package/dist/lib/installOutput.js +55 -0
  93. package/dist/lib/lensCommand.d.ts +20 -0
  94. package/dist/lib/lensCommand.js +61 -0
  95. package/dist/lib/lensing.d.ts +1 -0
  96. package/dist/lib/lensing.js +50 -9
  97. package/dist/lib/linksCommand.d.ts +7 -0
  98. package/dist/lib/linksCommand.js +48 -0
  99. package/dist/lib/listCommand.d.ts +8 -0
  100. package/dist/lib/listCommand.js +74 -0
  101. package/dist/lib/llm.d.ts +1 -1
  102. package/dist/lib/llm.js +27 -9
  103. package/dist/lib/localDiskCheck.d.ts +17 -0
  104. package/dist/lib/localDiskCheck.js +54 -0
  105. package/dist/lib/lock.d.ts +1 -1
  106. package/dist/lib/lock.js +5 -3
  107. package/dist/lib/machineConfig.d.ts +11 -1
  108. package/dist/lib/machineConfig.js +16 -0
  109. package/dist/lib/machineRegistry.d.ts +61 -0
  110. package/dist/lib/machineRegistry.js +80 -0
  111. package/dist/lib/maintainCommand.d.ts +8 -0
  112. package/dist/lib/maintainCommand.js +34 -0
  113. package/dist/lib/masterLease.d.ts +20 -0
  114. package/dist/lib/masterLease.js +68 -0
  115. package/dist/lib/migrate.js +0 -1
  116. package/dist/lib/migrateCommand.d.ts +7 -0
  117. package/dist/lib/migrateCommand.js +158 -0
  118. package/dist/lib/migrateDbCommand.d.ts +9 -0
  119. package/dist/lib/migrateDbCommand.js +94 -0
  120. package/dist/lib/modelValidation.d.ts +5 -0
  121. package/dist/lib/modelValidation.js +27 -0
  122. package/dist/lib/multimodalIngest.js +1 -1
  123. package/dist/lib/openrouterTiers.d.ts +29 -0
  124. package/dist/lib/openrouterTiers.js +113 -0
  125. package/dist/lib/platform.d.ts +0 -6
  126. package/dist/lib/platform.js +0 -28
  127. package/dist/lib/prefCommand.d.ts +10 -0
  128. package/dist/lib/prefCommand.js +118 -0
  129. package/dist/lib/projectsCommand.d.ts +8 -0
  130. package/dist/lib/projectsCommand.js +131 -0
  131. package/dist/lib/readCommand.d.ts +7 -0
  132. package/dist/lib/readCommand.js +63 -0
  133. package/dist/lib/recall.d.ts +3 -0
  134. package/dist/lib/recall.js +19 -4
  135. package/dist/lib/recallCommand.d.ts +11 -0
  136. package/dist/lib/recallCommand.js +112 -0
  137. package/dist/lib/reflectCommand.d.ts +8 -0
  138. package/dist/lib/reflectCommand.js +61 -0
  139. package/dist/lib/reindexCommand.d.ts +4 -0
  140. package/dist/lib/reindexCommand.js +34 -0
  141. package/dist/lib/reindexGraphCommand.d.ts +4 -0
  142. package/dist/lib/reindexGraphCommand.js +12 -0
  143. package/dist/lib/reinforceCommand.d.ts +8 -0
  144. package/dist/lib/reinforceCommand.js +40 -0
  145. package/dist/lib/remote.d.ts +5 -1
  146. package/dist/lib/remote.js +5 -1
  147. package/dist/lib/remoteWizard.d.ts +24 -5
  148. package/dist/lib/remoteWizard.js +308 -319
  149. package/dist/lib/restoreCommand.d.ts +5 -0
  150. package/dist/lib/restoreCommand.js +35 -0
  151. package/dist/lib/rulesGen.d.ts +8 -0
  152. package/dist/lib/rulesGen.js +16 -0
  153. package/dist/lib/sandboxStartCommand.d.ts +6 -0
  154. package/dist/lib/sandboxStartCommand.js +25 -0
  155. package/dist/lib/sandboxStatusCommand.d.ts +4 -0
  156. package/dist/lib/sandboxStatusCommand.js +24 -0
  157. package/dist/lib/sandboxStopCommand.d.ts +4 -0
  158. package/dist/lib/sandboxStopCommand.js +21 -0
  159. package/dist/lib/search.d.ts +0 -2
  160. package/dist/lib/search.js +0 -7
  161. package/dist/lib/searchCommand.d.ts +9 -0
  162. package/dist/lib/searchCommand.js +90 -0
  163. package/dist/lib/semanticSearchCommand.d.ts +8 -0
  164. package/dist/lib/semanticSearchCommand.js +52 -0
  165. package/dist/lib/setup/configSetRender.js +2 -0
  166. package/dist/lib/setup/providerGlyphs.d.ts +19 -0
  167. package/dist/lib/setup/providerGlyphs.js +42 -0
  168. package/dist/lib/setup/remoteRender.d.ts +31 -1
  169. package/dist/lib/setup/remoteRender.js +95 -4
  170. package/dist/lib/setup/sections/providers.d.ts +17 -0
  171. package/dist/lib/setup/sections/providers.js +307 -0
  172. package/dist/lib/setup/sections/routing.d.ts +2 -6
  173. package/dist/lib/setup/sections/routing.js +67 -82
  174. package/dist/lib/setup/sections/taskRoutingEditor.d.ts +13 -0
  175. package/dist/lib/setup/sections/taskRoutingEditor.js +139 -0
  176. package/dist/lib/setup/summary.d.ts +9 -0
  177. package/dist/lib/setup/summary.js +51 -37
  178. package/dist/lib/setup/ui/header.js +0 -1
  179. package/dist/lib/setup.d.ts +105 -15
  180. package/dist/lib/setup.js +747 -287
  181. package/dist/lib/setupKeys.d.ts +42 -0
  182. package/dist/lib/setupKeys.js +564 -0
  183. package/dist/lib/setupRemoteCommand.d.ts +4 -0
  184. package/dist/lib/setupRemoteCommand.js +28 -0
  185. package/dist/lib/setupRemotePullCommand.d.ts +5 -0
  186. package/dist/lib/setupRemotePullCommand.js +52 -0
  187. package/dist/lib/setupRemotePushCommand.d.ts +5 -0
  188. package/dist/lib/setupRemotePushCommand.js +57 -0
  189. package/dist/lib/setupRemoteResolveCommand.d.ts +4 -0
  190. package/dist/lib/setupRemoteResolveCommand.js +48 -0
  191. package/dist/lib/setupRemoteStatusCommand.d.ts +4 -0
  192. package/dist/lib/setupRemoteStatusCommand.js +73 -0
  193. package/dist/lib/setupRemoteSyncCommand.d.ts +6 -0
  194. package/dist/lib/setupRemoteSyncCommand.js +65 -0
  195. package/dist/lib/setupSyncProjectsCommand.d.ts +4 -0
  196. package/dist/lib/setupSyncProjectsCommand.js +292 -0
  197. package/dist/lib/staleCommand.d.ts +8 -0
  198. package/dist/lib/staleCommand.js +34 -0
  199. package/dist/lib/statsCommand.d.ts +6 -0
  200. package/dist/lib/statsCommand.js +142 -0
  201. package/dist/lib/statusCommand.d.ts +18 -0
  202. package/dist/lib/statusCommand.js +250 -0
  203. package/dist/lib/storesCommand.d.ts +2 -0
  204. package/dist/lib/storesCommand.js +4 -0
  205. package/dist/lib/syncClient.d.ts +41 -0
  206. package/dist/lib/syncClient.js +234 -0
  207. package/dist/lib/syncCommand.d.ts +6 -0
  208. package/dist/lib/syncCommand.js +57 -0
  209. package/dist/lib/syncDoctorCommand.d.ts +5 -0
  210. package/dist/lib/syncDoctorCommand.js +100 -0
  211. package/dist/lib/syncIngest.d.ts +30 -0
  212. package/dist/lib/syncIngest.js +175 -0
  213. package/dist/lib/syncIngestLaunchd.d.ts +8 -0
  214. package/dist/lib/syncIngestLaunchd.js +93 -0
  215. package/dist/lib/syncIngestStartup.d.ts +5 -0
  216. package/dist/lib/syncIngestStartup.js +29 -0
  217. package/dist/lib/syncIngestSystemd.d.ts +10 -0
  218. package/dist/lib/syncIngestSystemd.js +97 -0
  219. package/dist/lib/syncIngestTimer.d.ts +8 -0
  220. package/dist/lib/syncIngestTimer.js +27 -0
  221. package/dist/lib/syncIngestTimerCommand.d.ts +7 -0
  222. package/dist/lib/syncIngestTimerCommand.js +83 -0
  223. package/dist/lib/syncLock.d.ts +6 -0
  224. package/dist/lib/syncLock.js +74 -0
  225. package/dist/lib/syncSnapshot.d.ts +32 -0
  226. package/dist/lib/syncSnapshot.js +188 -0
  227. package/dist/lib/syncStaging.d.ts +79 -0
  228. package/dist/lib/syncStaging.js +237 -0
  229. package/dist/lib/tagsAddCommand.d.ts +8 -0
  230. package/dist/lib/tagsAddCommand.js +18 -0
  231. package/dist/lib/tagsCommand.d.ts +4 -0
  232. package/dist/lib/tagsCommand.js +16 -0
  233. package/dist/lib/timelineCommand.d.ts +7 -0
  234. package/dist/lib/timelineCommand.js +49 -0
  235. package/dist/lib/traceCommand.d.ts +6 -0
  236. package/dist/lib/traceCommand.js +39 -0
  237. package/dist/lib/traverseCommand.d.ts +6 -0
  238. package/dist/lib/traverseCommand.js +58 -0
  239. package/dist/lib/updateCommand.d.ts +13 -0
  240. package/dist/lib/updateCommand.js +67 -0
  241. package/dist/lib/updateStatusCommand.d.ts +5 -0
  242. package/dist/lib/updateStatusCommand.js +38 -0
  243. package/dist/lib/webAddCommand.d.ts +8 -0
  244. package/dist/lib/webAddCommand.js +55 -0
  245. package/dist/lib/webBuildCommand.d.ts +10 -0
  246. package/dist/lib/webBuildCommand.js +65 -0
  247. package/dist/lib/webBuildIndexCommand.d.ts +8 -0
  248. package/dist/lib/webBuildIndexCommand.js +37 -0
  249. package/dist/lib/webIndex.js +0 -1
  250. package/dist/lib/webIngestCommand.d.ts +11 -0
  251. package/dist/lib/webIngestCommand.js +51 -0
  252. package/dist/lib/webInitCommand.d.ts +9 -0
  253. package/dist/lib/webInitCommand.js +167 -0
  254. package/dist/lib/webRemoveCommand.d.ts +5 -0
  255. package/dist/lib/webRemoveCommand.js +41 -0
  256. package/dist/lib/webStatusCommand.d.ts +5 -0
  257. package/dist/lib/webStatusCommand.js +94 -0
  258. package/dist/lib/webUpdateCommand.d.ts +7 -0
  259. package/dist/lib/webUpdateCommand.js +72 -0
  260. package/dist/lib/workingSetCommand.d.ts +6 -0
  261. package/dist/lib/workingSetCommand.js +37 -0
  262. package/dist/sandbox/client.js +1 -1
  263. package/dist/sandbox/manager.js +1 -14
  264. package/dist/sandbox/server.js +3 -5
  265. package/package.json +6 -2
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 });
@@ -265,16 +271,6 @@ async function fetchDynamicModels() {
265
271
  return {};
266
272
  }
267
273
  }
268
- /**
269
- * Get model tiers for a provider — tries dynamic first, falls back to hardcoded.
270
- */
271
- async function getModelTiers(provider) {
272
- const dynamic = await fetchDynamicModels();
273
- if (dynamic[provider] && dynamic[provider].length > 0) {
274
- return dynamic[provider];
275
- }
276
- return PROVIDER_TIERS[provider] ?? [];
277
- }
278
274
  // ─── Provider display names and env var mapping ─────────────────────────────
279
275
  const PROVIDER_DISPLAY = {
280
276
  anthropic: "Anthropic (Claude)",
@@ -284,6 +280,7 @@ const PROVIDER_DISPLAY = {
284
280
  xai: "xAI (Grok)",
285
281
  mistral: "Mistral",
286
282
  lmstudio: "LM Studio (local, free)",
283
+ openrouter: "OpenRouter (free + paid models)",
287
284
  custom: "Custom (any OpenAI-compatible API)",
288
285
  };
289
286
  const PROVIDER_ENV_VAR = {
@@ -292,6 +289,7 @@ const PROVIDER_ENV_VAR = {
292
289
  groq: "GNOSYS_GROQ_KEY",
293
290
  xai: "GNOSYS_XAI_KEY",
294
291
  mistral: "GNOSYS_MISTRAL_KEY",
292
+ openrouter: "GNOSYS_OPENROUTER_KEY",
295
293
  custom: "GNOSYS_CUSTOM_KEY",
296
294
  };
297
295
  // Ordered list for the menu
@@ -303,16 +301,40 @@ const PROVIDER_ORDER = [
303
301
  "xai",
304
302
  "mistral",
305
303
  "lmstudio",
304
+ "openrouter",
306
305
  "custom",
307
306
  ];
308
307
  // Task descriptions for display
309
- const TASK_DESCRIPTIONS = {
308
+ export const TASK_DESCRIPTIONS = {
310
309
  structuring: "adding memories, tagging",
311
310
  synthesis: "Q&A answers",
311
+ chat: "interactive chat TUI",
312
312
  vision: "images, PDFs",
313
313
  transcription: "audio files",
314
314
  dream: "overnight consolidation",
315
315
  };
316
+ /** Tasks routed via taskModels in gnosys.json */
317
+ const ROUTABLE_TASK_LIST = [
318
+ "structuring",
319
+ "synthesis",
320
+ "vision",
321
+ "transcription",
322
+ "chat",
323
+ ];
324
+ export const ASSIGNABLE_TASK_LIST = [
325
+ ...ROUTABLE_TASK_LIST,
326
+ "dream",
327
+ ];
328
+ export function getAssignableRouting(cfg, task) {
329
+ if (task === "dream") {
330
+ const dreamProvider = (cfg.dream?.provider ?? "ollama");
331
+ return {
332
+ provider: dreamProvider,
333
+ model: cfg.dream?.model ?? getProviderModel(cfg, dreamProvider),
334
+ };
335
+ }
336
+ return resolveTaskModel(cfg, task);
337
+ }
316
338
  // ─── Exported Helpers ───────────────────────────────────────────────────────
317
339
  /**
318
340
  * Returns the cheapest capable model for structuring tasks.
@@ -334,8 +356,10 @@ export function getStructuringModel(provider, chosenModel) {
334
356
  * Creates the directory and file if they don't exist.
335
357
  * Replaces an existing key line if found, otherwise appends.
336
358
  */
337
- export async function writeApiKey(provider, key) {
338
- const envVar = PROVIDER_ENV_VAR[provider];
359
+ export async function writeApiKey(provider, key, opts) {
360
+ const envVar = opts?.scope === "global"
361
+ ? apiKeyServiceName(provider, "global")
362
+ : PROVIDER_ENV_VAR[provider];
339
363
  if (!envVar)
340
364
  return;
341
365
  const configDir = path.join(os.homedir(), ".config", "gnosys");
@@ -418,6 +442,68 @@ function hasSecretTool() {
418
442
  return false;
419
443
  }
420
444
  }
445
+ /**
446
+ * Prompt for a replacement API key after validation fails, storing it in the
447
+ * platform secure store when available (Keychain / GNOME Keyring), else .env.
448
+ */
449
+ async function promptAndStoreProviderApiKey(rl, provider, scope = "provider") {
450
+ if (provider === "ollama" || provider === "lmstudio" || provider === "skip") {
451
+ return null;
452
+ }
453
+ const service = apiKeyServiceName(provider, scope);
454
+ console.log();
455
+ console.log(` ${WARN} Your API key may be expired or invalid.`);
456
+ console.log(` ${DIM}Enter a new key — saved as ${service}${RESET}`);
457
+ console.log();
458
+ const key = await askInput(rl, `Enter your ${provider} API key`);
459
+ if (!key) {
460
+ return null;
461
+ }
462
+ if (storeApiKeySecret(service, key, provider)) {
463
+ const store = process.platform === "darwin" ? "macOS Keychain" : "GNOME Keyring";
464
+ console.log(` ${CHECK} Key saved to ${store} (${maskKey(key)})`);
465
+ return key;
466
+ }
467
+ console.log(` ${CROSS} Failed to write to secure store. Saving to ~/.config/gnosys/.env instead.`);
468
+ await writeApiKey(provider, key);
469
+ console.log(` ${CHECK} Key saved to ~/.config/gnosys/.env (${maskKey(key)})`);
470
+ return key;
471
+ }
472
+ /**
473
+ * Run model validation; on API-key auth failures, offer to rotate the key and
474
+ * retry once before asking whether to save config anyway.
475
+ */
476
+ async function validateModelWithKeyRotation(opts) {
477
+ const { Spinner } = await import("./setup/ui/spinner.js");
478
+ let apiKey = opts.apiKey;
479
+ const validateSpin = Spinner(`validating ${opts.provider} / ${opts.model}…`);
480
+ let result = await validateModel(opts.provider, opts.model, apiKey, {
481
+ customBaseUrl: opts.customBaseUrl,
482
+ });
483
+ if (result.ok) {
484
+ validateSpin.ok("model validated", `${result.latencyMs} ms · ${opts.provider} / ${opts.model}`);
485
+ return { proceed: true, apiKey };
486
+ }
487
+ validateSpin.fail("model test failed", result.error);
488
+ if (!opts.isLocalProvider &&
489
+ isApiKeyValidationError(result.error)) {
490
+ const newKey = await promptAndStoreProviderApiKey(opts.rl, opts.provider, opts.keyScope ?? "global");
491
+ if (newKey) {
492
+ apiKey = newKey;
493
+ const retrySpin = Spinner(`re-validating ${opts.provider} / ${opts.model}…`);
494
+ result = await validateModel(opts.provider, opts.model, apiKey, {
495
+ customBaseUrl: opts.customBaseUrl,
496
+ });
497
+ if (result.ok) {
498
+ retrySpin.ok("model validated", `${result.latencyMs} ms · ${opts.provider} / ${opts.model}`);
499
+ return { proceed: true, apiKey };
500
+ }
501
+ retrySpin.fail("model test failed", result.error);
502
+ }
503
+ }
504
+ const proceed = await askYesNo(opts.rl, opts.saveAnywayPrompt, opts.saveAnywayDefault);
505
+ return { proceed, apiKey };
506
+ }
421
507
  /**
422
508
  * Detect where an existing API key is stored.
423
509
  * Returns description like "macOS Keychain", "env var", ".env file", or empty string.
@@ -579,7 +665,7 @@ export async function detectIDEs(projectDir) {
579
665
  */
580
666
  export function upsertGrokMcpBlock(existing, name, entry) {
581
667
  // Drop mistaken v5.9.4 `[mcp.<name>]` sections so we don't leave dead config.
582
- let content = removeTomlSection(existing, `[mcp.${name}]`);
668
+ const content = removeTomlSection(existing, `[mcp.${name}]`);
583
669
  const sectionHeader = `[mcp_servers.${name}]`;
584
670
  const lines = content.split("\n");
585
671
  const headerIdx = lines.findIndex((line) => line.trim() === sectionHeader);
@@ -683,7 +769,7 @@ export async function setupIDE(ide, projectDir) {
683
769
  // 1. Strip legacy hand-written sections in ~/.codex/config.toml.
684
770
  const userCodexConfig = path.join(os.homedir(), ".codex", "config.toml");
685
771
  try {
686
- let existing = await fs.readFile(userCodexConfig, "utf-8");
772
+ const existing = await fs.readFile(userCodexConfig, "utf-8");
687
773
  const cleaned = removeTomlSection(removeTomlSection(existing, "[mcp.gnosys]"), "[gnosys]");
688
774
  if (cleaned !== existing) {
689
775
  await fs.writeFile(userCodexConfig, cleaned, "utf-8");
@@ -700,7 +786,7 @@ export async function setupIDE(ide, projectDir) {
700
786
  let alreadyCorrect = false;
701
787
  try {
702
788
  const existing = runCli("codex", ["mcp", "get", "gnosys"], { allowFailure: true });
703
- if (existing && existing.includes(gnosysCmd) && !existing.includes(" serve")) {
789
+ if (existing?.includes(gnosysCmd) && !existing.includes(" serve")) {
704
790
  alreadyCorrect = true;
705
791
  }
706
792
  else if (existing) {
@@ -817,16 +903,20 @@ async function askChoice(rl, question, options) {
817
903
  /**
818
904
  * Read a single line of input with an optional default value.
819
905
  */
820
- async function askInput(rl, prompt, opts) {
906
+ export async function askInput(rl, prompt, opts) {
821
907
  const suffix = opts?.default ? ` ${DIM}(${opts.default})${RESET}` : "";
822
908
  const answer = await safeQuestion(rl, `${prompt}${suffix}: `);
823
909
  const trimmed = answer.trim();
824
910
  return trimmed || opts?.default || "";
825
911
  }
912
+ export function printInfo(text, meta) {
913
+ const suffix = meta ? ` ${DIM}${meta}${RESET}` : "";
914
+ console.log(` ${text}${suffix}`);
915
+ }
826
916
  /**
827
917
  * Y/n prompt. Returns true for yes.
828
918
  */
829
- async function askYesNo(rl, question, defaultYes = true) {
919
+ export async function askYesNo(rl, question, defaultYes = true) {
830
920
  const hint = defaultYes ? "Y/n" : "y/N";
831
921
  const answer = await safeQuestion(rl, `${question} [${hint}] `);
832
922
  const trimmed = answer.trim().toLowerCase();
@@ -834,6 +924,78 @@ async function askYesNo(rl, question, defaultYes = true) {
834
924
  return defaultYes;
835
925
  return trimmed === "y" || trimmed === "yes";
836
926
  }
927
+ /**
928
+ * Read a password/API key without echoing it back to the terminal.
929
+ */
930
+ export async function askPassword(rl, prompt) {
931
+ if (!stdin.isTTY || typeof stdin.setRawMode !== "function") {
932
+ return askInput(rl, prompt);
933
+ }
934
+ return new Promise((resolve) => {
935
+ let value = "";
936
+ stdout.write(`${prompt}: `);
937
+ stdin.setRawMode(true);
938
+ stdin.resume();
939
+ const cleanup = () => {
940
+ stdin.setRawMode(false);
941
+ stdin.off("data", onData);
942
+ stdout.write("\n");
943
+ };
944
+ const onData = (chunk) => {
945
+ const input = chunk.toString("utf-8");
946
+ for (const char of input) {
947
+ const code = char.charCodeAt(0);
948
+ if (code === 3) {
949
+ cleanup();
950
+ try {
951
+ rl.close();
952
+ }
953
+ catch {
954
+ // already closed
955
+ }
956
+ process.exit(130);
957
+ }
958
+ if (code === 13 || code === 10) {
959
+ cleanup();
960
+ resolve(value.trim());
961
+ return;
962
+ }
963
+ if (code === 127 || code === 8) {
964
+ value = value.slice(0, -1);
965
+ continue;
966
+ }
967
+ value += char;
968
+ }
969
+ };
970
+ stdin.on("data", onData);
971
+ });
972
+ }
973
+ /** Parse `1,3,5`, `all`, or `none` into 0-based task indices. */
974
+ export function parseCommaSeparatedTaskSelection(input, count) {
975
+ const trimmed = input.trim().toLowerCase();
976
+ if (!trimmed)
977
+ return null;
978
+ if (trimmed === "all" || trimmed === "*") {
979
+ return "all";
980
+ }
981
+ if (trimmed === "none" || trimmed === "-") {
982
+ return "none";
983
+ }
984
+ const indices = [];
985
+ for (const part of trimmed.split(/[,;\s]+/)) {
986
+ const n = parseInt(part.trim(), 10);
987
+ if (Number.isNaN(n) || n < 1 || n > count) {
988
+ return null;
989
+ }
990
+ if (!indices.includes(n - 1)) {
991
+ indices.push(n - 1);
992
+ }
993
+ }
994
+ return indices.length > 0 ? indices : null;
995
+ }
996
+ export function modelForTaskAssignment(task, provider, model) {
997
+ return task === "structuring" ? getStructuringModel(provider, model) : model;
998
+ }
837
999
  /**
838
1000
  * Format a price for display: "$0.80" or "free".
839
1001
  */
@@ -926,7 +1088,7 @@ async function loadExistingConfig(projectDir) {
926
1088
  * Returns the provider name or "skip".
927
1089
  * If currentProvider is given, shows it as the current value.
928
1090
  */
929
- async function pickProvider(rl, dynamicModels, stepLabel, currentProvider) {
1091
+ export async function pickProvider(rl, dynamicModels, stepLabel, currentProvider) {
930
1092
  const currentHint = currentProvider ? ` ${DIM}(current: ${currentProvider})${RESET}` : "";
931
1093
  const providerOptions = PROVIDER_ORDER.map((key) => {
932
1094
  const tiers = dynamicModels[key] ?? PROVIDER_TIERS[key];
@@ -947,7 +1109,7 @@ async function pickProvider(rl, dynamicModels, stepLabel, currentProvider) {
947
1109
  * Returns the model string. Includes a "Custom (enter model name)"
948
1110
  * option so users can type any model ID not in the curated list.
949
1111
  */
950
- async function pickModel(rl, provider, dynamicModels, stepLabel, currentModel) {
1112
+ export async function pickModel(rl, provider, dynamicModels, stepLabel, currentModel) {
951
1113
  const tiers = dynamicModels[provider] ?? PROVIDER_TIERS[provider];
952
1114
  if (!tiers || tiers.length === 0) {
953
1115
  // No tiers available — fall back to direct entry
@@ -971,8 +1133,10 @@ async function pickModel(rl, provider, dynamicModels, stepLabel, currentModel) {
971
1133
  }
972
1134
  return tiers[tierIndex].model;
973
1135
  }
974
- // ─── Main Setup Wizard ──────────────────────────────────────────────────────
975
1136
  export async function runSetup(opts) {
1137
+ if (opts.section === "keys") {
1138
+ return runKeysSetup();
1139
+ }
976
1140
  const version = getVersion();
977
1141
  const projectDir = opts.directory ? path.resolve(opts.directory) : process.cwd();
978
1142
  // ─── Non-interactive mode ─────────────────────────────────────────────
@@ -1199,6 +1363,7 @@ export async function runSetup(opts) {
1199
1363
  groq: "GROQ_API_KEY",
1200
1364
  xai: "XAI_API_KEY",
1201
1365
  mistral: "MISTRAL_API_KEY",
1366
+ openrouter: "OPENROUTER_API_KEY",
1202
1367
  };
1203
1368
  const legacyEnvVar = legacyEnvVars[provider] ?? "";
1204
1369
  if (needsKey) {
@@ -1264,8 +1429,7 @@ export async function runSetup(opts) {
1264
1429
  const gnomeIdx = hasSecret ? idx++ : -1;
1265
1430
  const envIdx = idx++;
1266
1431
  const dotenvIdx = idx++;
1267
- const skipIdx = idx++;
1268
- // skipIdx is unused as a variable but documents the last index
1432
+ idx++; // skip entry consumes the last index (value itself unused)
1269
1433
  if (keyChoice === keychainIdx) {
1270
1434
  // macOS Keychain — reuse existing key if available, otherwise ask
1271
1435
  console.log();
@@ -1373,26 +1537,27 @@ export async function runSetup(opts) {
1373
1537
  console.log();
1374
1538
  console.log(`${DIM}Testing ${provider}/${model}...${RESET}`);
1375
1539
  try {
1376
- const { validateModel } = await import("./modelValidation.js");
1377
1540
  const customBaseUrl = provider === "custom"
1378
1541
  ? process.env.GNOSYS_LLM_BASE_URL
1379
1542
  : 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
- }
1543
+ const { proceed } = await validateModelWithKeyRotation({
1544
+ rl,
1545
+ provider,
1546
+ model,
1547
+ apiKey: capturedApiKey,
1548
+ customBaseUrl,
1549
+ isLocalProvider,
1550
+ saveAnywayPrompt: " Continue anyway?",
1551
+ saveAnywayDefault: true,
1552
+ });
1553
+ if (!proceed) {
1554
+ console.log(` ${DIM}Setup paused. Re-run when ready: gnosys setup${RESET}`);
1555
+ setupCompleted = true;
1556
+ rl.close();
1557
+ return {
1558
+ provider, model, structuringModel: "",
1559
+ apiKeyWritten, ides: [], mode: "agent", upgraded,
1560
+ };
1396
1561
  }
1397
1562
  }
1398
1563
  catch (err) {
@@ -1671,6 +1836,18 @@ export async function runSetup(opts) {
1671
1836
  await updateConfig(storePath, configUpdates);
1672
1837
  console.log();
1673
1838
  console.log(` ${CHECK} Config written to ${storePath}/gnosys.json`);
1839
+ const savedCfg = await loadConfig(storePath);
1840
+ const keyReqs = buildApiKeyRequirementsFromConfig(savedCfg);
1841
+ if (keyReqs.length > 0) {
1842
+ await ensureApiKeys(rl, keyReqs, askInput, {
1843
+ warn: WARN,
1844
+ check: CHECK,
1845
+ cross: CROSS,
1846
+ dim: DIM,
1847
+ reset: RESET,
1848
+ maskKey,
1849
+ });
1850
+ }
1674
1851
  }
1675
1852
  catch (err) {
1676
1853
  const msg = err instanceof Error ? err.message : String(err);
@@ -1787,58 +1964,163 @@ export async function runSetup(opts) {
1787
1964
  }
1788
1965
  }
1789
1966
  /**
1790
- * Update ONLY `llm.defaultProvider` in gnosys.json. Used by the summary
1791
- * panel row 1 ("provider") so it stops dragging the user into the full
1792
- * model picker — that's row 2's job.
1793
- *
1794
- * v5.9.4 Bug 4 — before this split, both summary rows routed through
1795
- * `runModelsSetup`, leaving no way to swap provider without also choosing
1796
- * a new model. Now row 1 picks a provider, row 2 picks a model.
1967
+ * Build API key requirements from the selected task set only (§3.7).
1968
+ * One global key per distinct cloud provider in the selection.
1797
1969
  */
1798
- export async function runProviderOnlySetup(opts = {}) {
1799
- const projectDir = opts.directory ? path.resolve(opts.directory) : process.cwd();
1800
- const ownsRl = !opts.rl;
1801
- const rl = opts.rl ?? createInterface({ input: stdin, output: stdout });
1970
+ export function buildInlineKeyRequirements(selectedTasks, providerForTask) {
1971
+ const providers = [
1972
+ ...new Set(selectedTasks
1973
+ .map((t) => providerForTask(t))
1974
+ .filter((p) => providerNeedsApiKey(p))
1975
+ .map((p) => p)),
1976
+ ];
1977
+ return providers.map((provider) => ({ provider, scope: "global" }));
1978
+ }
1979
+ /** Write or replace a single `NAME=value` line in ~/.config/gnosys/.env. */
1980
+ export async function writeServiceKeyToEnv(service, key) {
1981
+ const configDir = path.join(os.homedir(), ".config", "gnosys");
1982
+ await fs.mkdir(configDir, { recursive: true, mode: 0o700 });
1983
+ await fs.chmod(configDir, 0o700);
1984
+ const envPath = path.join(configDir, ".env");
1985
+ let lines = [];
1802
1986
  try {
1803
- const { Header } = await import("./setup/ui/header.js");
1804
- const { Title } = await import("./setup/ui/title.js");
1805
- const { Spinner } = await import("./setup/ui/spinner.js");
1806
- const { printStatus } = await import("./setup/ui/status.js");
1807
- console.log();
1808
- console.log(Header(["gnosys", "setup", "provider"]));
1809
- console.log();
1810
- console.log(Title("Default provider", "pick the LLM provider — model stays as configured"));
1811
- console.log();
1812
- const existingConfig = await loadExistingConfig(projectDir);
1813
- const currentProvider = existingConfig?.llm.defaultProvider;
1814
- const pricingSpin = Spinner("fetching latest pricing from openrouter…");
1815
- const fetchStart = Date.now();
1816
- const dynamicModels = await fetchDynamicModels();
1817
- const fetchMs = Date.now() - fetchStart;
1818
- if (Object.keys(dynamicModels).length > 0) {
1819
- pricingSpin.ok("pricing loaded", `${fetchMs} ms`);
1820
- }
1821
- else {
1822
- pricingSpin.fail("pricing fetch failed", "using bundled tiers");
1987
+ lines = (await fs.readFile(envPath, "utf-8")).split("\n");
1988
+ }
1989
+ catch {
1990
+ // fresh file
1991
+ }
1992
+ const prefix = `${service}=`;
1993
+ let replaced = false;
1994
+ lines = lines.map((line) => {
1995
+ if (line.startsWith(prefix)) {
1996
+ replaced = true;
1997
+ return `${service}=${key}`;
1823
1998
  }
1824
- console.log();
1825
- const provider = await pickProvider(rl, dynamicModels, "Choose your LLM provider", currentProvider);
1826
- if (!provider || provider === currentProvider) {
1827
- printStatus("warn", "no change · provider unchanged");
1828
- return;
1999
+ return line;
2000
+ });
2001
+ if (!replaced) {
2002
+ if (lines.length > 0 && lines[lines.length - 1] !== "") {
2003
+ lines.push("");
1829
2004
  }
1830
- const storePath = ensureActiveStorePath(projectDir);
1831
- const existingLlm = existingConfig?.llm ?? {};
1832
- await updateConfig(storePath, {
1833
- llm: { ...existingLlm, defaultProvider: provider },
2005
+ lines.push(`${service}=${key}`);
2006
+ }
2007
+ await fs.writeFile(envPath, lines.join("\n"), { mode: 0o600 });
2008
+ await fs.chmod(envPath, 0o600);
2009
+ }
2010
+ /**
2011
+ * Validate a provider/model/key triple without persisting the key (§3.8).
2012
+ * On auth failure, re-prompt for a replacement key in memory only.
2013
+ */
2014
+ export async function validateTaskCombo(opts) {
2015
+ const { Spinner } = await import("./setup/ui/spinner.js");
2016
+ const reprompt = opts.repromptKey ??
2017
+ (async (rl, provider) => {
2018
+ console.log();
2019
+ console.log(` ${WARN} Your API key may be expired or invalid.`);
2020
+ const key = await askInput(rl, `Enter your ${provider} API key`);
2021
+ return key || null;
1834
2022
  });
1835
- printStatus("ok", `default provider · ${provider}`, `${storePath}/gnosys.json`);
1836
- printStatus("progress", "model unchanged", "use row 2 to swap the model");
2023
+ let apiKey = opts.apiKey;
2024
+ const validateSpin = Spinner(`validating ${opts.provider} / ${opts.model}…`);
2025
+ let result = await validateModel(opts.provider, opts.model, apiKey, {
2026
+ customBaseUrl: opts.customBaseUrl,
2027
+ });
2028
+ if (result.ok) {
2029
+ validateSpin.ok("model validated", `${result.latencyMs} ms · ${opts.provider} / ${opts.model}`);
2030
+ return { proceed: true, apiKey };
2031
+ }
2032
+ validateSpin.fail("model test failed", result.error);
2033
+ if (!opts.isLocalProvider && isApiKeyValidationError(result.error)) {
2034
+ const newKey = await reprompt(opts.rl, opts.provider);
2035
+ if (newKey) {
2036
+ apiKey = newKey;
2037
+ const retrySpin = Spinner(`re-validating ${opts.provider} / ${opts.model}…`);
2038
+ result = await validateModel(opts.provider, opts.model, apiKey, {
2039
+ customBaseUrl: opts.customBaseUrl,
2040
+ });
2041
+ if (result.ok) {
2042
+ retrySpin.ok("model validated", `${result.latencyMs} ms · ${opts.provider} / ${opts.model}`);
2043
+ return { proceed: true, apiKey };
2044
+ }
2045
+ retrySpin.fail("model test failed", result.error);
2046
+ }
1837
2047
  }
1838
- finally {
1839
- if (ownsRl)
1840
- rl.close();
2048
+ const proceed = await askYesNo(opts.rl, opts.saveAnywayPrompt ?? "Save routing anyway?", opts.saveAnywayDefault ?? false);
2049
+ return { proceed, apiKey };
2050
+ }
2051
+ const LEGACY_PROVIDER_ENV = {
2052
+ anthropic: "ANTHROPIC_API_KEY",
2053
+ openai: "OPENAI_API_KEY",
2054
+ groq: "GROQ_API_KEY",
2055
+ xai: "XAI_API_KEY",
2056
+ mistral: "MISTRAL_API_KEY",
2057
+ openrouter: "OPENROUTER_API_KEY",
2058
+ };
2059
+ /** Ask where to store a validated key; persist or print env-var hints (§3.10). */
2060
+ export async function promptKeyDestinationAndPersist(opts) {
2061
+ const isMac = process.platform === "darwin";
2062
+ const isLinux = process.platform === "linux";
2063
+ const hasSecret = isLinux && hasSecretTool();
2064
+ const options = [];
2065
+ if (isMac) {
2066
+ options.push("OS secure store (macOS Keychain) — recommended");
2067
+ }
2068
+ else if (hasSecret) {
2069
+ options.push("OS secure store (GNOME Keyring) — recommended");
2070
+ }
2071
+ else {
2072
+ options.push("OS secure store — recommended");
2073
+ }
2074
+ options.push("Config file (~/.config/gnosys/.env)");
2075
+ options.push("Don't store — I'll set it as an environment variable myself");
2076
+ const choice = opts.destinationChoice ??
2077
+ (await askChoice(opts.rl, "Where should I store this key?", options));
2078
+ const secureIdx = 0;
2079
+ const dotenvIdx = 1;
2080
+ if (choice === secureIdx) {
2081
+ if (storeApiKeySecret(opts.service, opts.key, opts.provider)) {
2082
+ const store = process.platform === "darwin" ? "macOS Keychain" : "GNOME Keyring";
2083
+ console.log(` ${CHECK} Key saved to ${store} (${maskKey(opts.key)})`);
2084
+ return "secure";
2085
+ }
2086
+ console.log(` ${CROSS} Failed to write to secure store. Saving to ~/.config/gnosys/.env instead.`);
2087
+ await writeServiceKeyToEnv(opts.service, opts.key);
2088
+ console.log(` ${CHECK} Key saved to ~/.config/gnosys/.env (${maskKey(opts.key)})`);
2089
+ return "dotenv";
2090
+ }
2091
+ if (choice === dotenvIdx) {
2092
+ await writeServiceKeyToEnv(opts.service, opts.key);
2093
+ console.log(` ${CHECK} Key saved to ~/.config/gnosys/.env (${maskKey(opts.key)})`);
2094
+ return "dotenv";
2095
+ }
2096
+ const legacy = LEGACY_PROVIDER_ENV[opts.provider];
2097
+ const providerSvc = apiKeyServiceName(opts.provider, "provider");
2098
+ console.log();
2099
+ console.log(` ${DIM}Set one of these environment variables:${RESET}`);
2100
+ console.log(` ${GREEN}export ${opts.service}=your-key-here${RESET}`);
2101
+ if (opts.service !== providerSvc) {
2102
+ console.log(` ${DIM}or provider fallback:${RESET} ${GREEN}export ${providerSvc}=…${RESET}`);
2103
+ }
2104
+ if (legacy) {
2105
+ console.log(` ${DIM}or legacy:${RESET} ${GREEN}export ${legacy}=…${RESET}`);
1841
2106
  }
2107
+ return "none";
2108
+ }
2109
+ /** Build taskModels patch — only selected routable tasks that changed. */
2110
+ export function buildTaskModelsPatchFromAccepted(accepted, currentByTask, selectedSet) {
2111
+ const patch = {};
2112
+ for (const task of ROUTABLE_TASK_LIST) {
2113
+ if (!selectedSet.has(task))
2114
+ continue;
2115
+ const next = accepted[task];
2116
+ if (!next)
2117
+ continue;
2118
+ const cur = currentByTask[task];
2119
+ if (next.provider !== cur.provider || next.model !== cur.model) {
2120
+ patch[task] = next;
2121
+ }
2122
+ }
2123
+ return Object.keys(patch).length > 0 ? patch : undefined;
1842
2124
  }
1843
2125
  /**
1844
2126
  * Models-only configuration — prompts for provider, model, and key (or accepts
@@ -1850,7 +2132,6 @@ export async function runModelsSetup(opts = {}) {
1850
2132
  const ownsRl = !opts.rl;
1851
2133
  const rl = opts.rl ?? createInterface({ input: stdin, output: stdout });
1852
2134
  try {
1853
- // v5.9.3 Screen 3 — Header + Title at the top.
1854
2135
  const { Header } = await import("./setup/ui/header.js");
1855
2136
  const { Title } = await import("./setup/ui/title.js");
1856
2137
  const { Spinner } = await import("./setup/ui/spinner.js");
@@ -1859,16 +2140,13 @@ export async function runModelsSetup(opts = {}) {
1859
2140
  console.log();
1860
2141
  console.log(Header(["gnosys", "setup", "models"]));
1861
2142
  console.log();
1862
- console.log(Title("Model configuration", "pick a provider and model we'll validate it before saving"));
2143
+ console.log(Title("Model configuration", "pick a provider — then default or per-task routing"));
1863
2144
  console.log();
1864
2145
  const existingConfig = await loadExistingConfig(projectDir);
1865
2146
  const currentProvider = existingConfig?.llm.defaultProvider;
1866
2147
  const currentModel = existingConfig
1867
2148
  ? getProviderModel(existingConfig, existingConfig.llm.defaultProvider)
1868
2149
  : 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
2150
  const pricingSpin = Spinner("fetching latest pricing from openrouter…");
1873
2151
  const fetchStart = Date.now();
1874
2152
  const dynamicModels = await fetchDynamicModels();
@@ -1878,8 +2156,6 @@ export async function runModelsSetup(opts = {}) {
1878
2156
  pricingSpin.ok(`pricing loaded · ${modelCount} models cached`, `${fetchMs} ms`);
1879
2157
  }
1880
2158
  else {
1881
- // No-op fallback (cache miss + network fail) — keep the hardcoded
1882
- // tiers but signal that we're running offline.
1883
2159
  pricingSpin.fail("pricing fetch failed", "using bundled tiers");
1884
2160
  }
1885
2161
  console.log();
@@ -1895,181 +2171,339 @@ export async function runModelsSetup(opts = {}) {
1895
2171
  else {
1896
2172
  provider = await pickProvider(rl, dynamicModels, "Choose your LLM provider", currentProvider);
1897
2173
  }
1898
- // Step 2: model (or use --model flag)
1899
- let model;
1900
- if (opts.model) {
1901
- model = opts.model;
1902
- printStatus("ok", "model", model);
1903
- }
1904
- 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
- }
1913
- }
1914
- if (!model) {
1915
- printStatus("fail", "no model selected · aborting");
2174
+ const storePath = ensureActiveStorePath(projectDir);
2175
+ const cfgBefore = existingConfig ?? (await loadConfig(storePath));
2176
+ const nonInteractiveDefault = !!(opts.provider || opts.model);
2177
+ if (nonInteractiveDefault) {
2178
+ await runModelsDefaultOnlySetup({
2179
+ rl,
2180
+ opts,
2181
+ provider,
2182
+ dynamicModels,
2183
+ storePath,
2184
+ existingConfig,
2185
+ currentProvider,
2186
+ currentModel,
2187
+ printDiff,
2188
+ printStatus,
2189
+ });
1916
2190
  return;
1917
2191
  }
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
- }
1937
- }
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
1941
- }
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)) {
1947
- 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}`);
1955
- }
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;
1962
- }
1963
- }
2192
+ console.log();
2193
+ const intentChoice = await askChoice(rl, "What do you want to configure?", [
2194
+ "Default only — set default provider and model",
2195
+ "Assign to specific tasks — route selected tasks only (does not change default)",
2196
+ ]);
2197
+ if (intentChoice === 0) {
2198
+ await runModelsDefaultOnlySetup({
2199
+ rl,
2200
+ opts,
2201
+ provider,
2202
+ dynamicModels,
2203
+ storePath,
2204
+ existingConfig,
2205
+ currentProvider,
2206
+ currentModel,
2207
+ printDiff,
2208
+ printStatus,
2209
+ });
2210
+ return;
1964
2211
  }
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
- },
2212
+ await runModelsTaskRoutingSetup({
2213
+ rl,
2214
+ opts,
2215
+ provider,
2216
+ dynamicModels,
2217
+ storePath,
2218
+ cfgBefore,
2219
+ printStatus,
1983
2220
  });
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
2221
  }
1991
2222
  finally {
1992
2223
  if (ownsRl)
1993
2224
  rl.close();
1994
2225
  }
1995
2226
  }
1996
- /**
1997
- * Lightweight model-management command. Supports three operations:
1998
- * --list: print available models for the current provider
1999
- * --refresh: clear the OpenRouter cache and re-fetch
2000
- * --set X: update the default model in gnosys.json (no prompts)
2001
- */
2002
- async function runModelsCommand(opts = {}) {
2003
- const projectDir = opts.directory ? path.resolve(opts.directory) : process.cwd();
2004
- const existingConfig = await loadExistingConfig(projectDir);
2005
- const currentProvider = existingConfig?.llm.defaultProvider;
2006
- if (opts.refresh) {
2007
- const cacheFile = path.join(os.homedir(), ".config", "gnosys", "models-cache.json");
2008
- try {
2009
- await fs.unlink(cacheFile);
2010
- console.log(`${CHECK} Cache cleared.`);
2227
+ async function resolveKeyInMemory(opts) {
2228
+ if (!providerNeedsApiKey(opts.provider))
2229
+ return "";
2230
+ const cached = opts.keyCache.get(opts.provider);
2231
+ if (cached)
2232
+ return cached;
2233
+ const stored = readStoredSecret(apiKeyServiceName(opts.provider, "global"));
2234
+ if (stored) {
2235
+ opts.keyCache.set(opts.provider, stored);
2236
+ return stored;
2237
+ }
2238
+ const key = await askInput(opts.rl, `API key for ${opts.provider} (shared across selected tasks)`);
2239
+ opts.keyCache.set(opts.provider, key);
2240
+ return key;
2241
+ }
2242
+ async function runModelsDefaultOnlySetup(ctx) {
2243
+ const { rl, opts, provider, dynamicModels, storePath, printDiff, printStatus } = ctx;
2244
+ const isLocalProvider = provider === "ollama" || provider === "lmstudio";
2245
+ let model;
2246
+ if (opts.model) {
2247
+ model = opts.model;
2248
+ printStatus("ok", "model", model);
2249
+ }
2250
+ else {
2251
+ const tiers = dynamicModels[provider] ?? PROVIDER_TIERS[provider];
2252
+ if (provider === "custom" || !tiers || tiers.length === 0) {
2253
+ model = await askInput(rl, "Model name");
2011
2254
  }
2012
- catch {
2013
- console.log(`${DIM}No cache to clear.${RESET}`);
2255
+ else {
2256
+ const showCurrent = ctx.currentProvider === provider ? ctx.currentModel : undefined;
2257
+ model = await pickModel(rl, provider, dynamicModels, "Choose model", showCurrent);
2014
2258
  }
2015
2259
  }
2016
- if (opts.list) {
2017
- if (!currentProvider) {
2018
- console.log(`${WARN} No provider configured. Run 'gnosys setup' first.`);
2019
- return;
2020
- }
2260
+ if (!model) {
2261
+ printStatus("fail", "no model selected · aborting");
2262
+ return;
2263
+ }
2264
+ let apiKey = readFirstInChain(provider) ?? "";
2265
+ let keyEnteredThisRun = false;
2266
+ if (!apiKey && !isLocalProvider) {
2021
2267
  console.log();
2022
- console.log(`${BOLD}Available models for ${currentProvider}:${RESET}`);
2268
+ apiKey = await askInput(rl, `API key for ${provider}`);
2269
+ keyEnteredThisRun = !!apiKey;
2270
+ if (!apiKey) {
2271
+ console.log(`${WARN} No API key found for ${provider}. Validation may be skipped.`);
2272
+ }
2273
+ }
2274
+ const shouldValidate = opts.validate !== false;
2275
+ if (shouldValidate && (apiKey || isLocalProvider)) {
2023
2276
  console.log();
2024
- const dynamicModels = await fetchDynamicModels();
2025
- const tiers = dynamicModels[currentProvider] ?? PROVIDER_TIERS[currentProvider] ?? [];
2026
- if (tiers.length === 0) {
2027
- console.log(` ${DIM}No models in catalog. Try '--refresh' or use a custom model name.${RESET}`);
2277
+ const customBaseUrl = provider === "custom" ? process.env.GNOSYS_LLM_BASE_URL : undefined;
2278
+ const { proceed, apiKey: validatedKey } = await validateModelWithKeyRotation({
2279
+ rl,
2280
+ provider,
2281
+ model,
2282
+ apiKey,
2283
+ customBaseUrl,
2284
+ isLocalProvider,
2285
+ saveAnywayPrompt: "Save config anyway?",
2286
+ saveAnywayDefault: false,
2287
+ keyScope: "provider",
2288
+ });
2289
+ apiKey = validatedKey;
2290
+ if (!proceed) {
2291
+ printStatus("warn", "cancelled · no changes written");
2028
2292
  return;
2029
2293
  }
2030
- for (const t of tiers) {
2031
- const rec = t.recommended ? ` ${CYAN}<- recommended${RESET}` : "";
2032
- const price = t.input === 0 && t.output === 0
2033
- ? "free"
2034
- : `$${t.input.toFixed(2)}–$${t.output.toFixed(2)}/M`;
2035
- console.log(` ${t.name.padEnd(24)} ${t.model.padEnd(40)} ${DIM}${price}${RESET}${rec}`);
2294
+ if (keyEnteredThisRun && apiKey && !isLocalProvider) {
2295
+ const service = apiKeyServiceName(provider, "provider");
2296
+ await promptKeyDestinationAndPersist({
2297
+ rl,
2298
+ service,
2299
+ provider,
2300
+ key: apiKey,
2301
+ scope: "provider",
2302
+ });
2036
2303
  }
2037
- return;
2038
2304
  }
2039
- if (opts.set) {
2040
- if (!currentProvider) {
2041
- console.log(`${WARN} No provider configured. Run 'gnosys setup' first.`);
2042
- return;
2043
- }
2044
- // v5.9.4 Bug 10 — unified store resolution.
2045
- const storePath = ensureActiveStorePath(projectDir);
2046
- const existingProviderConfig = existingConfig?.llm?.[currentProvider];
2047
- const providerConfigBase = (typeof existingProviderConfig === "object" && existingProviderConfig !== null)
2048
- ? existingProviderConfig
2049
- : {};
2050
- await updateConfig(storePath, {
2051
- llm: {
2052
- ...(existingConfig?.llm ?? {}),
2053
- defaultProvider: currentProvider,
2054
- [currentProvider]: { ...providerConfigBase, model: opts.set },
2305
+ const existingLlm = ctx.existingConfig?.llm;
2306
+ const existingProviderConfig = existingLlm
2307
+ ? existingLlm[provider]
2308
+ : undefined;
2309
+ const providerConfigBase = typeof existingProviderConfig === "object" && existingProviderConfig !== null
2310
+ ? existingProviderConfig
2311
+ : {};
2312
+ await updateConfig(storePath, {
2313
+ llm: {
2314
+ ...(existingLlm ?? {}),
2315
+ defaultProvider: provider,
2316
+ [provider]: {
2317
+ ...providerConfigBase,
2318
+ model,
2055
2319
  },
2056
- });
2057
- console.log(`${CHECK} Default model set to ${GREEN}${opts.set}${RESET} for ${currentProvider}.`);
2058
- return;
2320
+ },
2321
+ });
2322
+ const { buildModelsDiffRows } = await import("./setup/modelsRender.js");
2323
+ console.log();
2324
+ printDiff(buildModelsDiffRows(ctx.currentProvider, ctx.currentModel, provider, model));
2325
+ printStatus("ok", `saved · ${storePath}/gnosys.json`);
2326
+ }
2327
+ async function runModelsTaskRoutingSetup(ctx) {
2328
+ const { rl, opts, provider: initialProvider, dynamicModels, storePath, cfgBefore, printStatus } = ctx;
2329
+ const tasks = ASSIGNABLE_TASK_LIST;
2330
+ const currentByTask = {};
2331
+ for (const task of tasks) {
2332
+ currentByTask[task] = getAssignableRouting(cfgBefore, task);
2333
+ }
2334
+ const dreamEnabledBefore = !!cfgBefore.dream?.enabled;
2335
+ console.log();
2336
+ console.log(`${BOLD}Which tasks should use ${initialProvider}?${RESET}`);
2337
+ console.log(`${DIM}Enter numbers (comma-separated), ${BOLD}all${RESET}${DIM}, or ${BOLD}none${RESET}${DIM}. Unlisted tasks keep their current routing.${RESET}`);
2338
+ console.log();
2339
+ for (let i = 0; i < tasks.length; i++) {
2340
+ const task = tasks[i];
2341
+ const cur = currentByTask[task];
2342
+ const desc = TASK_DESCRIPTIONS[task] ?? "";
2343
+ const off = task === "dream" && !dreamEnabledBefore ? ` ${DIM}[off]${RESET}` : "";
2344
+ console.log(` ${BOLD}${i + 1}.${RESET} ${task.padEnd(14)} ${DIM}now:${RESET} ${cur.provider} / ${cur.model} ${DIM}(${desc})${RESET}${off}`);
2345
+ }
2346
+ console.log();
2347
+ let selectedIndices = [];
2348
+ while (true) {
2349
+ const raw = await askInput(rl, `Tasks for ${initialProvider} (e.g. 5,6 or all)`, { default: "all" });
2350
+ const parsed = parseCommaSeparatedTaskSelection(raw, tasks.length);
2351
+ if (parsed === "all") {
2352
+ selectedIndices = tasks.map((_, i) => i);
2353
+ break;
2354
+ }
2355
+ if (parsed === "none") {
2356
+ selectedIndices = [];
2357
+ break;
2358
+ }
2359
+ if (parsed && parsed.length > 0) {
2360
+ selectedIndices = parsed;
2361
+ break;
2362
+ }
2363
+ console.log(`${RED}Enter numbers 1-${tasks.length}, comma-separated, or 'all'.${RESET}`);
2059
2364
  }
2060
- // No flags: show current config
2061
- if (!currentProvider) {
2062
- console.log(`${WARN} No provider configured. Run 'gnosys setup' first.`);
2365
+ const selectedSet = new Set(selectedIndices.map((i) => tasks[i]));
2366
+ if (selectedSet.size === 0) {
2367
+ printStatus("warn", "no tasks selected · no changes written");
2063
2368
  return;
2064
2369
  }
2065
- const currentModel = existingConfig
2066
- ? getProviderModel(existingConfig, existingConfig.llm.defaultProvider)
2067
- : "";
2370
+ const selectedTasks = [...selectedSet];
2371
+ const keyRequirements = buildInlineKeyRequirements(selectedTasks, () => initialProvider);
2372
+ if (keyRequirements.length > 0) {
2373
+ console.log();
2374
+ console.log(` ${DIM}Keys needed for:${RESET}`);
2375
+ for (const req of keyRequirements) {
2376
+ const svc = apiKeyServiceName(req.provider, req.scope);
2377
+ console.log(` ${DIM} all selected → ${req.provider} (${svc})${RESET}`);
2378
+ }
2379
+ console.log();
2380
+ }
2381
+ const keyCache = new Map();
2382
+ const persistedServices = new Set();
2383
+ const accepted = {};
2384
+ for (const task of selectedTasks) {
2385
+ let taskProvider = initialProvider;
2386
+ const cur = currentByTask[task];
2387
+ while (true) {
2388
+ const isLocal = taskProvider === "ollama" || taskProvider === "lmstudio";
2389
+ const showCurrent = cur.provider === taskProvider ? cur.model : undefined;
2390
+ console.log();
2391
+ const model = await pickModel(rl, taskProvider, dynamicModels, `Model for ${task}`, showCurrent);
2392
+ if (!model) {
2393
+ printStatus("fail", `no model for ${task} · skipping`);
2394
+ accepted[task] = cur;
2395
+ break;
2396
+ }
2397
+ const apiKey = await resolveKeyInMemory({
2398
+ rl,
2399
+ provider: taskProvider,
2400
+ keyCache,
2401
+ });
2402
+ const shouldValidate = opts.validate !== false;
2403
+ let proceed = true;
2404
+ let validatedKey = apiKey;
2405
+ if (shouldValidate && (apiKey || isLocal)) {
2406
+ const customBaseUrl = taskProvider === "custom" ? process.env.GNOSYS_LLM_BASE_URL : undefined;
2407
+ const result = await validateTaskCombo({
2408
+ rl,
2409
+ provider: taskProvider,
2410
+ model,
2411
+ apiKey,
2412
+ customBaseUrl,
2413
+ isLocalProvider: isLocal,
2414
+ saveAnywayPrompt: `Save ${task} routing anyway?`,
2415
+ saveAnywayDefault: false,
2416
+ });
2417
+ proceed = result.proceed;
2418
+ validatedKey = result.apiKey;
2419
+ }
2420
+ if (proceed) {
2421
+ accepted[task] = {
2422
+ provider: taskProvider,
2423
+ model,
2424
+ };
2425
+ if (validatedKey && !isLocal) {
2426
+ const service = apiKeyServiceName(taskProvider, "global");
2427
+ if (!persistedServices.has(service)) {
2428
+ await promptKeyDestinationAndPersist({
2429
+ rl,
2430
+ service,
2431
+ provider: taskProvider,
2432
+ key: validatedKey,
2433
+ scope: "global",
2434
+ });
2435
+ persistedServices.add(service);
2436
+ }
2437
+ keyCache.set(taskProvider, validatedKey);
2438
+ }
2439
+ break;
2440
+ }
2441
+ const retryChoice = await askChoice(rl, "Validation failed. What next?", [
2442
+ "Pick a different provider + model",
2443
+ "Keep provider, pick a new model",
2444
+ "Skip this task (keep current routing)",
2445
+ ]);
2446
+ if (retryChoice === 0) {
2447
+ const picked = await pickProvider(rl, dynamicModels, `Provider for ${task}`, taskProvider);
2448
+ if (picked)
2449
+ taskProvider = picked;
2450
+ continue;
2451
+ }
2452
+ if (retryChoice === 1) {
2453
+ continue;
2454
+ }
2455
+ accepted[task] = cur;
2456
+ break;
2457
+ }
2458
+ }
2459
+ const planned = { ...currentByTask };
2460
+ for (const task of selectedTasks) {
2461
+ if (accepted[task])
2462
+ planned[task] = accepted[task];
2463
+ }
2068
2464
  console.log();
2069
- console.log(`Provider: ${GREEN}${currentProvider}${RESET}`);
2070
- console.log(`Model: ${GREEN}${currentModel}${RESET}`);
2465
+ console.log(`${BOLD}Planned task routing${RESET}`);
2466
+ console.log(` ${"Task".padEnd(16)}${"Provider / model".padEnd(42)}${RESET}`);
2467
+ console.log(` ${"\u2500".repeat(56)}`);
2468
+ for (const task of tasks) {
2469
+ const p = planned[task];
2470
+ const marker = selectedSet.has(task) ? `${CYAN}*${RESET} ` : " ";
2471
+ const off = task === "dream" && !dreamEnabledBefore
2472
+ ? ` ${DIM}(dream off)${RESET}`
2473
+ : "";
2474
+ console.log(`${marker}${task.padEnd(14)}${p.provider} / ${p.model}${off}`);
2475
+ }
2476
+ console.log(`${DIM} * = changed in this run${RESET}`);
2071
2477
  console.log();
2072
- console.log(`${DIM}Use '--list' to see options, '--set <model>' to change, '--refresh' to update catalog.${RESET}`);
2478
+ const confirmed = await askYesNo(rl, "Save this routing?", true);
2479
+ if (!confirmed) {
2480
+ printStatus("warn", "cancelled · no changes written");
2481
+ return;
2482
+ }
2483
+ const taskModelsPatch = buildTaskModelsPatchFromAccepted(accepted, currentByTask, selectedSet);
2484
+ let dreamPatch;
2485
+ if (selectedSet.has("dream") && accepted.dream) {
2486
+ dreamPatch = {
2487
+ ...(cfgBefore.dream ?? {}),
2488
+ provider: accepted.dream.provider,
2489
+ model: accepted.dream.model,
2490
+ };
2491
+ if (!dreamEnabledBefore &&
2492
+ (await askYesNo(rl, "Enable dream mode with this routing?", true))) {
2493
+ dreamPatch.enabled = true;
2494
+ }
2495
+ }
2496
+ const updatePayload = {};
2497
+ if (taskModelsPatch)
2498
+ updatePayload.taskModels = taskModelsPatch;
2499
+ if (dreamPatch)
2500
+ updatePayload.dream = dreamPatch;
2501
+ if (!taskModelsPatch && !dreamPatch) {
2502
+ printStatus("warn", "no routing changes to save");
2503
+ return;
2504
+ }
2505
+ await updateConfig(storePath, updatePayload);
2506
+ printStatus("ok", `saved task routing · ${storePath}/gnosys.json`);
2073
2507
  }
2074
2508
  /**
2075
2509
  * Walks the user through configuring dream mode. Handles:
@@ -2143,8 +2577,12 @@ export async function runDreamSetup(opts = {}) {
2143
2577
  dream: { ...(existingDream ?? {}), enabled: false },
2144
2578
  });
2145
2579
  clearDreamMachineEverywhere();
2580
+ const { uninstallDreamLaunchAgent } = await import("./dreamLaunchd.js");
2581
+ const launchdPath = uninstallDreamLaunchAgent();
2146
2582
  console.log();
2147
2583
  printStatus("ok", "dream mode disabled · designation cleared");
2584
+ if (launchdPath)
2585
+ printStatus("ok", "launchd agent removed", launchdPath);
2148
2586
  localDb.close();
2149
2587
  remoteDb?.close();
2150
2588
  return;
@@ -2201,26 +2639,27 @@ export async function runDreamSetup(opts = {}) {
2201
2639
  }
2202
2640
  // Validate — animated Spinner with the model latency reported.
2203
2641
  if (dreamProvider !== "skip") {
2204
- const apiKey = await getApiKeyForProvider(dreamProvider);
2642
+ const apiKey = await getApiKeyForProvider(dreamProvider, { task: "dream" });
2205
2643
  const isLocalProvider = dreamProvider === "ollama" || dreamProvider === "lmstudio";
2206
2644
  if (apiKey || isLocalProvider) {
2207
2645
  console.log();
2208
- const validateSpin = Spinner(`validating ${dreamProvider} / ${dreamModel}…`);
2209
2646
  try {
2210
2647
  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
- }
2648
+ const { proceed } = await validateModelWithKeyRotation({
2649
+ rl,
2650
+ provider: dreamProvider,
2651
+ model: dreamModel,
2652
+ apiKey,
2653
+ customBaseUrl,
2654
+ isLocalProvider,
2655
+ saveAnywayPrompt: "save config anyway?",
2656
+ saveAnywayDefault: true,
2657
+ });
2658
+ if (!proceed) {
2659
+ printStatus("warn", "setup cancelled · no changes written");
2660
+ localDb.close();
2661
+ remoteDb?.close();
2662
+ return;
2224
2663
  }
2225
2664
  }
2226
2665
  catch (err) {
@@ -2228,7 +2667,7 @@ export async function runDreamSetup(opts = {}) {
2228
2667
  }
2229
2668
  }
2230
2669
  else {
2231
- printStatus("warn", `no API key for ${dreamProvider}`, "set one via `gnosys setup models`");
2670
+ printStatus("warn", `no API key for ${dreamProvider}`, "set one via `gnosys setup providers`");
2232
2671
  }
2233
2672
  }
2234
2673
  // ─── 7.2 Thresholds + sub-tasks ───────────────────────────────────
@@ -2241,6 +2680,12 @@ export async function runDreamSetup(opts = {}) {
2241
2680
  const dIdle = existingDream?.idleMinutes ?? 10;
2242
2681
  const dRuntime = existingDream?.maxRuntimeMinutes ?? 30;
2243
2682
  const dMinMem = existingDream?.minMemories ?? 10;
2683
+ const dScheduleStart = existingDream?.schedule?.startHour ?? 2;
2684
+ const dScheduleEnd = existingDream?.schedule?.endHour ?? 5;
2685
+ const dSystemIdle = existingDream?.systemIdleMinutes ?? 30;
2686
+ const dMinNewMemories = existingDream?.minNewMemoriesToDream ?? 10;
2687
+ const dMinHours = existingDream?.minHoursBetweenRuns ?? 20;
2688
+ const dMaxCalls = existingDream?.maxLLMCallsPerRun ?? 12;
2244
2689
  const dSelfCritique = existingDream?.selfCritique ?? true;
2245
2690
  const dGenSummaries = existingDream?.generateSummaries ?? true;
2246
2691
  const dDiscover = existingDream?.discoverRelationships ?? true;
@@ -2257,16 +2702,34 @@ export async function runDreamSetup(opts = {}) {
2257
2702
  let idleMinutes = dIdle;
2258
2703
  let maxRuntimeMinutes = dRuntime;
2259
2704
  let minMemories = dMinMem;
2705
+ let scheduleStartHour = dScheduleStart;
2706
+ let scheduleEndHour = dScheduleEnd;
2707
+ let systemIdleMinutes = dSystemIdle;
2708
+ let minNewMemoriesToDream = dMinNewMemories;
2709
+ let minHoursBetweenRuns = dMinHours;
2710
+ let maxLLMCallsPerRun = dMaxCalls;
2260
2711
  let selfCritique = dSelfCritique;
2261
2712
  let generateSummaries = dGenSummaries;
2262
2713
  let discoverRelationships = dDiscover;
2263
2714
  if (editChoice === "e") {
2264
2715
  const idleAns = await askInput(rl, "idle minutes before triggering", { default: String(dIdle) });
2265
- idleMinutes = Math.max(1, parseInt(idleAns) || dIdle);
2716
+ idleMinutes = Math.max(1, parseInt(idleAns, 10) || dIdle);
2266
2717
  const runtimeAns = await askInput(rl, "max runtime minutes", { default: String(dRuntime) });
2267
- maxRuntimeMinutes = Math.max(1, parseInt(runtimeAns) || dRuntime);
2718
+ maxRuntimeMinutes = Math.max(1, parseInt(runtimeAns, 10) || dRuntime);
2268
2719
  const minMemAns = await askInput(rl, "minimum memories before activating", { default: String(dMinMem) });
2269
- minMemories = Math.max(1, parseInt(minMemAns) || dMinMem);
2720
+ minMemories = Math.max(1, parseInt(minMemAns, 10) || dMinMem);
2721
+ const scheduleStartAns = await askInput(rl, "night window start hour (0-23)", { default: String(dScheduleStart) });
2722
+ scheduleStartHour = Math.min(23, Math.max(0, parseInt(scheduleStartAns, 10) || dScheduleStart));
2723
+ const scheduleEndAns = await askInput(rl, "night window end hour (0-23)", { default: String(dScheduleEnd) });
2724
+ scheduleEndHour = Math.min(23, Math.max(0, parseInt(scheduleEndAns, 10) || dScheduleEnd));
2725
+ const systemIdleAns = await askInput(rl, "real machine idle minutes required", { default: String(dSystemIdle) });
2726
+ systemIdleMinutes = Math.max(1, parseInt(systemIdleAns, 10) || dSystemIdle);
2727
+ const minNewAns = await askInput(rl, "minimum changed memories before dreaming", { default: String(dMinNewMemories) });
2728
+ minNewMemoriesToDream = Math.max(0, parseInt(minNewAns, 10) || dMinNewMemories);
2729
+ const minHoursAns = await askInput(rl, "minimum hours between successful dreams", { default: String(dMinHours) });
2730
+ minHoursBetweenRuns = Math.max(0, parseInt(minHoursAns, 10) || dMinHours);
2731
+ const maxCallsAns = await askInput(rl, "maximum LLM calls per dream run", { default: String(dMaxCalls) });
2732
+ maxLLMCallsPerRun = Math.max(0, parseInt(maxCallsAns, 10) || dMaxCalls);
2270
2733
  selfCritique = await askYesNo(rl, "self-critique (rule + LLM-based review flagging)", dSelfCritique);
2271
2734
  generateSummaries = await askYesNo(rl, "generate summaries (LLM)", dGenSummaries);
2272
2735
  discoverRelationships = await askYesNo(rl, "discover relationships (LLM)", dDiscover);
@@ -2281,6 +2744,11 @@ export async function runDreamSetup(opts = {}) {
2281
2744
  minMemories,
2282
2745
  provider: dreamProvider,
2283
2746
  model: dreamModel || undefined,
2747
+ schedule: { startHour: scheduleStartHour, endHour: scheduleEndHour },
2748
+ systemIdleMinutes,
2749
+ minNewMemoriesToDream,
2750
+ minHoursBetweenRuns,
2751
+ maxLLMCallsPerRun,
2284
2752
  selfCritique,
2285
2753
  generateSummaries,
2286
2754
  discoverRelationships,
@@ -2289,6 +2757,8 @@ export async function runDreamSetup(opts = {}) {
2289
2757
  // Reset consecutive failure counter on a fresh setup so Layer 4
2290
2758
  // doesn't fire immediately based on stale history.
2291
2759
  localDb.resetDreamConsecutiveFailures();
2760
+ const { installDreamLaunchAgent } = await import("./dreamLaunchd.js");
2761
+ const launchdPath = installDreamLaunchAgent();
2292
2762
  localDb.close();
2293
2763
  remoteDb?.close();
2294
2764
  // Final Diff block per the design — provider/machine + the two
@@ -2316,7 +2786,9 @@ export async function runDreamSetup(opts = {}) {
2316
2786
  discoverRelationships,
2317
2787
  }));
2318
2788
  const dreamerName = designate ? localMachine : (designatedMachine ?? "the designated machine");
2319
- printStatus("progress", `first cycle runs after ${dreamerName} is idle for ${idleMinutes} min`);
2789
+ printStatus("progress", `scheduled dream checks run nightly (${scheduleStartHour}:00-${scheduleEndHour}:00) on ${dreamerName}`);
2790
+ if (launchdPath)
2791
+ printStatus("ok", "launchd agent installed", launchdPath);
2320
2792
  printStatus("progress", "check status anytime with `gnosys status --system`");
2321
2793
  }
2322
2794
  finally {
@@ -2379,29 +2851,17 @@ async function openRemoteDbIfConfigured(localDb) {
2379
2851
  * Best-effort lookup of the API key for a provider. Used by the dream setup
2380
2852
  * wizard to power the validation step. Mirrors the resolveApiKey precedence.
2381
2853
  */
2382
- export async function getApiKeyForProvider(provider) {
2383
- if (provider === "ollama" || provider === "lmstudio" || provider === "skip")
2854
+ export async function getApiKeyForProvider(provider, opts) {
2855
+ if (provider === "ollama" || provider === "lmstudio" || provider === "skip") {
2384
2856
  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
2857
+ }
2858
+ if (opts?.directory) {
2859
+ const cfg = await loadExistingConfig(opts.directory);
2860
+ if (cfg) {
2861
+ const fromCfg = getApiKeyForProviderFromConfig(cfg, provider);
2862
+ if (fromCfg)
2863
+ return fromCfg;
2404
2864
  }
2405
2865
  }
2406
- return "";
2866
+ return readFirstInChain(provider) ?? "";
2407
2867
  }