diff --git a/src/cli.ts b/src/cli.ts index 686c7d5..f44caac 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,10 +4,12 @@ import { join } from "node:path"; import { homedir } from "node:os"; import * as readline from "node:readline"; import { startAuthFlow, clearCredentials, loadCredentials, CREDENTIALS_FILE } from "./services/auth.js"; -import { CONFIG, CONFIG_FILE, SUPERMEMORY_API_KEY, getApiBaseUrl, isConfigured, writeInstallDefaults } from "./config.js"; +import { CONFIG, CONFIG_FILE, SUPERMEMORY_API_KEY, getApiBaseUrl, isConfigured } from "./config.js"; import { SupermemoryClient } from "./services/client.js"; import { getTags } from "./services/tags.js"; +import { stripJsoncComments } from "./services/jsonc.js"; import { + applyInstallDefaults, editOpenCodeConfig, editOpenCodeTuiConfig, readOpenCodeRegistration, @@ -408,6 +410,16 @@ function disableAutoCompactHook(): boolean { } } +function writeInstallDefaults(isExistingInstall: boolean): void { + const raw = isExistingInstall ? readFileSync(DEFAULT_CONFIG_FILE, "utf-8") : ""; + const next = applyInstallDefaults(raw, isExistingInstall); + if (next === null) { + console.warn(`⚠ Could not parse ${DEFAULT_CONFIG_FILE}; left it unchanged.`); + } else if (next !== raw) { + writeFileSync(DEFAULT_CONFIG_FILE, next); + } +} + interface InstallOptions { tui: boolean; disableAutoCompact: boolean; @@ -535,7 +547,7 @@ function maskKey(key: string | undefined): string { function getConfiguredApiKeyFromFile(): string | undefined { try { if (!existsSync(DEFAULT_CONFIG_FILE)) return undefined; - const parsed = JSON.parse(readFileSync(DEFAULT_CONFIG_FILE, "utf-8")) as { apiKey?: string }; + const parsed = JSON.parse(stripJsoncComments(readFileSync(DEFAULT_CONFIG_FILE, "utf-8"))) as { apiKey?: string }; return parsed.apiKey; } catch { return undefined; diff --git a/src/config.ts b/src/config.ts index b342f7b..338b8ef 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { stripJsoncComments } from "./services/jsonc.js"; @@ -118,22 +118,22 @@ function resolveRecallMode(): RecallMode { return DEFAULTS.recallMode; } -function loadRawConfig(): { config: SupermemoryConfig; existed: boolean } { +function loadRawConfig(): { config: SupermemoryConfig; existed: boolean; path?: string } { for (const path of CONFIG_FILES) { if (existsSync(path)) { try { const content = readFileSync(path, "utf-8"); const json = stripJsoncComments(content); - return { config: JSON.parse(json) as SupermemoryConfig, existed: true }; + return { config: JSON.parse(json) as SupermemoryConfig, existed: true, path }; } catch { - return { config: {}, existed: true }; + return { config: {}, existed: true, path }; } } } return { config: {}, existed: false }; } -const { config: fileConfig, existed: configExisted } = loadRawConfig(); +const { config: fileConfig, existed: configExisted, path: loadedConfigFile } = loadRawConfig(); function getApiKey(): string | undefined { if (process.env.SUPERMEMORY_API_KEY) return process.env.SUPERMEMORY_API_KEY; @@ -172,8 +172,8 @@ export function getApiBaseUrl(): string { return normalized; } -export const CONFIG_FILE = CONFIG_FILES[1]; -const DEFAULT_CONFIG_FILE = CONFIG_FILE ?? join(CONFIG_DIR, "supermemory.json"); +/** The file the config was loaded from, or where a new one should be created. */ +export const CONFIG_FILE = loadedConfigFile ?? CONFIG_FILES[1]; export const CONFIG = { similarityThreshold: fileConfig.similarityThreshold ?? DEFAULTS.similarityThreshold, @@ -218,15 +218,3 @@ export function getRecallConfig(): { mode: CONFIG.recallMode, }; } - -export function writeInstallDefaults(isExistingInstall: boolean): void { - const current = loadRawConfig().config; - const next: SupermemoryConfig = { ...current }; - if (isExistingInstall) { - if (next.captureEveryNTurns === undefined) next.captureEveryNTurns = 3; - } else { - next.recallMode = "direct"; - next.captureEveryNTurns = 0; - } - writeFileSync(DEFAULT_CONFIG_FILE, JSON.stringify(next, null, 2)); -} diff --git a/src/services/opencode-config.test.ts b/src/services/opencode-config.test.ts index 79b1a48..40dea19 100644 --- a/src/services/opencode-config.test.ts +++ b/src/services/opencode-config.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { + applyInstallDefaults, editOpenCodeConfig, editOpenCodeTuiConfig, readOpenCodeRegistration, @@ -89,3 +90,36 @@ describe("OpenCode V1 TUI config editor", () => { expect(readOpenCodeTuiRegistration('{"plugin": ["other"]}')).toBe(false); }); }); + +describe("applyInstallDefaults", () => { + const jsonc = `{ + // created per README + "apiKey": "sm_test", + "recallMode": "advisory" +} +`; + + test("keeps comments and user settings in an existing jsonc config", () => { + const next = applyInstallDefaults(jsonc, true)!; + + expect(next).toContain("// created per README"); + expect(next).toContain('"recallMode": "advisory"'); + expect(next).toContain('"captureEveryNTurns": 3'); + }); + + test("leaves an existing capture cadence untouched", () => { + const content = `{ "captureEveryNTurns": 5 } // keep`; + expect(applyInstallDefaults(content, true)).toBe(content); + }); + + test("writes fresh-install defaults to an empty config", () => { + expect(JSON.parse(applyInstallDefaults("", false)!)).toEqual({ + recallMode: "direct", + captureEveryNTurns: 0, + }); + }); + + test("does not rewrite a config it cannot parse", () => { + expect(applyInstallDefaults(`{ "apiKey": `, true)).toBeNull(); + }); +}); diff --git a/src/services/opencode-config.ts b/src/services/opencode-config.ts index c486259..4ef2316 100644 --- a/src/services/opencode-config.ts +++ b/src/services/opencode-config.ts @@ -206,3 +206,28 @@ export function editOpenCodeConfig(rawContent: string): OpenCodeConfigEditResult warnings, }; } + +/** + * Adds installer defaults to supermemory.json(c) without rewriting the rest of + * the file, so user comments survive. Returns null for content that is not a + * valid JSONC object, which should be left for the user to fix. + */ +export function applyInstallDefaults(rawContent: string, isExistingInstall: boolean): string | null { + const content = rawContent.trim() === "" ? "{}\n" : rawContent; + let config: JsonObject; + try { + config = parseOpenCodeConfig(content); + } catch { + return null; + } + + const defaults: JsonObject = isExistingInstall + ? config.captureEveryNTurns === undefined ? { captureEveryNTurns: 3 } : {} + : { recallMode: "direct", captureEveryNTurns: 0 }; + + let next = content; + for (const [key, value] of Object.entries(defaults)) { + next = applyModification(next, [key], value); + } + return next; +}