diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 9fdb0b98..7a89ae70 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "GitHits plugins for Claude Code - code examples from global open source", - "version": "0.2.1" + "version": "0.2.2" }, "plugins": [ { diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 4b27f711..9be464a1 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "githits", - "version": "0.2.1", + "version": "0.2.2", "description": "Code examples from global open source for developers and AI assistants", "author": { "name": "GitHits" diff --git a/.plugin/plugin.json b/.plugin/plugin.json index 4b27f711..9be464a1 100644 --- a/.plugin/plugin.json +++ b/.plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "githits", - "version": "0.2.1", + "version": "0.2.2", "description": "Code examples from global open source for developers and AI assistants", "author": { "name": "GitHits" diff --git a/bun.lock b/bun.lock index 8fa77f13..522aa183 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@modelcontextprotocol/sdk": "^1.23.0", "@napi-rs/keyring": "^1.2.0", "commander": "^14.0.2", + "jsonc-parser": "^3.3.1", "open": "^11.0.0", "zod": "^4.1.13", }, @@ -395,6 +396,8 @@ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], diff --git a/docs/implementation/config.md b/docs/implementation/config.md index c78ed55d..f0cd3cf8 100644 --- a/docs/implementation/config.md +++ b/docs/implementation/config.md @@ -90,6 +90,14 @@ Commands receive the full `Dependencies` object. Services receive only what they - **Custom environment not working** — Make sure both `GITHITS_MCP_URL` and `GITHITS_API_URL` are set. They point to different services. - **Tokens from wrong environment** — Tokens are stored per MCP URL. If you switched `GITHITS_MCP_URL`, you need to re-authenticate for the new URL. +### Init config parsing behavior + +`githits init` now accepts both strict JSON and JSONC-style config files when reading agent MCP config files (for example files containing comments or trailing commas). + +- Parsing flow first attempts strict JSON, then falls back to JSONC parsing. +- If parsing still fails, setup reports a parse error and leaves the file unchanged. +- Successful writes are still emitted as canonical JSON with 2-space indentation and a trailing newline. + ## Key Reference Files | File | What it demonstrates | diff --git a/gemini-extension.json b/gemini-extension.json index c9d927e6..6d91c623 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "githits", - "version": "0.2.1", + "version": "0.2.2", "description": "Code examples from global open source for developers and AI assistants.", "mcpServers": { "githits": { diff --git a/package.json b/package.json index b75094c1..a8912b4c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "githits", "description": "CLI companion for GitHits - code examples from global open source for developers and AI assistants", - "version": "0.2.1", + "version": "0.2.2", "type": "module", "files": [ "dist", @@ -72,6 +72,7 @@ "@modelcontextprotocol/sdk": "^1.23.0", "@napi-rs/keyring": "^1.2.0", "commander": "^14.0.2", + "jsonc-parser": "^3.3.1", "open": "^11.0.0", "zod": "^4.1.13" }, diff --git a/plugins/claude/.claude-plugin/plugin.json b/plugins/claude/.claude-plugin/plugin.json index 4b27f711..9be464a1 100644 --- a/plugins/claude/.claude-plugin/plugin.json +++ b/plugins/claude/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "githits", - "version": "0.2.1", + "version": "0.2.2", "description": "Code examples from global open source for developers and AI assistants", "author": { "name": "GitHits" diff --git a/src/commands/init/agent-definitions.test.ts b/src/commands/init/agent-definitions.test.ts index f9c49dec..3eb514cb 100644 --- a/src/commands/init/agent-definitions.test.ts +++ b/src/commands/init/agent-definitions.test.ts @@ -86,10 +86,105 @@ describe("detection configuration", () => { expect(paths).toEqual(["/home/test/.gemini/antigravity"]); }); - it("opencode uses binary detection only", () => { + it("opencode uses hybrid detection", () => { const agent = agentDefinitions.find((a) => a.id === "opencode")!; expect(agent.detectBinary).toBeDefined(); - expect(agent.detectPaths).toBeUndefined(); + expect(agent.detectPaths).toBeDefined(); + }); + + it("opencode includes desktop and config detection paths on linux", () => { + const originalPlatform = process.platform; + const originalXdgDataHome = process.env.XDG_DATA_HOME; + Object.defineProperty(process, "platform", { + value: "linux", + configurable: true, + }); + process.env.XDG_DATA_HOME = "/home/test/.local/share"; + try { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "opencode")!; + const paths = agent.detectPaths?.(fs); + expect(paths).toEqual([ + "/home/test/.local/share/ai.opencode.desktop", + "/home/test/.local/share/ai.opencode.desktop.beta", + "/home/test/.local/share/ai.opencode.desktop.dev", + "/home/test/.config/opencode", + ]); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + if (originalXdgDataHome !== undefined) { + process.env.XDG_DATA_HOME = originalXdgDataHome; + } else { + delete process.env.XDG_DATA_HOME; + } + } + }); + + it("opencode includes desktop and config detection paths on darwin", () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { + value: "darwin", + configurable: true, + }); + try { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "opencode")!; + const paths = agent.detectPaths?.(fs); + expect(paths).toEqual([ + "/home/test/Library/Application Support/ai.opencode.desktop", + "/home/test/Library/Application Support/ai.opencode.desktop.beta", + "/home/test/Library/Application Support/ai.opencode.desktop.dev", + "/home/test/.config/opencode", + ]); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + } + }); + + it("opencode includes desktop and config detection paths on win32", () => { + const originalPlatform = process.platform; + const originalAppdata = process.env.APPDATA; + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }); + process.env.APPDATA = "C:\\Users\\test\\AppData\\Roaming"; + try { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "C:\\Users\\test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "opencode")!; + const paths = agent.detectPaths?.(fs); + expect(paths).toEqual([ + "C:\\Users\\test\\AppData\\Roaming/ai.opencode.desktop", + "C:\\Users\\test\\AppData\\Roaming/ai.opencode.desktop.beta", + "C:\\Users\\test\\AppData\\Roaming/ai.opencode.desktop.dev", + "C:\\Users\\test\\AppData\\Roaming/opencode", + ]); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + if (originalAppdata !== undefined) { + process.env.APPDATA = originalAppdata; + } else { + delete process.env.APPDATA; + } + } }); it("claude-desktop checks multiple Windows paths on win32", () => { @@ -311,14 +406,16 @@ describe("detection configuration", () => { expect(await agent.detectBinary!(exec)).toBe(false); }); - it("path-detected agents use FileSystemService.getHomeDir (not hardcoded)", () => { + it("path/hybrid-detected agents use FileSystemService.getHomeDir (not hardcoded)", () => { const originalPlatform = process.platform; const originalAppdata = process.env.APPDATA; + const originalXdgDataHome = process.env.XDG_DATA_HOME; Object.defineProperty(process, "platform", { value: "linux", configurable: true, }); delete process.env.APPDATA; + delete process.env.XDG_DATA_HOME; try { const fs = createMockFileSystemService({ getHomeDir: mock(() => "/custom/home"), @@ -343,6 +440,11 @@ describe("detection configuration", () => { } else { delete process.env.APPDATA; } + if (originalXdgDataHome !== undefined) { + process.env.XDG_DATA_HOME = originalXdgDataHome; + } else { + delete process.env.XDG_DATA_HOME; + } } }); }); @@ -769,12 +871,12 @@ describe("detectAgents", () => { isDirectory: mock(() => Promise.resolve(true)), }); const detected = await detectAgents(agentDefinitions, fs); - // detectAgents (deprecated) only checks path-based agents - expect(detected).toHaveLength(6); + // detectAgents (deprecated) checks path and hybrid agents + expect(detected).toHaveLength(7); expect(detected).not.toContain("claude-code"); expect(detected).not.toContain("codex-cli"); expect(detected).not.toContain("gemini-cli"); - expect(detected).not.toContain("opencode"); + expect(detected).toContain("opencode"); }); }); @@ -978,7 +1080,7 @@ describe("scanAgents", () => { expect(result.notDetected.some((a) => a.id === "codex-cli")).toBe(false); }); - it("does not detect opencode from config directory when binary is missing", async () => { + it("detects opencode from config directory when binary is missing", async () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "linux", @@ -994,8 +1096,59 @@ describe("scanAgents", () => { }, }); const result = await scanAgents(agentDefinitions, fs, execService); - expect(result.notDetected.some((a) => a.id === "opencode")).toBe(true); - expect(result.needsSetup.some((a) => a.id === "opencode")).toBe(false); + expect(result.notDetected.some((a) => a.id === "opencode")).toBe(false); + expect(result.needsSetup.some((a) => a.id === "opencode")).toBe(true); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + } + }); + + it("detects opencode from desktop app data directory when binary is missing", async () => { + const originalPlatform = process.platform; + const originalXdgDataHome = process.env.XDG_DATA_HOME; + Object.defineProperty(process, "platform", { + value: "linux", + configurable: true, + }); + process.env.XDG_DATA_HOME = "/home/test/.local/share"; + try { + const { fs, execService } = createScanMocks({ + detectedDirs: ["/home/test/.local/share/ai.opencode.desktop"], + }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.notDetected.some((a) => a.id === "opencode")).toBe(false); + expect(result.needsSetup.some((a) => a.id === "opencode")).toBe(true); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + if (originalXdgDataHome !== undefined) { + process.env.XDG_DATA_HOME = originalXdgDataHome; + } else { + delete process.env.XDG_DATA_HOME; + } + } + }); + + it("detects opencode from desktop app data directory on darwin when binary is missing", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { + value: "darwin", + configurable: true, + }); + try { + const { fs, execService } = createScanMocks({ + detectedDirs: [ + "/home/test/Library/Application Support/ai.opencode.desktop", + ], + }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.notDetected.some((a) => a.id === "opencode")).toBe(false); + expect(result.needsSetup.some((a) => a.id === "opencode")).toBe(true); } finally { Object.defineProperty(process, "platform", { value: originalPlatform, @@ -1004,6 +1157,34 @@ describe("scanAgents", () => { } }); + it("detects opencode from desktop app data directory on win32 when binary is missing", async () => { + const originalPlatform = process.platform; + const originalAppdata = process.env.APPDATA; + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }); + process.env.APPDATA = "C:\\Users\\test\\AppData\\Roaming"; + try { + const { fs, execService } = createScanMocks({ + detectedDirs: ["C:\\Users\\test\\AppData\\Roaming/ai.opencode.desktop"], + }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.notDetected.some((a) => a.id === "opencode")).toBe(false); + expect(result.needsSetup.some((a) => a.id === "opencode")).toBe(true); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + if (originalAppdata !== undefined) { + process.env.APPDATA = originalAppdata; + } else { + delete process.env.APPDATA; + } + } + }); + it("categorizes undetected agent as notDetected", async () => { const { fs, execService } = createScanMocks({ detectedDirs: [] }); const result = await scanAgents(agentDefinitions, fs, execService); diff --git a/src/commands/init/agent-definitions.ts b/src/commands/init/agent-definitions.ts index a252fcd7..e6a9093e 100644 --- a/src/commands/init/agent-definitions.ts +++ b/src/commands/init/agent-definitions.ts @@ -52,7 +52,7 @@ const GITHITS_MCP_INVOCATION = [ ] as const; /** How an agent is considered present on the machine. */ -type DetectionMethod = "binary" | "path"; +type DetectionMethod = "binary" | "path" | "hybrid"; /** * Represents a coding agent that can be configured with GitHits MCP server. @@ -66,9 +66,9 @@ export interface AgentDefinition { id: string; /** Detection contract for the agent. */ detectionMethod: DetectionMethod; - /** Directories to check for path-based detection. */ + /** Directories to check for path/hybrid detection. */ detectPaths?: (fs: FileSystemService) => string[]; - /** Executable detection for binary-based agents. */ + /** Executable detection for binary/hybrid agents. */ detectBinary?: (exec: ExecService) => Promise; /** How this agent is configured */ setupMethod: "cli" | "config-file"; @@ -97,6 +97,41 @@ function getAppDataPath(fs: FileSystemService, appName: string): string { } } +/** + * Returns platform-specific user data directory root. + * macOS: ~/Library/Application Support + * Windows: %APPDATA% (or ~/AppData/Roaming fallback) + * Linux: $XDG_DATA_HOME (or ~/.local/share fallback) + */ +function getUserDataRoot(fs: FileSystemService): string { + const home = fs.getHomeDir(); + switch (process.platform) { + case "win32": + return process.env.APPDATA ?? fs.joinPath(home, "AppData", "Roaming"); + case "darwin": + return fs.joinPath(home, "Library", "Application Support"); + default: + return process.env.XDG_DATA_HOME ?? fs.joinPath(home, ".local", "share"); + } +} + +function getOpenCodeConfigDir(fs: FileSystemService): string { + if (process.platform === "win32") { + return fs.joinPath(getUserDataRoot(fs), "opencode"); + } + return fs.joinPath(fs.getHomeDir(), ".config", "opencode"); +} + +function getOpenCodeDesktopDetectPaths(fs: FileSystemService): string[] { + const userDataRoot = getUserDataRoot(fs); + return [ + fs.joinPath(userDataRoot, "ai.opencode.desktop"), + fs.joinPath(userDataRoot, "ai.opencode.desktop.beta"), + fs.joinPath(userDataRoot, "ai.opencode.desktop.dev"), + getOpenCodeConfigDir(fs), + ]; +} + /** * Detect whether an executable is available on PATH. * This is the install signal for CLI-configured agents. @@ -364,24 +399,17 @@ const googleAntigravity: AgentDefinition = { }), }; -/** OpenCode: detected by opencode executable, configured via config file */ +/** OpenCode: detected by CLI binary or desktop/config directories, configured via config file */ const openCode: AgentDefinition = { name: "OpenCode", id: "opencode", - detectionMethod: "binary", + detectionMethod: "hybrid", setupMethod: "config-file", + detectPaths: (fs) => getOpenCodeDesktopDetectPaths(fs), detectBinary: async (exec) => isExecutableAvailable(exec, "opencode"), getSetupConfig: (fs) => ({ method: "config-file", - configPath: - process.platform === "win32" - ? fs.joinPath( - process.env.APPDATA ?? - fs.joinPath(fs.getHomeDir(), "AppData", "Roaming"), - "opencode", - "opencode.json", - ) - : fs.joinPath(fs.getHomeDir(), ".config", "opencode", "opencode.json"), + configPath: fs.joinPath(getOpenCodeConfigDir(fs), "opencode.json"), serversKey: "mcp", serverName: GITHITS_SERVER_NAME, serverConfig: { @@ -411,8 +439,9 @@ export const agentDefinitions: AgentDefinition[] = [ ]; /** - * Detect which path-based agents are present by checking if their detection - * directories exist. Binary-based agents are intentionally ignored here. + * Detect agents that expose directory probes (path + hybrid) by checking if + * their detection directories exist. Binary checks are intentionally skipped + * here and only performed by scanAgents(). * @deprecated Use scanAgents() instead, which also checks configuration status. */ export async function detectAgents( @@ -421,7 +450,11 @@ export async function detectAgents( ): Promise { const detected: string[] = []; for (const agent of definitions) { - if (agent.detectionMethod !== "path" || !agent.detectPaths) { + if ( + (agent.detectionMethod !== "path" && + agent.detectionMethod !== "hybrid") || + !agent.detectPaths + ) { continue; } const paths = agent.detectPaths(fs); @@ -480,6 +513,29 @@ export async function scanAgents( break; } } + } else if (agent.detectionMethod === "hybrid") { + let binaryDetected = false; + let pathDetected = false; + + if (agent.detectBinary) { + try { + binaryDetected = await agent.detectBinary(execService); + } catch { + binaryDetected = false; + } + } + + if (!binaryDetected && agent.detectPaths) { + const paths = agent.detectPaths(fs); + for (const path of paths) { + if (await fs.isDirectory(path)) { + pathDetected = true; + break; + } + } + } + + detected = binaryDetected || pathDetected; } if (!detected) { diff --git a/src/commands/init/setup-handlers.test.ts b/src/commands/init/setup-handlers.test.ts index a74737dc..9defb770 100644 --- a/src/commands/init/setup-handlers.test.ts +++ b/src/commands/init/setup-handlers.test.ts @@ -6,6 +6,7 @@ import { import type { CliSetup, ConfigFileSetup } from "./agent-definitions.js"; import type { CliCheckCommand, MergeResult } from "./setup-handlers.js"; import { + detectConfigFormat, executeCliSetup, executeConfigFileSetup, formatSetupPreview, @@ -15,6 +16,29 @@ import { mergeServerConfig, } from "./setup-handlers.js"; +describe("detectConfigFormat", () => { + it("returns json for strict JSON", () => { + expect(detectConfigFormat('{"mcpServers": {}}')).toBe("json"); + }); + + it("returns jsonc for JSONC with comments and trailing commas", () => { + const content = `{ + // comment + "mcpServers": { + "GitHits": { + "command": "npx", + "args": ["-y", "githits@latest", "mcp", "start",], + }, + }, + }`; + expect(detectConfigFormat(content)).toBe("jsonc"); + }); + + it("returns invalid for malformed content", () => { + expect(detectConfigFormat("{invalid json")).toBe("invalid"); + }); +}); + /** Assert that a MergeResult is "added" and return its content */ function expectAdded(result: MergeResult): string { expect(result.status).toBe("added"); @@ -192,6 +216,22 @@ describe("isAlreadyConfigured", () => { expect(await isAlreadyConfigured(configSetup, fs)).toBe(false); }); + it("returns true for JSONC config with comments and trailing commas", async () => { + const existing = `{ + // VS Code style config + "mcpServers": { + "GitHits": { + "command": "npx", + "args": ["-y", "githits@latest", "mcp", "start",], + }, + }, + }`; + const fs = createMockFileSystemService({ + readFile: mock(() => Promise.resolve(existing)), + }); + expect(await isAlreadyConfigured(configSetup, fs)).toBe(true); + }); + it("returns false when serversKey is missing", async () => { const existing = JSON.stringify({ otherKey: {} }); const fs = createMockFileSystemService({ @@ -657,6 +697,25 @@ describe("mergeServerConfig", () => { expect(error).toContain("Invalid JSON"); }); + it("parses JSONC with comments and trailing commas", () => { + const existing = `{ + // existing MCP servers + "mcpServers": { + "other": { "command": "other" }, + }, + }`; + const result = mergeServerConfig( + existing, + "mcpServers", + "GitHits", + serverConfig, + ); + const content = expectAdded(result); + const parsed = JSON.parse(content); + expect(parsed.mcpServers.other).toEqual({ command: "other" }); + expect(parsed.mcpServers.GitHits).toEqual(serverConfig); + }); + it("returns parse_error when root is not an object", () => { const result = mergeServerConfig( "[1,2,3]", @@ -1071,7 +1130,8 @@ describe("executeConfigFileSetup", () => { const result = await executeConfigFileSetup(configSetup, fs); expect(result.status).toBe("success"); expect(atomicWrite).toHaveBeenCalledTimes(1); - const firstCall = atomicWrite.mock.calls[0]; + const calls = atomicWrite.mock.calls as unknown[][]; + const firstCall = calls[0]; expect(firstCall).toBeDefined(); const writtenContent = firstCall?.[1]; expect(typeof writtenContent).toBe("string"); @@ -1147,6 +1207,37 @@ describe("executeConfigFileSetup", () => { expect(atomicWrite).not.toHaveBeenCalled(); }); + it("supports JSONC config in executeConfigFileSetup", async () => { + const existing = `{ + // existing server + "mcpServers": { + "other": { "command": "other" }, + }, + }`; + const atomicWrite = mock(() => Promise.resolve()); + const fs = createMockFileSystemService({ + readFile: mock(() => Promise.resolve(existing)), + getDirname: mock(() => "/home/test/.cursor"), + ensureDir: mock(() => Promise.resolve()), + atomicWriteFile: atomicWrite, + }); + + const result = await executeConfigFileSetup(configSetup, fs); + expect(result.status).toBe("success"); + expect(atomicWrite).toHaveBeenCalledTimes(1); + const calls = atomicWrite.mock.calls as unknown[][]; + const firstCall = calls[0]; + expect(firstCall).toBeDefined(); + const writtenContent = firstCall?.[1]; + expect(typeof writtenContent).toBe("string"); + if (typeof writtenContent !== "string") { + throw new Error("Expected written config content"); + } + const parsed = JSON.parse(writtenContent); + expect(parsed.mcpServers.other).toEqual({ command: "other" }); + expect(parsed.mcpServers.GitHits).toEqual(configSetup.serverConfig); + }); + it("ensures parent directory exists before writing", async () => { const enoent = Object.assign(new Error("File not found"), { code: "ENOENT", diff --git a/src/commands/init/setup-handlers.ts b/src/commands/init/setup-handlers.ts index 91f11748..4624266b 100644 --- a/src/commands/init/setup-handlers.ts +++ b/src/commands/init/setup-handlers.ts @@ -1,3 +1,8 @@ +import { + type ParseError, + parse as parseJsonc, + printParseErrorCode, +} from "jsonc-parser"; import type { ExecService } from "../../services/exec-service.js"; import type { FileSystemService } from "../../services/filesystem-service.js"; import type { @@ -34,6 +39,90 @@ export type MergeResult = | { status: "already_configured" } | { status: "parse_error"; error: string }; +export type ConfigFormat = "json" | "jsonc" | "invalid"; + +type ParsedConfigResult = + | { + format: "json" | "jsonc"; + value: Record; + } + | { + format: "invalid"; + error: string; + }; + +function parseConfigObject(content: string): ParsedConfigResult { + let normalizedContent = content; + if (normalizedContent.charCodeAt(0) === 0xfeff) { + normalizedContent = normalizedContent.slice(1); + } + + const trimmed = normalizedContent.trim(); + if (trimmed === "") { + return { + format: "json", + value: {}, + }; + } + + try { + const parsed = JSON.parse(normalizedContent); + if (!isPlainObject(parsed)) { + return { + format: "invalid", + error: "Config file root is not a JSON object", + }; + } + return { + format: "json", + value: parsed, + }; + } catch (jsonError) { + const parseErrors: ParseError[] = []; + const parsed = parseJsonc(normalizedContent, parseErrors, { + allowTrailingComma: true, + disallowComments: false, + allowEmptyContent: false, + }); + + if (parseErrors.length > 0) { + const firstParseError = parseErrors[0]; + const strictErrorMessage = + jsonError instanceof Error ? jsonError.message : String(jsonError); + const jsoncDetail = firstParseError + ? `${printParseErrorCode(firstParseError.error)} at offset ${firstParseError.offset}` + : "Unknown parse error"; + return { + format: "invalid", + error: `Invalid JSON: ${strictErrorMessage}. JSONC parse error: ${jsoncDetail}`, + }; + } + + if (!isPlainObject(parsed)) { + return { + format: "invalid", + error: "Config file root is not a JSON object", + }; + } + + return { + format: "jsonc", + value: parsed, + }; + } +} + +/** + * Detect whether config content is strict JSON, JSONC, or invalid. + * + * Used by tests and diagnostics to assert parser behavior independently from + * merge/check flows. + */ +export function detectConfigFormat(content: string): ConfigFormat { + const parsed = parseConfigObject(content); + return parsed.format; +} + function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -219,36 +308,14 @@ export function mergeServerConfig( serverName: string, serverConfig: Record, ): MergeResult { - // Strip BOM if present - let content = existingContent; - if (content.charCodeAt(0) === 0xfeff) { - content = content.slice(1); - } - - // Handle empty content - const trimmed = content.trim(); - if (trimmed === "") { - content = "{}"; - } - - // Parse existing JSON - let config: Record; - try { - config = JSON.parse(content); - } catch (err) { + const parsedConfig = parseConfigObject(existingContent); + if (parsedConfig.format === "invalid") { return { status: "parse_error", - error: `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`, - }; - } - - // Ensure it's an object - if (typeof config !== "object" || config === null || Array.isArray(config)) { - return { - status: "parse_error", - error: "Config file root is not a JSON object", + error: parsedConfig.error, }; } + const config = parsedConfig.value; // Get or create the servers section if (!(serversKey in config)) { @@ -319,31 +386,12 @@ export async function isAlreadyConfigured( fs: FileSystemService, ): Promise { try { - let content: string; - try { - content = await fs.readFile(config.configPath); - } catch { - return false; - } - - // Strip BOM if present - if (content.charCodeAt(0) === 0xfeff) { - content = content.slice(1); - } - - const trimmed = content.trim(); - if (trimmed === "") { - return false; - } - - const parsed = JSON.parse(trimmed); - if ( - typeof parsed !== "object" || - parsed === null || - Array.isArray(parsed) - ) { + const content = await fs.readFile(config.configPath); + const parsedConfig = parseConfigObject(content); + if (parsedConfig.format === "invalid") { return false; } + const parsed = parsedConfig.value; const servers = parsed[config.serversKey]; if (