diff --git a/package.json b/package.json index 06136ab..d541194 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "scripts": { "build": "bun build ./src/index.ts --outdir ./dist --target node && bun build ./src/cli.ts --outfile ./dist/cli.js --target node && tsc --emitDeclarationOnly", "dev": "tsc --watch", + "test": "bun test", "typecheck": "tsc --noEmit" }, "keywords": [ diff --git a/src/cli.ts b/src/cli.ts index 3073493..73e8a42 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,7 +13,6 @@ const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode"); const OPENCODE_COMMAND_DIR = join(OPENCODE_CONFIG_DIR, "command"); const OH_MY_OPENCODE_CONFIG = join(OPENCODE_CONFIG_DIR, "oh-my-opencode.json"); const PLUGIN_NAME = "opencode-supermemory@latest"; -const DEFAULT_CONFIG_FILE = CONFIG_FILE ?? join(OPENCODE_CONFIG_DIR, "supermemory.json"); const SUPERMEMORY_INIT_COMMAND = `--- description: Initialize Supermemory with comprehensive codebase knowledge @@ -401,7 +400,13 @@ interface InstallOptions { async function install(options: InstallOptions): Promise { console.log("\n🧠 opencode-supermemory installer\n"); - writeInstallDefaults(existsSync(DEFAULT_CONFIG_FILE)); + try { + writeInstallDefaults(existsSync(CONFIG_FILE)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`✗ ${message}`); + return 1; + } const rl = options.tui ? createReadline() : null; @@ -517,8 +522,8 @@ 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 }; + if (!existsSync(CONFIG_FILE)) return undefined; + const parsed = JSON.parse(stripJsoncComments(readFileSync(CONFIG_FILE, "utf-8"))) as { apiKey?: string }; return parsed.apiKey; } catch { return undefined; @@ -527,7 +532,7 @@ function getConfiguredApiKeyFromFile(): string | undefined { function getKeySource(): string { if (process.env.SUPERMEMORY_API_KEY) return "SUPERMEMORY_API_KEY env var"; - if (getConfiguredApiKeyFromFile()) return DEFAULT_CONFIG_FILE; + if (getConfiguredApiKeyFromFile()) return CONFIG_FILE; if (loadCredentials()) return CREDENTIALS_FILE; return "not configured"; } diff --git a/src/config.ts b/src/config.ts index b91b1b2..f4f6ace 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,15 +1,20 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; +import { isDeepStrictEqual } from "node:util"; import { stripJsoncComments } from "./services/jsonc.js"; import { loadCredentials } from "./services/auth.js"; const CONFIG_DIR = join(homedir(), ".config", "opencode"); export const PLUGIN_VERSION = "2.0.8"; -const CONFIG_FILES = [ - join(CONFIG_DIR, "supermemory.jsonc"), - join(CONFIG_DIR, "supermemory.json"), -]; + +function resolveConfigFile(configDir = CONFIG_DIR): string { + const jsoncFile = join(configDir, "supermemory.jsonc"); + const jsonFile = join(configDir, "supermemory.json"); + return [jsoncFile, jsonFile].find((path) => existsSync(path)) ?? jsonFile; +} + +export const CONFIG_FILE = resolveConfigFile(); export const DEFAULT_BASE_URL = "https://api.supermemory.ai"; @@ -81,19 +86,137 @@ function validateCompactionThreshold(value: number | undefined): number { return value; } -function loadRawConfig(): { config: SupermemoryConfig; existed: boolean } { - 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 }; - } catch { - return { config: {}, existed: true }; +interface RawConfigResult { + config: SupermemoryConfig; + existed: boolean; + content?: string; + parseError?: string; +} + +function isJsonWhitespace(char: string): boolean { + return char === " " || char === "\t" || char === "\r" || char === "\n"; +} + +function findJsoncObjectStart(content: string): number { + let inLineComment = false; + let inBlockComment = false; + + for (let i = 0; i < content.length; i++) { + const char = content[i] ?? ""; + const next = content[i + 1] ?? ""; + + if (inLineComment) { + if (char === "\n") inLineComment = false; + continue; + } + + if (inBlockComment) { + if (char === "*" && next === "/") { + inBlockComment = false; + i++; } + continue; + } + + if (char === "/" && next === "/") { + inLineComment = true; + i++; + continue; + } + + if (char === "/" && next === "*") { + inBlockComment = true; + i++; + continue; + } + + if (isJsonWhitespace(char)) continue; + if (char === "{") return i; + throw new Error("Cannot update Supermemory JSONC config: expected a top-level object"); + } + + throw new Error("Cannot update Supermemory JSONC config: expected a top-level object"); +} + +function inferJsoncPropertyIndent(content: string, start: number): string { + let lineStart = start; + + while (lineStart < content.length) { + let contentStart = lineStart; + while (content[contentStart] === " " || content[contentStart] === "\t") contentStart++; + + if (content.startsWith("\r\n", contentStart)) { + lineStart = contentStart + 2; + continue; + } + if (content[contentStart] === "\r" || content[contentStart] === "\n") { + lineStart = contentStart + 1; + continue; + } + + const indent = content.slice(lineStart, contentStart); + return content[contentStart] === "}" ? `${indent} ` : indent; + } + + return " "; +} + +function insertJsoncProperties( + content: string, + entries: Array, + hasExistingProperties: boolean, +): string { + if (entries.length === 0) return content; + + const objectStart = findJsoncObjectStart(content); + const renderedEntries = entries.map(([key, value], index) => { + const needsComma = hasExistingProperties || index < entries.length - 1; + return `${JSON.stringify(key)}: ${JSON.stringify(value)}${needsComma ? "," : ""}`; + }); + + let lineBreakStart = objectStart + 1; + while (content[lineBreakStart] === " " || content[lineBreakStart] === "\t") { + lineBreakStart++; + } + + const lineBreakLength = content.startsWith("\r\n", lineBreakStart) + ? 2 + : content[lineBreakStart] === "\r" || content[lineBreakStart] === "\n" + ? 1 + : 0; + + if (lineBreakLength === 0) { + const insertion = ` ${renderedEntries.join(" ")} `; + return `${content.slice(0, objectStart + 1)}${insertion}${content.slice(objectStart + 1)}`; + } + + const insertionIndex = lineBreakStart + lineBreakLength; + const newline = content.slice(lineBreakStart, insertionIndex); + const propertyIndent = inferJsoncPropertyIndent(content, insertionIndex); + const insertion = `${renderedEntries.map((entry) => `${propertyIndent}${entry}`).join(newline)}${newline}`; + + return `${content.slice(0, insertionIndex)}${insertion}${content.slice(insertionIndex)}`; +} + +function isConfigObject(value: unknown): value is SupermemoryConfig & Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function loadRawConfig(): RawConfigResult { + if (!existsSync(CONFIG_FILE)) return { config: {}, existed: false }; + + try { + const content = readFileSync(CONFIG_FILE, "utf-8"); + const json = stripJsoncComments(content); + const parsed: unknown = JSON.parse(json); + if (!isConfigObject(parsed)) { + throw new Error("expected a top-level object"); } + return { config: parsed as SupermemoryConfig, existed: true, content }; + } catch (error) { + const parseError = error instanceof Error ? error.message : String(error); + return { config: {}, existed: true, parseError }; } - return { config: {}, existed: false }; } const { config: fileConfig, existed: configExisted } = loadRawConfig(); @@ -135,9 +258,6 @@ export function getApiBaseUrl(): string { return normalized; } -export const CONFIG_FILE = CONFIG_FILES[1]; -const DEFAULT_CONFIG_FILE = CONFIG_FILE ?? join(CONFIG_DIR, "supermemory.json"); - export const CONFIG = { similarityThreshold: fileConfig.similarityThreshold ?? DEFAULTS.similarityThreshold, maxMemories: fileConfig.maxMemories ?? DEFAULTS.maxMemories, @@ -166,7 +286,45 @@ export function isConfigured(): boolean { } export function writeInstallDefaults(isExistingInstall: boolean): void { - const current = loadRawConfig().config; + const loaded = loadRawConfig(); + if (loaded.parseError) { + throw new Error(`Cannot update invalid Supermemory config at ${CONFIG_FILE}: ${loaded.parseError}`); + } + + const current = loaded.config; + const installDefaults = isExistingInstall + ? { autoRecallEveryPrompt: true, captureEveryNTurns: 3 } + : { autoRecallEveryPrompt: false, captureEveryNTurns: 0 }; + + if (CONFIG_FILE.endsWith(".jsonc") && loaded.content !== undefined) { + const missingEntries: Array = []; + if (current.autoRecallEveryPrompt === undefined) { + missingEntries.push(["autoRecallEveryPrompt", installDefaults.autoRecallEveryPrompt]); + } + if (current.captureEveryNTurns === undefined) { + missingEntries.push(["captureEveryNTurns", installDefaults.captureEveryNTurns]); + } + if (missingEntries.length === 0) return; + + const updated = insertJsoncProperties( + loaded.content, + missingEntries, + Object.keys(current).length > 0, + ); + const reparsed: unknown = JSON.parse(stripJsoncComments(updated)); + if ( + !isConfigObject(reparsed) || + Object.keys(reparsed).length !== Object.keys(current).length + missingEntries.length || + missingEntries.some(([key, value]) => reparsed[key] !== value) || + Object.entries(current).some(([key, value]) => !isDeepStrictEqual(reparsed[key], value)) + ) { + throw new Error(`Cannot safely update Supermemory JSONC config at ${CONFIG_FILE}`); + } + + writeFileSync(CONFIG_FILE, updated); + return; + } + const next: SupermemoryConfig = { ...current }; if (isExistingInstall) { if (next.autoRecallEveryPrompt === undefined) next.autoRecallEveryPrompt = true; @@ -175,5 +333,6 @@ export function writeInstallDefaults(isExistingInstall: boolean): void { next.autoRecallEveryPrompt = false; next.captureEveryNTurns = 0; } - writeFileSync(DEFAULT_CONFIG_FILE, JSON.stringify(next, null, 2)); + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2)); } diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..583c0d7 --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,389 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { stripJsoncComments } from "../src/services/jsonc.js"; + +const REPO_ROOT = join(import.meta.dir, ".."); +const temporaryHomes: string[] = []; + +interface ProbeResult { + selectedFile: string; + autoRecallEveryPrompt: boolean; + captureEveryNTurns: number; + apiKey?: string; + writeError?: string; + jsonc?: Record; + json?: Record; +} + +function makeHome(): string { + const home = mkdtempSync(join(tmpdir(), "opencode-supermemory-test-")); + temporaryHomes.push(home); + return home; +} + +function configPath(home: string, extension: "json" | "jsonc"): string { + return join(home, ".config", "opencode", `supermemory.${extension}`); +} + +function writeConfig(home: string, extension: "json" | "jsonc", content: string): void { + const path = configPath(home, extension); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); +} + +function childEnv(home: string): Record { + return { + ...process.env, + HOME: home, + SUPERMEMORY_API_KEY: "", + SUPERMEMORY_API_URL: "", + SUPERMEMORY_BASE_URL: "", + } as Record; +} + +function runConfigProbe(home: string): ProbeResult { + const script = ` + const { existsSync, readFileSync } = await import("node:fs"); + const config = await import("./src/config.ts"); + const { stripJsoncComments } = await import("./src/services/jsonc.ts"); + let writeError; + try { + config.writeInstallDefaults(existsSync(config.CONFIG_FILE)); + } catch (error) { + writeError = error instanceof Error ? error.message : String(error); + } + const read = (path) => { + if (!existsSync(path)) return undefined; + const raw = readFileSync(path, "utf-8"); + try { + return JSON.parse(stripJsoncComments(raw)); + } catch { + return { invalidRawContent: raw }; + } + }; + const dir = ${JSON.stringify(join(home, ".config", "opencode"))}; + console.log(JSON.stringify({ + selectedFile: config.CONFIG_FILE, + autoRecallEveryPrompt: config.CONFIG.autoRecallEveryPrompt, + captureEveryNTurns: config.CONFIG.captureEveryNTurns, + apiKey: config.SUPERMEMORY_API_KEY, + writeError, + jsonc: read(dir + "/supermemory.jsonc"), + json: read(dir + "/supermemory.json"), + })); + `; + const result = Bun.spawnSync({ + cmd: [process.execPath, "-e", script], + cwd: REPO_ROOT, + env: childEnv(home), + }); + + expect(result.exitCode, result.stderr.toString()).toBe(0); + return JSON.parse(result.stdout.toString()) as ProbeResult; +} + +afterEach(() => { + for (const home of temporaryHomes.splice(0)) rmSync(home, { recursive: true, force: true }); +}); + +describe("selected Supermemory config path", () => { + test("uses and updates an existing JSONC file without creating JSON", () => { + const home = makeHome(); + const original = `{ + // JSONC is the active config. + "apiKey": "sm_jsonc_1234567890", + "autoRecallEveryPrompt": false, + "captureEveryNTurns": 7, + }\n`; + writeConfig(home, "jsonc", original); + + const result = runConfigProbe(home); + + expect(basename(result.selectedFile)).toBe("supermemory.jsonc"); + expect(result.autoRecallEveryPrompt).toBe(false); + expect(result.captureEveryNTurns).toBe(7); + expect(result.jsonc).toMatchObject({ + apiKey: "sm_jsonc_1234567890", + autoRecallEveryPrompt: false, + captureEveryNTurns: 7, + }); + expect(readFileSync(configPath(home, "jsonc"), "utf-8")).toBe(original); + expect(result.json).toBeUndefined(); + }); + + const jsoncInsertionCases = [ + { + name: "leading comments before the root and nested values with LF", + prefix: "/* leading comment with { */\n// second leading comment\n", + tail: '\n "apiKey": "sm_leading",\n "nested": { "list": [1, { "value": "unchanged" }] },\n}\n', + insertion: ' "autoRecallEveryPrompt": true,\n "captureEveryNTurns": 3,\n', + expected: { + apiKey: "sm_leading", + nested: { list: [1, { value: "unchanged" }] }, + autoRecallEveryPrompt: true, + captureEveryNTurns: 3, + }, + }, + { + name: "empty one-line object without an existing-property separator", + prefix: "", + tail: "}\n", + insertion: ' "autoRecallEveryPrompt": true, "captureEveryNTurns": 3 ', + expected: { autoRecallEveryPrompt: true, captureEveryNTurns: 3 }, + }, + { + name: "comment-only object without a trailing comma", + prefix: "", + tail: "\n // keep line comment\n /* keep block comment */\n}\n", + insertion: ' "autoRecallEveryPrompt": true,\n "captureEveryNTurns": 3\n', + expected: { autoRecallEveryPrompt: true, captureEveryNTurns: 3 }, + }, + { + name: "one-line object with an existing-property separator", + prefix: "", + tail: '"apiKey":"sm_one","metadata":{"enabled":true}}\n', + insertion: ' "autoRecallEveryPrompt": true, "captureEveryNTurns": 3, ', + expected: { + apiKey: "sm_one", + metadata: { enabled: true }, + autoRecallEveryPrompt: true, + captureEveryNTurns: 3, + }, + }, + { + name: "only captureEveryNTurns missing", + prefix: "", + tail: '\n "autoRecallEveryPrompt": false,\n "apiKey": "sm_one_missing"\n}\n', + insertion: ' "captureEveryNTurns": 3,\n', + expected: { + apiKey: "sm_one_missing", + autoRecallEveryPrompt: false, + captureEveryNTurns: 3, + }, + }, + { + name: "CRLF content and trailing-comma style", + prefix: "", + tail: '\r\n\t"apiKey": "sm_crlf",\r\n}\r\n', + insertion: '\t"autoRecallEveryPrompt": true,\r\n\t"captureEveryNTurns": 3,\r\n', + expected: { + apiKey: "sm_crlf", + autoRecallEveryPrompt: true, + captureEveryNTurns: 3, + }, + }, + { + name: "blank first line uses the next content indentation", + prefix: "", + tail: '\n\n "apiKey": "sm_blank_line"\n }\n', + insertion: ' "autoRecallEveryPrompt": true,\n "captureEveryNTurns": 3,\n', + expected: { + apiKey: "sm_blank_line", + autoRecallEveryPrompt: true, + captureEveryNTurns: 3, + }, + }, + ]; + + for (const fixture of jsoncInsertionCases) { + test(`adds only missing JSONC defaults: ${fixture.name}`, () => { + const home = makeHome(); + const original = `${fixture.prefix}{${fixture.tail}`; + writeConfig(home, "jsonc", original); + + const result = runConfigProbe(home); + const raw = readFileSync(configPath(home, "jsonc"), "utf-8"); + const parsed = JSON.parse(stripJsoncComments(raw)) as Record; + let insertionOffset = 0; + while (fixture.tail[insertionOffset] === " " || fixture.tail[insertionOffset] === "\t") { + insertionOffset++; + } + if (fixture.tail.startsWith("\r\n", insertionOffset)) { + insertionOffset += 2; + } else if (fixture.tail[insertionOffset] === "\r" || fixture.tail[insertionOffset] === "\n") { + insertionOffset++; + } else { + insertionOffset = 0; + } + const preservedPrefix = `${fixture.prefix}{${fixture.tail.slice(0, insertionOffset)}`; + const preservedSuffix = fixture.tail.slice(insertionOffset); + const insertionEnd = raw.length - preservedSuffix.length; + + expect(basename(result.selectedFile)).toBe("supermemory.jsonc"); + expect(result.writeError).toBeUndefined(); + expect(raw.slice(0, preservedPrefix.length)).toBe(preservedPrefix); + expect(raw.slice(insertionEnd)).toBe(preservedSuffix); + expect(raw.slice(preservedPrefix.length, insertionEnd)).toBe(fixture.insertion); + expect(raw.split('"autoRecallEveryPrompt"').length - 1).toBe(1); + expect(raw.split('"captureEveryNTurns"').length - 1).toBe(1); + expect(parsed).toEqual(fixture.expected); + expect(result.json).toBeUndefined(); + }); + } + + test("uses and updates an existing JSON file", () => { + const home = makeHome(); + writeConfig(home, "json", JSON.stringify({ + apiKey: "sm_json_1234567890", + autoRecallEveryPrompt: true, + captureEveryNTurns: 5, + })); + + const result = runConfigProbe(home); + + expect(basename(result.selectedFile)).toBe("supermemory.json"); + expect(result.autoRecallEveryPrompt).toBe(true); + expect(result.captureEveryNTurns).toBe(5); + expect(result.json).toMatchObject({ + apiKey: "sm_json_1234567890", + autoRecallEveryPrompt: true, + captureEveryNTurns: 5, + }); + expect(result.jsonc).toBeUndefined(); + }); + + test("creates JSON with fresh-install defaults when neither file exists", () => { + const home = makeHome(); + + const result = runConfigProbe(home); + + expect(basename(result.selectedFile)).toBe("supermemory.json"); + expect(result.autoRecallEveryPrompt).toBe(false); + expect(result.captureEveryNTurns).toBe(0); + expect(result.json).toEqual({ + autoRecallEveryPrompt: false, + captureEveryNTurns: 0, + }); + expect(result.jsonc).toBeUndefined(); + }); + + test("keeps JSONC authoritative when both files exist", () => { + const home = makeHome(); + writeConfig(home, "jsonc", JSON.stringify({ + autoRecallEveryPrompt: false, + captureEveryNTurns: 8, + })); + writeConfig(home, "json", JSON.stringify({ + autoRecallEveryPrompt: true, + captureEveryNTurns: 2, + untouched: "json fallback", + })); + + const result = runConfigProbe(home); + + expect(basename(result.selectedFile)).toBe("supermemory.jsonc"); + expect(result.autoRecallEveryPrompt).toBe(false); + expect(result.captureEveryNTurns).toBe(8); + expect(result.jsonc).toMatchObject({ + autoRecallEveryPrompt: false, + captureEveryNTurns: 8, + }); + expect(result.json).toEqual({ + autoRecallEveryPrompt: true, + captureEveryNTurns: 2, + untouched: "json fallback", + }); + }); + + test("does not fall through or overwrite an invalid preferred JSONC file", () => { + const home = makeHome(); + writeConfig(home, "jsonc", "{ invalid jsonc"); + writeConfig(home, "json", JSON.stringify({ + autoRecallEveryPrompt: false, + captureEveryNTurns: 11, + untouched: "json fallback", + })); + + const result = runConfigProbe(home); + + expect(basename(result.selectedFile)).toBe("supermemory.jsonc"); + expect(result.autoRecallEveryPrompt).toBe(true); + expect(result.captureEveryNTurns).toBe(3); + expect(result.writeError).toContain(`Cannot update invalid Supermemory config at ${configPath(home, "jsonc")}`); + expect(result.jsonc).toEqual({ invalidRawContent: "{ invalid jsonc" }); + expect(result.json).toEqual({ + autoRecallEveryPrompt: false, + captureEveryNTurns: 11, + untouched: "json fallback", + }); + }); + + const nonObjectConfigCases = [ + { name: "array", content: '["not", "a", "config"]\n' }, + { name: "string", content: '"not a config"\n' }, + { name: "number", content: "42\n" }, + { name: "null", content: "null\n" }, + ]; + + for (const extension of ["json", "jsonc"] as const) { + for (const fixture of nonObjectConfigCases) { + test(`rejects ${fixture.name} root in selected ${extension.toUpperCase()} without rewriting it`, () => { + const home = makeHome(); + writeConfig(home, extension, fixture.content); + + const result = runConfigProbe(home); + + expect(basename(result.selectedFile)).toBe(`supermemory.${extension}`); + expect(result.writeError).toContain("expected a top-level object"); + expect(readFileSync(configPath(home, extension), "utf-8")).toBe(fixture.content); + expect(existsSync(configPath(home, extension === "json" ? "jsonc" : "json"))).toBe(false); + }); + } + } +}); + +test("install stops without changing an invalid selected config", () => { + const home = makeHome(); + const path = configPath(home, "jsonc"); + const invalidContent = "{ invalid jsonc"; + writeConfig(home, "jsonc", invalidContent); + + const result = Bun.spawnSync({ + cmd: [process.execPath, "src/cli.ts", "install", "--no-tui"], + cwd: REPO_ROOT, + env: childEnv(home), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain(`Cannot update invalid Supermemory config at ${path}`); + expect(readFileSync(path, "utf-8")).toBe(invalidContent); + expect(existsSync(configPath(home, "json"))).toBe(false); +}); + +test("status reports the selected JSONC source without revealing its API key", () => { + const home = makeHome(); + const apiKey = "sm_status_1234567890"; + const fallbackApiKey = "sm_fallback_1234567890"; + const path = configPath(home, "jsonc"); + writeConfig(home, "jsonc", `{ + // The status command must parse JSONC through the same selected path. + "apiKey": "${apiKey}", + "baseUrl": "http://127.0.0.1:1" + }`); + writeConfig(home, "json", JSON.stringify({ + apiKey: fallbackApiKey, + baseUrl: "http://127.0.0.1:2", + })); + + const script = ` + globalThis.fetch = async () => new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }); + process.argv = [process.execPath, "src/cli.ts", "status"]; + await import("./src/cli.ts"); + `; + const result = Bun.spawnSync({ + cmd: [process.execPath, "-e", script], + cwd: REPO_ROOT, + env: childEnv(home), + }); + const output = result.stdout.toString(); + + expect(result.exitCode, result.stderr.toString()).toBe(0); + expect(output).toContain(`API key: sm_sta...7890 (${path})`); + expect(output).not.toContain(apiKey); + expect(output).not.toContain(fallbackApiKey); +});