gnosys 5.3.2 → 5.4.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.
package/dist/lib/setup.js CHANGED
@@ -15,6 +15,8 @@ import path from "path";
15
15
  import os from "os";
16
16
  import { execSync } from "child_process";
17
17
  import { loadConfig, updateConfig, getProviderModel, } from "./config.js";
18
+ import { validateModel } from "./modelValidation.js";
19
+ import { getGnosysHome } from "./paths.js";
18
20
  // ─── ANSI Colors ────────────────────────────────────────────────────────────
19
21
  const BOLD = "\x1b[1m";
20
22
  const DIM = "\x1b[2m";
@@ -479,8 +481,76 @@ export async function detectIDEs(projectDir) {
479
481
  // Not installed
480
482
  }
481
483
  }
484
+ // Check for Gemini CLI — CLI in PATH or global ~/.gemini/ directory
485
+ try {
486
+ execSync("which gemini", { stdio: "ignore" });
487
+ detected.push("gemini-cli");
488
+ }
489
+ catch {
490
+ try {
491
+ const stat = await fs.stat(path.join(home, ".gemini"));
492
+ if (stat.isDirectory())
493
+ detected.push("gemini-cli");
494
+ }
495
+ catch {
496
+ // Not installed
497
+ }
498
+ }
499
+ // Check for Antigravity — ~/.gemini/antigravity/ directory or app installed
500
+ // (Antigravity stores its MCP config at ~/.gemini/antigravity/mcp_config.json)
501
+ try {
502
+ const stat = await fs.stat(path.join(home, ".gemini", "antigravity"));
503
+ if (stat.isDirectory())
504
+ detected.push("antigravity");
505
+ }
506
+ catch {
507
+ // Also check macOS Applications
508
+ try {
509
+ await fs.stat("/Applications/Antigravity.app");
510
+ detected.push("antigravity");
511
+ }
512
+ catch {
513
+ // Not installed
514
+ }
515
+ }
516
+ // Check for Claude Desktop — distinct from Claude Code CLI. Detected via the
517
+ // app bundle on macOS or the platform-specific config dir.
518
+ try {
519
+ const cfg = claudeDesktopConfigPath();
520
+ const cfgDir = path.dirname(cfg);
521
+ const stat = await fs.stat(cfgDir);
522
+ if (stat.isDirectory())
523
+ detected.push("claude-desktop");
524
+ }
525
+ catch {
526
+ // Also check macOS Applications
527
+ try {
528
+ await fs.stat("/Applications/Claude.app");
529
+ detected.push("claude-desktop");
530
+ }
531
+ catch {
532
+ // Not installed
533
+ }
534
+ }
482
535
  return detected;
483
536
  }
537
+ /**
538
+ * Resolve the platform-specific Claude Desktop config file path.
539
+ * macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
540
+ * Windows: %APPDATA%/Claude/claude_desktop_config.json
541
+ * Linux: ~/.config/Claude/claude_desktop_config.json (no official build yet)
542
+ */
543
+ function claudeDesktopConfigPath() {
544
+ const home = os.homedir();
545
+ if (process.platform === "darwin") {
546
+ return path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
547
+ }
548
+ if (process.platform === "win32") {
549
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
550
+ return path.join(appData, "Claude", "claude_desktop_config.json");
551
+ }
552
+ return path.join(home, ".config", "Claude", "claude_desktop_config.json");
553
+ }
484
554
  /**
485
555
  * Set up Gnosys MCP integration for a specific IDE.
486
556
  */
@@ -542,6 +612,74 @@ export async function setupIDE(ide, projectDir) {
542
612
  }
543
613
  return { success: true, message: "Codex config updated (.codex/config.toml)" };
544
614
  }
