diff --git a/CLAUDE.md b/CLAUDE.md index 08579321..1776b08a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,7 +90,7 @@ YAML frontmatter (`id`, `title`, `domain`, `rules`, optional `files`). Sections: Editor integrations share the `EditorTarget` union. Adding a new editor requires coordinated edits — missing any one breaks detection, init, or tests: 1. `src/helpers/init-project.ts` — extend `EditorTarget` union, `EDITOR_LABELS`, the `configureEditorSettings` switch, and (when authenticated install applies) the `tryInstallPlugin` branch -2. `src/helpers/plugin-install.ts` — add `isCliAvailable()` and any install/download helper. For tarball-based editors (no marketplace CLI), use `installEditorPluginBundle()` — it handles directory creation, old-file cleanup, and tarball extraction in one call +2. `src/helpers/plugin-install.ts` — add `isCliAvailable()` and any install/download helper. For tarball-based editors (no marketplace CLI), use `installEditorPluginBundle()` — it handles directory creation, old-file cleanup, and tarball extraction in one call. If the editor also ships a GUI/Desktop distribution with no CLI binary at all, add a broader `isAvailable()` that OR's in a shared-state fallback (e.g., the editor's user-scope config directory already existing) — see the opencode Desktop note below 3. `src/helpers/editor-detect.ts` — append to the `Promise.all` and the returned array 4. `src/commands/init.ts` — extend `EDITOR_DIRS`, `SIGNUP_EDITORS`, the `--editor` `.choices([...] as const)`, and `printManualInstructions` 5. `src/commands/plugin/install.ts` — extend `.choices([...] as const)` and add a case to `installForEditor` + the manual-instructions `catch` @@ -100,3 +100,5 @@ Editor integrations share the `EditorTarget` union. Adding a new editor requires User-scope editors (e.g., opencode) write to a path resolved in `paths.ts` rather than the project tree — `configureEditorSettings` returns that path for the init summary and the real work happens in `tryInstallPlugin`. **Match the target editor's actual path resolution — don't assume Windows conventions.** opencode uses the `xdg-basedir` npm package, which falls back to `~/.config` on **all platforms** (including Windows, where it resolves to `C:\Users\\.config\…`, not `%APPDATA%\…`). `opencodeAgentsDir()` must mirror that exact logic or the CLI writes files the editor can't find. When adding a user-scope editor, verify the editor's path helper in its source before writing the resolver. + +**opencode ships two distributions — CLI detection alone misses the Desktop app.** The `opencode` CLI is one distribution; the opencode Desktop app (Electron-based, e.g. `@opencode-aidesktop` on Windows) is another, and it ships **no CLI binary at all** — `isOpencodeCliAvailable()` (a PATH check via `resolveCommand`) can never detect it. Both distributions read/write the same `opencodeConfigDir()` (`~/.config/opencode/`), so `isOpencodeAvailable()` in `plugin-install.ts` also treats that directory's existence as a valid installed-opencode signal. All three call sites (`editor-detect.ts`, `init-project.ts`, `commands/plugin/install.ts`) use `isOpencodeAvailable()`, not the narrower CLI-only check — use the broader one for any new opencode-gated behavior too. diff --git a/src/commands/init.ts b/src/commands/init.ts index 797f35b7..7c38c333 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -324,12 +324,13 @@ function printManualInstructions(editor: EditorTarget, detail?: string): void { console.log(` ${styleText("bold", "copilot plugin install")} ${detail}`); break; case "opencode": - // `cli-not-found` is the sentinel set by `tryInstallPlugin` in - // init-project.ts when the `opencode` binary is not on PATH. All other + // `not-found` is the sentinel set by `tryInstallPlugin` in + // init-project.ts when neither the `opencode` CLI nor a Desktop app + // install (detected via its shared config dir) is present. All other // values are error messages from a failed download/extract. - if (detail === "cli-not-found") { + if (detail === "not-found") { logWarn( - "opencode CLI not found on PATH — skipping agent install.", + "opencode not found on this machine — skipping agent install.", "Install opencode from https://opencode.ai/docs/, then run:" ); console.log( diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts index cb6f8f47..c76021bf 100644 --- a/src/commands/plugin/install.ts +++ b/src/commands/plugin/install.ts @@ -24,7 +24,7 @@ import { installVscodeExtension, isClaudeCliAvailable, isCopilotCliAvailable, - isOpencodeCliAvailable, + isOpencodeAvailable, isVscodeCliAvailable, } from "../../helpers/plugin-install"; import { configureVscodeSettings } from "../../helpers/vscode-settings"; @@ -92,11 +92,13 @@ export async function installForEditor( } case "opencode": { // Writing files to `~/.config/opencode/{agents,skills}/` is only - // useful if opencode is actually installed. Skip the install and + // useful if opencode is actually installed. `isOpencodeAvailable()` + // recognizes both the CLI (on PATH) and the Desktop app (no CLI, but + // shares the same user-scope config directory). Skip the install and // surface a clear message otherwise, matching every other editor's guard. - if (!(await isOpencodeCliAvailable())) { + if (!(await isOpencodeAvailable())) { logWarn( - "opencode CLI not found on PATH — skipping plugin install.", + "opencode not found on this machine — skipping plugin install.", "Install opencode from https://opencode.ai/docs/, then re-run:" ); console.log( diff --git a/src/helpers/editor-detect.ts b/src/helpers/editor-detect.ts index 4f582f7a..6ea2bc03 100644 --- a/src/helpers/editor-detect.ts +++ b/src/helpers/editor-detect.ts @@ -14,7 +14,7 @@ import { isClaudeCliAvailable, isCopilotCliAvailable, isCursorCliAvailable, - isOpencodeCliAvailable, + isOpencodeAvailable, isVscodeCliAvailable, } from "./plugin-install"; import { withPromptFix } from "./prompt"; @@ -37,7 +37,7 @@ export async function detectEditors(): Promise { isCursorCliAvailable(), isVscodeCliAvailable(), isCopilotCliAvailable(), - isOpencodeCliAvailable(), + isOpencodeAvailable(), ]); logDebug("Editor detection:", { claude, cursor, vscode, copilot, opencode }); diff --git a/src/helpers/init-project.ts b/src/helpers/init-project.ts index 850df571..fb479ed4 100644 --- a/src/helpers/init-project.ts +++ b/src/helpers/init-project.ts @@ -290,19 +290,21 @@ async function tryInstallPlugin(editor: EditorTarget): Promise { } if (editor === "opencode") { - const { isOpencodeCliAvailable, installOpencodePlugin } = + const { isOpencodeAvailable, installOpencodePlugin } = await import("./plugin-install"); // Writing agent markdown to `~/.config/opencode/agents/` is only useful - // if opencode itself is on PATH — otherwise we leave stale files in a - // directory nothing reads. Mirror the detect-before-install guard that - // every other editor's install path already uses. - if (!(await isOpencodeCliAvailable())) { + // if opencode is actually installed — otherwise we leave stale files in + // a directory nothing reads. `isOpencodeAvailable()` recognizes both the + // CLI (on PATH) and the Desktop app (no CLI, but shares the same + // user-scope config directory), mirroring the detect-before-install + // guard that every other editor's install path already uses. + if (!(await isOpencodeAvailable())) { return { installed: true, - // `cli-not-found` is a marker recognized by `printManualInstructions` + // `not-found` is a marker recognized by `printManualInstructions` // in `commands/init.ts`; the user-facing message lives there. - detail: "cli-not-found", + detail: "not-found", }; } diff --git a/src/helpers/plugin-install.ts b/src/helpers/plugin-install.ts index 73f516dd..030637e1 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -312,6 +312,23 @@ export async function isOpencodeCliAvailable(): Promise { return resolved !== null; } +/** + * Check whether opencode is installed in any form — the CLI on PATH, or the + * opencode Desktop app (Electron-based GUI, ships no CLI binary at all). + * + * Both distributions read agents/skills from the same user-scope config + * directory (`opencodeConfigDir()` — see its doc comment for the exact + * resolution rules), so a directory that already exists there is reliable + * evidence opencode has been run and initialized on this machine, even when + * `isOpencodeCliAvailable()` finds nothing. This is what call sites should + * use to decide whether to attempt the plugin install, which itself never + * shells out to a CLI — it only writes files into that shared directory. + */ +export async function isOpencodeAvailable(): Promise { + if (await isOpencodeCliAvailable()) return true; + return existsSync(opencodeConfigDir()); +} + /** * Install archgate agents and skills into opencode's user-scope directories. * diff --git a/tests/commands/plugin/install.test.ts b/tests/commands/plugin/install.test.ts index b9c693bb..b742de3c 100644 --- a/tests/commands/plugin/install.test.ts +++ b/tests/commands/plugin/install.test.ts @@ -27,7 +27,7 @@ const mockInstallCursorPlugin = mock((_token: string) => Promise.resolve()); const mockIsClaudeCliAvailable = mock(() => Promise.resolve(false)); const mockIsCopilotCliAvailable = mock(() => Promise.resolve(false)); const mockIsVscodeCliAvailable = mock(() => Promise.resolve(false)); -const mockIsOpencodeCliAvailable = mock(() => Promise.resolve(false)); +const mockIsOpencodeAvailable = mock(() => Promise.resolve(false)); mock.module("../../../src/helpers/plugin-install", () => ({ buildMarketplaceUrl: () => "https://plugins.archgate.dev/archgate.git", buildVscodeMarketplaceUrl: () => @@ -42,7 +42,7 @@ mock.module("../../../src/helpers/plugin-install", () => ({ isClaudeCliAvailable: mockIsClaudeCliAvailable, isCopilotCliAvailable: mockIsCopilotCliAvailable, isVscodeCliAvailable: mockIsVscodeCliAvailable, - isOpencodeCliAvailable: mockIsOpencodeCliAvailable, + isOpencodeAvailable: mockIsOpencodeAvailable, isCursorCliAvailable: mock(() => Promise.resolve(false)), })); @@ -124,7 +124,7 @@ beforeEach(() => { mockIsClaudeCliAvailable.mockReset(); mockIsCopilotCliAvailable.mockReset(); mockIsVscodeCliAvailable.mockReset(); - mockIsOpencodeCliAvailable.mockReset(); + mockIsOpencodeAvailable.mockReset(); mockDetectEditors.mockReset(); mockPromptEditorSelection.mockReset(); mockConfigureVscodeSettings.mockReset(); @@ -141,7 +141,7 @@ beforeEach(() => { mockIsClaudeCliAvailable.mockImplementation(() => Promise.resolve(false)); mockIsCopilotCliAvailable.mockImplementation(() => Promise.resolve(false)); mockIsVscodeCliAvailable.mockImplementation(() => Promise.resolve(false)); - mockIsOpencodeCliAvailable.mockImplementation(() => Promise.resolve(false)); + mockIsOpencodeAvailable.mockImplementation(() => Promise.resolve(false)); mockConfigureVscodeSettings.mockImplementation(() => Promise.resolve()); }); @@ -281,22 +281,22 @@ describe("plugin install action", () => { expect(warnSpy).toHaveBeenCalled(); }); - test("installs opencode plugin when CLI is available", async () => { + test("installs opencode plugin when opencode is available", async () => { mockLoadCredentials.mockImplementation(() => Promise.resolve({ token: "tok", github_user: "user" }) ); - mockIsOpencodeCliAvailable.mockImplementation(() => Promise.resolve(true)); + mockIsOpencodeAvailable.mockImplementation(() => Promise.resolve(true)); await runInstall(["--editor", "opencode"]); expect(mockInstallOpencodePlugin).toHaveBeenCalledWith("tok"); }); - test("skips opencode install when CLI not available", async () => { + test("skips opencode install when opencode not available", async () => { mockLoadCredentials.mockImplementation(() => Promise.resolve({ token: "tok", github_user: "user" }) ); - mockIsOpencodeCliAvailable.mockImplementation(() => Promise.resolve(false)); + mockIsOpencodeAvailable.mockImplementation(() => Promise.resolve(false)); await runInstall(["--editor", "opencode"]); diff --git a/tests/helpers/init-project.test.ts b/tests/helpers/init-project.test.ts index 234ca1b7..d19cb2ef 100644 --- a/tests/helpers/init-project.test.ts +++ b/tests/helpers/init-project.test.ts @@ -437,11 +437,11 @@ describe("tryInstallPlugin via initProject", () => { } }); - test("opencode with CLI available auto-installs", async () => { + test("opencode available auto-installs", async () => { credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); - const cliSpy = spyOn( + const availableSpy = spyOn( pluginInstall, - "isOpencodeCliAvailable" + "isOpencodeAvailable" ).mockResolvedValue(true); const installSpy = spyOn( pluginInstall, @@ -455,16 +455,16 @@ describe("tryInstallPlugin via initProject", () => { expect(result.plugin!.installed).toBe(true); expect(result.plugin!.autoInstalled).toBe(true); } finally { - cliSpy.mockRestore(); + availableSpy.mockRestore(); installSpy.mockRestore(); } }); - test("opencode without CLI returns cli-not-found", async () => { + test("opencode not found returns not-found", async () => { credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); - const cliSpy = spyOn( + const availableSpy = spyOn( pluginInstall, - "isOpencodeCliAvailable" + "isOpencodeAvailable" ).mockResolvedValue(false); try { const result = await initProject(tempDir, { @@ -472,17 +472,17 @@ describe("tryInstallPlugin via initProject", () => { editor: "opencode", }); expect(result.plugin!.installed).toBe(true); - expect(result.plugin!.detail).toBe("cli-not-found"); + expect(result.plugin!.detail).toBe("not-found"); } finally { - cliSpy.mockRestore(); + availableSpy.mockRestore(); } }); test("opencode install failure returns error detail", async () => { credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); - const cliSpy = spyOn( + const availableSpy = spyOn( pluginInstall, - "isOpencodeCliAvailable" + "isOpencodeAvailable" ).mockResolvedValue(true); const installSpy = spyOn( pluginInstall, @@ -496,7 +496,7 @@ describe("tryInstallPlugin via initProject", () => { expect(result.plugin!.installed).toBe(true); expect(result.plugin!.detail).toBe("network timeout"); } finally { - cliSpy.mockRestore(); + availableSpy.mockRestore(); installSpy.mockRestore(); } }); diff --git a/tests/helpers/plugin-install.test.ts b/tests/helpers/plugin-install.test.ts index d91f8183..e803642f 100644 --- a/tests/helpers/plugin-install.test.ts +++ b/tests/helpers/plugin-install.test.ts @@ -9,13 +9,14 @@ import { spyOn, test, } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; // --------------------------------------------------------------------------- // Imports under test // --------------------------------------------------------------------------- +import { opencodeConfigDir } from "../../src/helpers/paths"; import * as platform from "../../src/helpers/platform"; import { buildCursorMarketplaceUrl, @@ -29,6 +30,7 @@ import { isClaudeCliAvailable, isCopilotCliAvailable, isCursorCliAvailable, + isOpencodeAvailable, isOpencodeCliAvailable, isVscodeCliAvailable, } from "../../src/helpers/plugin-install"; @@ -262,6 +264,32 @@ describe("plugin-install", () => { }); }); + describe("isOpencodeAvailable", () => { + test("returns true when the CLI is on PATH", async () => { + mockResolveCommand.mockImplementation(() => Promise.resolve("opencode")); + const result = await isOpencodeAvailable(); + expect(result).toBe(true); + }); + + test("returns true when the Desktop app's config dir exists but no CLI is on PATH", async () => { + // Regression test: the opencode Desktop app is Electron-based and + // ships no CLI binary at all, so isOpencodeCliAvailable() alone would + // never detect it. Both distributions read/write the same user-scope + // config directory, so its presence is a reliable signal on its own. + mockResolveCommand.mockImplementation(() => Promise.resolve(null)); + mkdirSync(opencodeConfigDir(), { recursive: true }); + + const result = await isOpencodeAvailable(); + expect(result).toBe(true); + }); + + test("returns false when neither the CLI nor the config dir is present", async () => { + mockResolveCommand.mockImplementation(() => Promise.resolve(null)); + const result = await isOpencodeAvailable(); + expect(result).toBe(false); + }); + }); + // ----------------------------------------------------------------------- // installClaudePlugin // -----------------------------------------------------------------------