From 3bcf3cb9360ceb3cf41583aa8ea73c16142d51e1 Mon Sep 17 00:00:00 2001 From: Nathan Burg Date: Mon, 27 Apr 2026 17:23:23 -0700 Subject: [PATCH 1/3] fix(init): parse JSONC agent config files Allow init to read JSONC-style MCP config files so setup does not fail on comments or trailing commas. This keeps malformed files safe by returning parse errors without modifying existing content. --- bun.lock | 3 + docs/implementation/config.md | 8 ++ package.json | 1 + src/commands/init/setup-handlers.test.ts | 93 ++++++++++++++- src/commands/init/setup-handlers.ts | 146 +++++++++++++++-------- 5 files changed, 201 insertions(+), 50 deletions(-) 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/package.json b/package.json index b75094c1..0ed0e500 100644 --- a/package.json +++ b/package.json @@ -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/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 ( From 46526dd394c9f42e970f9733461e22a042fd2eb2 Mon Sep 17 00:00:00 2001 From: Nathan Burg Date: Mon, 27 Apr 2026 17:23:28 -0700 Subject: [PATCH 2/3] fix(init): detect OpenCode from CLI or Desktop Use hybrid detection for OpenCode so init recognizes both CLI installs and desktop app data directories. This prevents missing setup prompts when only Desktop is installed. --- src/commands/init/agent-definitions.test.ts | 199 +++++++++++++++++++- src/commands/init/agent-definitions.ts | 90 +++++++-- 2 files changed, 263 insertions(+), 26 deletions(-) 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) { From d561e85f4674111aca0f5c58cf318442349488c5 Mon Sep 17 00:00:00 2001 From: Nathan Burg Date: Mon, 27 Apr 2026 18:03:30 -0700 Subject: [PATCH 3/3] chore: bump package version --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .plugin/plugin.json | 2 +- gemini-extension.json | 2 +- package.json | 2 +- plugins/claude/.claude-plugin/plugin.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) 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/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 0ed0e500..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", 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"