615
+ case "gemini-cli": {
616
+ // Gemini CLI reads MCP servers from ~/.gemini/settings.json (user-level)
617
+ const geminiDir = path.join(os.homedir(), ".gemini");
618
+ const settingsPath = path.join(geminiDir, "settings.json");
619
+ await fs.mkdir(geminiDir, { recursive: true });
620
+ let config = {};
621
+ try {
622
+ const existing = await fs.readFile(settingsPath, "utf-8");
623
+ config = JSON.parse(existing);
624
+ }
625
+ catch {
626
+ // File doesn't exist or is invalid — start fresh
627
+ }
628
+ const servers = (config.mcpServers ?? {});
629
+ servers.gnosys = { command: "gnosys", args: ["serve"] };
630
+ config.mcpServers = servers;
631
+ await fs.writeFile(settingsPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
632
+ return { success: true, message: "Gemini CLI MCP config updated (~/.gemini/settings.json)" };
633
+ }
634
+ case "antigravity": {
635
+ // Antigravity reads MCP servers from ~/.gemini/antigravity/mcp_config.json
636
+ // (separate file from Gemini CLI's settings.json, even though they share the parent dir)
637
+ const antigravityDir = path.join(os.homedir(), ".gemini", "antigravity");
638
+ const configPath = path.join(antigravityDir, "mcp_config.json");
639
+ await fs.mkdir(antigravityDir, { recursive: true });
640
+ let config = {};
641
+ try {
642
+ const existing = await fs.readFile(configPath, "utf-8");
643
+ config = JSON.parse(existing);
644
+ }
645
+ catch {
646
+ // File doesn't exist or is invalid — start fresh
647
+ }
648
+ const servers = (config.mcpServers ?? {});
649
+ servers.gnosys = { command: "gnosys", args: ["serve"] };
650
+ config.mcpServers = servers;
651
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
652
+ return { success: true, message: "Antigravity MCP config updated (~/.gemini/antigravity/mcp_config.json)" };
653
+ }
654
+ case "claude-desktop": {
655
+ // Claude Desktop reads MCP servers from claude_desktop_config.json
656
+ // in a platform-specific app data directory. Distinct from Claude
657
+ // Code CLI which uses `claude mcp add`.
658
+ const configPath = claudeDesktopConfigPath();
659
+ const configDir = path.dirname(configPath);
660
+ await fs.mkdir(configDir, { recursive: true });
661
+ let config = {};
662
+ try {
663
+ const existing = await fs.readFile(configPath, "utf-8");
664
+ config = JSON.parse(existing);
665
+ }
666
+ catch {
667
+ // File doesn't exist or is invalid — start fresh
668
+ }
669
+ const servers = (config.mcpServers ?? {});
670
+ servers.gnosys = { command: "gnosys", args: ["serve"] };
671
+ config.mcpServers = servers;
672
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
673
+ // Display path with ~ prefix when inside HOME for clarity
674
+ const home = os.homedir();
675
+ const displayPath = configPath.startsWith(home)
676
+ ? configPath.replace(home, "~")
677
+ : configPath;
678
+ return {
679
+ success: true,
680
+ message: `Claude Desktop MCP config updated (${displayPath}). Restart Claude Desktop for the change to take effect.`,
681
+ };
682
+ }
545
683
  default:
546
684
  return { success: false, message: `Unknown IDE: ${ide}` };
547
685
  }
@@ -680,7 +818,7 @@ async function loadExistingConfig(projectDir) {
680
818
  }
681
819
  // Try global config at ~/.gnosys
682
820
  try {
683
- const globalStore = path.join(os.homedir(), ".gnosys");
821
+ const globalStore = getGnosysHome();
684
822
  const stat = await fs.stat(path.join(globalStore, "gnosys.json"));
685
823
  if (stat.isFile()) {
686
824
  return await loadConfig(globalStore);
@@ -714,12 +852,15 @@ async function pickProvider(rl, dynamicModels, stepLabel, currentProvider) {
714
852
  }
715
853
  /**
716
854
  * Let the user pick a model from a provider's tiers.
717
- * Returns the model string.
855
+ * Returns the model string. Includes a "Custom (enter model name)"
856
+ * option so users can type any model ID not in the curated list.
718
857
  */
719
858
  async function pickModel(rl, provider, dynamicModels, stepLabel, currentModel) {
720
859
  const tiers = dynamicModels[provider] ?? PROVIDER_TIERS[provider];
721
- if (!tiers || tiers.length === 0)
722
- return "";
860
+ if (!tiers || tiers.length === 0) {
861
+ // No tiers available — fall back to direct entry
862
+ return await askInput(rl, "Model name");
863
+ }
723
864
  const isLocal = provider === "ollama" || provider === "lmstudio";
724
865
  const currentHint = currentModel ? ` ${DIM}(current: ${currentModel})${RESET}` : "";
725
866
  const tierOptions = tiers.map((t) => {
@@ -729,7 +870,13 @@ async function pickModel(rl, provider, dynamicModels, stepLabel, currentModel) {
729
870
  }
730
871
  return `${t.name} (${t.model}) ${DIM}${formatPrice(t.input, t.output)}${RESET}${rec}`;
731
872
  });
873
+ tierOptions.push(`Custom ${DIM}(enter model name)${RESET}`);
732
874
  const tierIndex = await askChoice(rl, `${stepLabel}${currentHint}`, tierOptions);
875
+ // Custom option is the last entry
876
+ if (tierIndex === tiers.length) {
877
+ const custom = await askInput(rl, "Enter model name");
878
+ return custom;
879
+ }
733
880
  return tiers[tierIndex].model;
734
881
  }
735
882
  // ─── Main Setup Wizard ──────────────────────────────────────────────────────
@@ -789,7 +936,8 @@ export async function runSetup(opts) {
789
936
  ? getProviderModel(existingConfig, existingConfig.llm.defaultProvider)
790
937
  : undefined;
791
938
  // ─── Pre-check: Upgrade detection ─────────────────────────────────
792
- const centralDbPath = path.join(os.homedir(), ".gnosys", "gnosys.db");
939
+ const { GnosysDB: GnosysDBForUpgrade } = await import("./db.js");
940
+ const centralDbPath = GnosysDBForUpgrade.getCentralDbPath();
793
941
  const centralDbExists = fsSync.existsSync(centralDbPath);
794
942
  if (centralDbExists) {
795
943
  const projects = await getRegisteredProjects();
@@ -882,8 +1030,14 @@ export async function runSetup(opts) {
882
1030
  }
883
1031
  return `${t.name} (${t.model}) ${DIM}${formatPrice(t.input, t.output)}${RESET}${rec}`;
884
1032
  });
1033
+ tierOptions.push(`Custom ${DIM}(enter model name)${RESET}`);
885
1034
  const tierIndex = await askChoice(rl, `${BOLD}Step 2/5${RESET} ${DIM}\u2014${RESET} Choose model tier${currentModelHint}`, tierOptions);
886
- model = tiers[tierIndex].model;
1035
+ if (tierIndex === tiers.length) {
1036
+ model = await askInput(rl, "Enter model name");
1037
+ }
1038
+ else {
1039
+ model = tiers[tierIndex].model;
1040
+ }
887
1041
  }
888
1042
  }
889
1043
  else if (provider === "custom") {
@@ -931,6 +1085,9 @@ export async function runSetup(opts) {
931
1085
  // ─── Step 3/5 — API key ───────────────────────────────────────────
932
1086
  let apiKeyWritten = false;
933
1087
  let apiKeySource = "";
1088
+ // Captured key value (kept in memory for the validation step below).
1089
+ // Not persisted beyond the wizard run.
1090
+ let capturedApiKey = "";
934
1091
  const needsKey = !isSkip &&
935
1092
  provider !== "ollama" &&
936
1093
  provider !== "lmstudio";
@@ -959,6 +1116,16 @@ export async function runSetup(opts) {
959
1116
  console.log(` ${CHECK} Found existing key (${source})`);
960
1117
  if (existingKey) {
961
1118
  console.log(` ${DIM} ${maskKey(existingKey)}${RESET}`);
1119
+ capturedApiKey = existingKey;
1120
+ }
1121
+ else if (existingKeySource === "macOS Keychain" && process.platform === "darwin") {
1122
+ // Pull key out of keychain so we can validate
1123
+ try {
1124
+ capturedApiKey = execSync(`security find-generic-password -a "$USER" -s "${envVarName}" -w`, { stdio: "pipe", encoding: "utf-8" }).trim();
1125
+ }
1126
+ catch {
1127
+ // Couldn't read it — validation will be skipped
1128
+ }
962
1129
  }
963
1130
  apiKeyWritten = true;
964
1131
  apiKeySource = existingKeySource || "env";
@@ -971,6 +1138,7 @@ export async function runSetup(opts) {
971
1138
  // Fall through to key storage options below
972
1139
  apiKeyWritten = false;
973
1140
  apiKeySource = "";
1141
+ capturedApiKey = "";
974
1142
  }
975
1143
  }
976
1144
  if (!apiKeyWritten) {
@@ -1009,6 +1177,7 @@ export async function runSetup(opts) {
1009
1177
  console.log(` ${CHECK} Key ${existingKey ? "moved" : "saved"} to macOS Keychain (${maskKey(key)})`);
1010
1178
  apiKeyWritten = true;
1011
1179
  apiKeySource = "macOS Keychain";
1180
+ capturedApiKey = key;
1012
1181
  }
1013
1182
  else {
1014
1183
  console.log(` ${CROSS} Failed to write to Keychain. Falling back to .env file.`);
@@ -1016,6 +1185,7 @@ export async function runSetup(opts) {
1016
1185
  console.log(` ${CHECK} Key saved to ~/.config/gnosys/.env (${maskKey(key)})`);
1017
1186
  apiKeyWritten = true;
1018
1187
  apiKeySource = "~/.config/gnosys/.env";
1188
+ capturedApiKey = key;
1019
1189
  }
1020
1190
  }
1021
1191
  }
@@ -1029,6 +1199,7 @@ export async function runSetup(opts) {
1029
1199
  console.log(` ${CHECK} Key ${existingKey ? "moved" : "saved"} to GNOME Keyring (${maskKey(key)})`);
1030
1200
  apiKeyWritten = true;
1031
1201
  apiKeySource = "GNOME Keyring";
1202
+ capturedApiKey = key;
1032
1203
  }
1033
1204
  else {
1034
1205
  console.log(` ${CROSS} Failed to write to GNOME Keyring. Falling back to .env file.`);
@@ -1036,6 +1207,7 @@ export async function runSetup(opts) {
1036
1207
  console.log(` ${CHECK} Key saved to ~/.config/gnosys/.env (${maskKey(key)})`);
1037
1208
  apiKeyWritten = true;
1038
1209
  apiKeySource = "~/.config/gnosys/.env";
1210
+ capturedApiKey = key;
1039
1211
  }
1040
1212
  }
1041
1213
  }
@@ -1071,6 +1243,7 @@ export async function runSetup(opts) {
1071
1243
  console.log(` ${CHECK} Key saved to ~/.config/gnosys/.env (${maskKey(key)})`);
1072
1244
  apiKeyWritten = true;
1073
1245
  apiKeySource = "~/.config/gnosys/.env";
1246
+ capturedApiKey = key;
1074
1247
  }
1075
1248
  }
1076
1249
  else {
@@ -1095,6 +1268,42 @@ export async function runSetup(opts) {
1095
1268
  console.log();
1096
1269
  console.log(`${DIM}Step 3/5 \u2014 API key: not needed (local provider)${RESET}`);
1097
1270
  }
1271
+ // ─── Validate model with a quick test call ────────────────────────
1272
+ // Only attempt validation when we have what we need: a chosen model,
1273
+ // and either a captured key (for cloud providers) or a local provider
1274
+ // (which doesn't need a key).
1275
+ const isLocalProvider = provider === "ollama" || provider === "lmstudio";
1276
+ const canValidate = !isSkip && model && (capturedApiKey || isLocalProvider);
1277
+ if (canValidate) {
1278
+ console.log();
1279
+ console.log(`${DIM}Testing ${provider}/${model}...${RESET}`);
1280
+ try {
1281
+ const { validateModel } = await import("./modelValidation.js");
1282
+ const customBaseUrl = provider === "custom"
1283
+ ? process.env.GNOSYS_LLM_BASE_URL
1284
+ : undefined;
1285
+ const result = await validateModel(provider, model, capturedApiKey, { customBaseUrl });
1286
+ if (result.ok) {
1287
+ console.log(` ${CHECK} Model validated (${result.latencyMs}ms)`);
1288
+ }
1289
+ else {
1290
+ console.log(` ${WARN} Model test failed: ${result.error}`);
1291
+ const proceed = await askYesNo(rl, " Continue anyway?", true);
1292
+ if (!proceed) {
1293
+ console.log(` ${DIM}Setup paused. Re-run when ready: gnosys setup${RESET}`);
1294
+ setupCompleted = true;
1295
+ rl.close();
1296
+ return {
1297
+ provider, model, structuringModel: "",
1298
+ apiKeyWritten, ides: [], mode: "agent", upgraded,
1299
+ };
1300
+ }
1301
+ }
1302
+ }
1303
+ catch (err) {
1304
+ console.log(` ${DIM}Validation skipped: ${err instanceof Error ? err.message : err}${RESET}`);
1305
+ }
1306
+ }
1098
1307
  // ─── Step 4/5 — Task Model Configuration ─────────────────────────
1099
1308
  const taskOverrides = {};
1100
1309
  let dreamEnabled = existingConfig?.dream?.enabled ?? false;
@@ -1224,11 +1433,17 @@ export async function runSetup(opts) {
1224
1433
  console.log();
1225
1434
  const ideLabels = {
1226
1435
  claude: "Claude Code",
1436
+ "claude-desktop": "Claude Desktop",
1227
1437
  cursor: "Cursor",
1228
1438
  codex: "Codex",
1439
+ "gemini-cli": "Gemini CLI",
1440
+ antigravity: "Antigravity",
1229
1441
  };
1442
+ // IDEs whose MCP config lives at the user level (~/...) rather than per-project.
1443
+ // We don't try to create a project-level directory for these.
1444
+ const userLevelIdes = new Set(["claude", "claude-desktop", "gemini-cli", "antigravity"]);
1230
1445
  // Build IDE options: show detected ones and offer to create missing ones
1231
- const allIdeKeys = ["claude", "cursor", "codex"];
1446
+ const allIdeKeys = ["claude", "claude-desktop", "cursor", "codex", "gemini-cli", "antigravity"];
1232
1447
  const ideOptions = [];
1233
1448
  const ideKeyForOption = []; // parallel array mapping option index to IDE key
1234
1449
  for (const ide of allIdeKeys) {
@@ -1237,12 +1452,13 @@ export async function runSetup(opts) {
1237
1452
  if (isDetected) {
1238
1453
  ideOptions.push(`${label} (detected)`);
1239
1454
  }
1240
- else if (ide === "claude") {
1241
- // Claude CLI needs to be installed, can't just create a directory
1242
- ideOptions.push(`${label} ${DIM}(not detected \u2014 install Claude CLI first)${RESET}`);
1455
+ else if (userLevelIdes.has(ide)) {
1456
+ // User-level IDEs \u2014 config goes under ~/. We can still write the config
1457
+ // even if the IDE isn't installed yet (it will be picked up later).
1458
+ ideOptions.push(`${label} ${DIM}(not detected \u2014 will configure anyway)${RESET}`);
1243
1459
  }
1244
1460
  else {
1245
- // Offer to create the directory
1461
+ // Project-level IDEs \u2014 offer to create the local directory
1246
1462
  ideOptions.push(`${label} ${DIM}(create .${ide}/ \u2014 not detected)${RESET}`);
1247
1463
  }
1248
1464
  ideKeyForOption.push(ide);
@@ -1268,8 +1484,10 @@ export async function runSetup(opts) {
1268
1484
  }
1269
1485
  // Last option is "Skip"
1270
1486
  for (const ide of idesToSetup) {
1271
- // For non-detected IDEs (except claude), create the directory first
1272
- if (!detectedIdes.includes(ide) && ide !== "claude") {
1487
+ // For non-detected project-level IDEs, create the directory first.
1488
+ // User-level IDEs (claude, gemini-cli, antigravity) handle their own
1489
+ // ~/-level config dirs inside setupIDE().
1490
+ if (!detectedIdes.includes(ide) && !userLevelIdes.has(ide)) {
1273
1491
  const dirPath = path.join(projectDir, `.${ide}`);
1274
1492
  try {
1275
1493
  await fs.mkdir(dirPath, { recursive: true });
@@ -1310,7 +1528,7 @@ export async function runSetup(opts) {
1310
1528
  // Determine which store path to write to — prefer project, fall back to global
1311
1529
  let storePath;
1312
1530
  const projectStore = path.join(projectDir, ".gnosys");
1313
- const globalStore = path.join(os.homedir(), ".gnosys");
1531
+ const globalStore = getGnosysHome();
1314
1532
  if (fsSync.existsSync(path.join(projectStore, "gnosys.json"))) {
1315
1533
  storePath = projectStore;
1316
1534
  }
@@ -1425,6 +1643,43 @@ export async function runSetup(opts) {
1425
1643
  const ideNames = configuredIdes.map((id) => ideLabels[id] ?? id).join(", ");
1426
1644
  summaryRows.push(["IDEs:", ideNames]);
1427
1645
  }
1646
+ // ─── Step: Multi-machine sync (optional) ──────────────────────────────
1647
+ let remoteConfigured = false;
1648
+ if (!isSkip) {
1649
+ console.log();
1650
+ console.log(`${BOLD}Multi-machine sync${RESET}`);
1651
+ console.log("Share your gnosys.db across machines via NAS or shared drive.");
1652
+ console.log(`Your local DB stays fast, the remote is the source of truth.`);
1653
+ console.log();
1654
+ const setUpRemote = (await rl.question(`Configure remote sync now? [y/N] `)).trim().toLowerCase();
1655
+ if (setUpRemote === "y" || setUpRemote === "yes") {
1656
+ console.log();
1657
+ try {
1658
+ const { GnosysDB } = await import("./db.js");
1659
+ const { runConfigureWizard } = await import("./remoteWizard.js");
1660
+ const centralDb = GnosysDB.openCentral();
1661
+ if (centralDb.isAvailable()) {
1662
+ // Pass our readline to the wizard — it will use ours and not close it
1663
+ remoteConfigured = await runConfigureWizard(centralDb, rl);
1664
+ centralDb.close();
1665
+ }
1666
+ else {
1667
+ console.log("Central DB not available — skipping remote sync.");
1668
+ }
1669
+ }
1670
+ catch (err) {
1671
+ console.log(`Remote sync setup failed: ${err instanceof Error ? err.message : err}`);
1672
+ console.log("You can run 'gnosys remote configure' later.");
1673
+ }
1674
+ }
1675
+ else {
1676
+ console.log("Skipped. Run 'gnosys remote configure' anytime to set up.");
1677
+ }
1678
+ }
1679
+ if (remoteConfigured) {
1680
+ summaryRows.push(["", ""]);
1681
+ summaryRows.push(["Remote sync:", "configured"]);
1682
+ }
1428
1683
  printBox("Setup Complete", summaryRows);
1429
1684
  console.log(`Next: Run ${CYAN}gnosys init${RESET} in any project to start using memory.`);
1430
1685
  console.log();
@@ -1447,4 +1702,228 @@ export async function runSetup(opts) {
1447
1702
  throw err;
1448
1703
  }
1449
1704
  }
1705
+ /**
1706
+ * Models-only configuration — prompts for provider, model, and key (or accepts
1707
+ * them via options for non-interactive use). Validates the model against the
1708
+ * provider, then writes the result to gnosys.json. Skips IDE and remote setup.
1709
+ */
1710
+ export async function runModelsSetup(opts = {}) {
1711
+ const projectDir = opts.directory ? path.resolve(opts.directory) : process.cwd();
1712
+ const rl = createInterface({ input: stdin, output: stdout });
1713
+ try {
1714
+ console.log();
1715
+ console.log(`${BOLD}${CYAN}Gnosys${RESET} ${DIM}— Model Configuration${RESET}`);
1716
+ console.log();
1717
+ const existingConfig = await loadExistingConfig(projectDir);
1718
+ const currentProvider = existingConfig?.llm.defaultProvider;
1719
+ const currentModel = existingConfig
1720
+ ? getProviderModel(existingConfig, existingConfig.llm.defaultProvider)
1721
+ : undefined;
1722
+ // Step 1: provider (or use --provider flag)
1723
+ console.log(`${DIM}Fetching latest model pricing...${RESET}`);
1724
+ const dynamicModels = await fetchDynamicModels();
1725
+ if (Object.keys(dynamicModels).length > 0) {
1726
+ console.log(`${DIM}${CHECK} Live pricing loaded from OpenRouter${RESET}`);
1727
+ }
1728
+ console.log();
1729
+ let provider;
1730
+ if (opts.provider) {
1731
+ if (!PROVIDER_ORDER.includes(opts.provider)) {
1732
+ console.log(`${CROSS} Unknown provider: ${opts.provider}`);
1733
+ console.log(` Valid: ${PROVIDER_ORDER.join(", ")}`);
1734
+ return;
1735
+ }
1736
+ provider = opts.provider;
1737
+ console.log(`Provider: ${GREEN}${provider}${RESET}`);
1738
+ }
1739
+ else {
1740
+ provider = await pickProvider(rl, dynamicModels, "Choose your LLM provider", currentProvider);
1741
+ }
1742
+ // Step 2: model (or use --model flag)
1743
+ let model;
1744
+ if (opts.model) {
1745
+ model = opts.model;
1746
+ console.log(`Model: ${GREEN}${model}${RESET}`);
1747
+ }
1748
+ else {
1749
+ const tiers = dynamicModels[provider] ?? PROVIDER_TIERS[provider];
1750
+ if (provider === "custom" || !tiers || tiers.length === 0) {
1751
+ model = await askInput(rl, "Model name");
1752
+ }
1753
+ else {
1754
+ const showCurrent = currentProvider === provider ? currentModel : undefined;
1755
+ model = await pickModel(rl, provider, dynamicModels, "Choose model", showCurrent);
1756
+ }
1757
+ }
1758
+ if (!model) {
1759
+ console.log(`${CROSS} No model selected. Aborting.`);
1760
+ return;
1761
+ }
1762
+ // Step 3: load API key from existing storage (if available)
1763
+ const envVarName = provider === "custom" ? "GNOSYS_CUSTOM_KEY" :
1764
+ `GNOSYS_${provider.toUpperCase()}_KEY`;
1765
+ const legacyEnvVars = {
1766
+ anthropic: "ANTHROPIC_API_KEY",
1767
+ openai: "OPENAI_API_KEY",
1768
+ groq: "GROQ_API_KEY",
1769
+ xai: "XAI_API_KEY",
1770
+ mistral: "MISTRAL_API_KEY",
1771
+ };
1772
+ const legacyEnvVar = legacyEnvVars[provider] ?? "";
1773
+ let apiKey = process.env[envVarName] || (legacyEnvVar ? process.env[legacyEnvVar] : "") || "";
1774
+ if (!apiKey && process.platform === "darwin") {
1775
+ try {
1776
+ apiKey = execSync(`security find-generic-password -a "$USER" -s "${envVarName}" -w`, { stdio: "pipe", encoding: "utf-8" }).trim();
1777
+ }
1778
+ catch {
1779
+ // No key in keychain
1780
+ }
1781
+ }
1782
+ if (!apiKey && provider !== "ollama" && provider !== "lmstudio") {
1783
+ console.log(`${WARN} No API key found for ${provider}. Run 'gnosys setup' to configure one.`);
1784
+ // Continue anyway — user might just want to update the model in config
1785
+ }
1786
+ // Step 4: validate (default: true)
1787
+ const shouldValidate = opts.validate !== false;
1788
+ const isLocalProvider = provider === "ollama" || provider === "lmstudio";
1789
+ if (shouldValidate && (apiKey || isLocalProvider)) {
1790
+ console.log();
1791
+ console.log(`${DIM}Testing ${provider}/${model}...${RESET}`);
1792
+ const customBaseUrl = provider === "custom"
1793
+ ? process.env.GNOSYS_LLM_BASE_URL
1794
+ : undefined;
1795
+ const result = await validateModel(provider, model, apiKey, { customBaseUrl });
1796
+ if (result.ok) {
1797
+ console.log(` ${CHECK} Model validated (${result.latencyMs}ms)`);
1798
+ }
1799
+ else {
1800
+ console.log(` ${WARN} Model test failed: ${result.error}`);
1801
+ const proceed = await askYesNo(rl, " Save config anyway?", false);
1802
+ if (!proceed) {
1803
+ console.log(` ${DIM}Cancelled.${RESET}`);
1804
+ return;
1805
+ }
1806
+ }
1807
+ }
1808
+ // Step 5: write config
1809
+ const projectStore = path.join(projectDir, ".gnosys");
1810
+ const globalStore = getGnosysHome();
1811
+ let storePath;
1812
+ if (fsSync.existsSync(path.join(projectStore, "gnosys.json"))) {
1813
+ storePath = projectStore;
1814
+ }
1815
+ else if (fsSync.existsSync(path.join(globalStore, "gnosys.json"))) {
1816
+ storePath = globalStore;
1817
+ }
1818
+ else {
1819
+ await fs.mkdir(globalStore, { recursive: true });
1820
+ storePath = globalStore;
1821
+ }
1822
+ const existingLlm = existingConfig?.llm;
1823
+ const existingProviderConfig = existingLlm
1824
+ ? existingLlm[provider]
1825
+ : undefined;
1826
+ const providerConfigBase = (typeof existingProviderConfig === "object" && existingProviderConfig !== null)
1827
+ ? existingProviderConfig
1828
+ : {};
1829
+ await updateConfig(storePath, {
1830
+ llm: {
1831
+ ...(existingLlm ?? {}),
1832
+ defaultProvider: provider,
1833
+ [provider]: {
1834
+ ...providerConfigBase,
1835
+ model,
1836
+ },
1837
+ },
1838
+ });
1839
+ console.log();
1840
+ console.log(` ${CHECK} Config saved: ${storePath}/gnosys.json`);
1841
+ console.log(` ${DIM}Provider: ${provider}${RESET}`);
1842
+ console.log(` ${DIM}Model: ${model}${RESET}`);
1843
+ }
1844
+ finally {
1845
+ rl.close();
1846
+ }
1847
+ }
1848
+ /**
1849
+ * Lightweight model-management command. Supports three operations:
1850
+ * --list: print available models for the current provider
1851
+ * --refresh: clear the OpenRouter cache and re-fetch
1852
+ * --set X: update the default model in gnosys.json (no prompts)
1853
+ */
1854
+ export async function runModelsCommand(opts = {}) {
1855
+ const projectDir = opts.directory ? path.resolve(opts.directory) : process.cwd();
1856
+ const existingConfig = await loadExistingConfig(projectDir);
1857
+ const currentProvider = existingConfig?.llm.defaultProvider;
1858
+ if (opts.refresh) {
1859
+ const cacheFile = path.join(os.homedir(), ".config", "gnosys", "models-cache.json");
1860
+ try {
1861
+ await fs.unlink(cacheFile);
1862
+ console.log(`${CHECK} Cache cleared.`);
1863
+ }
1864
+ catch {
1865
+ console.log(`${DIM}No cache to clear.${RESET}`);
1866
+ }
1867
+ }
1868
+ if (opts.list) {
1869
+ if (!currentProvider) {
1870
+ console.log(`${WARN} No provider configured. Run 'gnosys setup' first.`);
1871
+ return;
1872
+ }
1873
+ console.log();
1874
+ console.log(`${BOLD}Available models for ${currentProvider}:${RESET}`);
1875
+ console.log();
1876
+ const dynamicModels = await fetchDynamicModels();
1877
+ const tiers = dynamicModels[currentProvider] ?? PROVIDER_TIERS[currentProvider] ?? [];
1878
+ if (tiers.length === 0) {
1879
+ console.log(` ${DIM}No models in catalog. Try '--refresh' or use a custom model name.${RESET}`);
1880
+ return;
1881
+ }
1882
+ for (const t of tiers) {
1883
+ const rec = t.recommended ? ` ${CYAN}<- recommended${RESET}` : "";
1884
+ const price = t.input === 0 && t.output === 0
1885
+ ? "free"
1886
+ : `$${t.input.toFixed(2)}–$${t.output.toFixed(2)}/M`;
1887
+ console.log(` ${t.name.padEnd(24)} ${t.model.padEnd(40)} ${DIM}${price}${RESET}${rec}`);
1888
+ }
1889
+ return;
1890
+ }
1891
+ if (opts.set) {
1892
+ if (!currentProvider) {
1893
+ console.log(`${WARN} No provider configured. Run 'gnosys setup' first.`);
1894
+ return;
1895
+ }
1896
+ const projectStore = path.join(projectDir, ".gnosys");
1897
+ const globalStore = getGnosysHome();
1898
+ const storePath = fsSync.existsSync(path.join(projectStore, "gnosys.json"))
1899
+ ? projectStore
1900
+ : globalStore;
1901
+ const existingProviderConfig = existingConfig?.llm?.[currentProvider];
1902
+ const providerConfigBase = (typeof existingProviderConfig === "object" && existingProviderConfig !== null)
1903
+ ? existingProviderConfig
1904
+ : {};
1905
+ await updateConfig(storePath, {
1906
+ llm: {
1907
+ ...(existingConfig?.llm ?? {}),
1908
+ defaultProvider: currentProvider,
1909
+ [currentProvider]: { ...providerConfigBase, model: opts.set },
1910
+ },
1911
+ });
1912
+ console.log(`${CHECK} Default model set to ${GREEN}${opts.set}${RESET} for ${currentProvider}.`);
1913
+ return;
1914
+ }
1915
+ // No flags: show current config
1916
+ if (!currentProvider) {
1917
+ console.log(`${WARN} No provider configured. Run 'gnosys setup' first.`);
1918
+ return;
1919
+ }
1920
+ const currentModel = existingConfig
1921
+ ? getProviderModel(existingConfig, existingConfig.llm.defaultProvider)
1922
+ : "";
1923
+ console.log();
1924
+ console.log(`Provider: ${GREEN}${currentProvider}${RESET}`);
1925
+ console.log(`Model: ${GREEN}${currentModel}${RESET}`);
1926
+ console.log();
1927
+ console.log(`${DIM}Use '--list' to see options, '--set <model>' to change, '--refresh' to update catalog.${RESET}`);
1928
+ }
1450
1929
  //# sourceMappingURL=setup.js.map