Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `is<Editor>CliAvailable()` 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 `is<Editor>CliAvailable()` 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 `is<Editor>Available()` 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`
Expand All @@ -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\<user>\.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.
9 changes: 5 additions & 4 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 6 additions & 4 deletions src/commands/plugin/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
installVscodeExtension,
isClaudeCliAvailable,
isCopilotCliAvailable,
isOpencodeCliAvailable,
isOpencodeAvailable,
isVscodeCliAvailable,
} from "../../helpers/plugin-install";
import { configureVscodeSettings } from "../../helpers/vscode-settings";
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions src/helpers/editor-detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
isClaudeCliAvailable,
isCopilotCliAvailable,
isCursorCliAvailable,
isOpencodeCliAvailable,
isOpencodeAvailable,
isVscodeCliAvailable,
} from "./plugin-install";
import { withPromptFix } from "./prompt";
Expand All @@ -37,7 +37,7 @@ export async function detectEditors(): Promise<DetectedEditor[]> {
isCursorCliAvailable(),
isVscodeCliAvailable(),
isCopilotCliAvailable(),
isOpencodeCliAvailable(),
isOpencodeAvailable(),
]);

logDebug("Editor detection:", { claude, cursor, vscode, copilot, opencode });
Expand Down
16 changes: 9 additions & 7 deletions src/helpers/init-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,19 +290,21 @@ async function tryInstallPlugin(editor: EditorTarget): Promise<PluginResult> {
}

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",
};
}

Expand Down
17 changes: 17 additions & 0 deletions src/helpers/plugin-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,23 @@ export async function isOpencodeCliAvailable(): Promise<boolean> {
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<boolean> {
if (await isOpencodeCliAvailable()) return true;
return existsSync(opencodeConfigDir());
}

/**
* Install archgate agents and skills into opencode's user-scope directories.
*
Expand Down
16 changes: 8 additions & 8 deletions tests/commands/plugin/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () =>
Expand All @@ -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)),
}));

Expand Down Expand Up @@ -124,7 +124,7 @@ beforeEach(() => {
mockIsClaudeCliAvailable.mockReset();
mockIsCopilotCliAvailable.mockReset();
mockIsVscodeCliAvailable.mockReset();
mockIsOpencodeCliAvailable.mockReset();
mockIsOpencodeAvailable.mockReset();
mockDetectEditors.mockReset();
mockPromptEditorSelection.mockReset();
mockConfigureVscodeSettings.mockReset();
Expand All @@ -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());
});

Expand Down Expand Up @@ -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"]);

Expand Down
24 changes: 12 additions & 12 deletions tests/helpers/init-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -455,34 +455,34 @@ 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, {
installPlugin: true,
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,
Expand All @@ -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();
}
});
Expand Down
30 changes: 29 additions & 1 deletion tests/helpers/plugin-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +30,7 @@ import {
isClaudeCliAvailable,
isCopilotCliAvailable,
isCursorCliAvailable,
isOpencodeAvailable,
isOpencodeCliAvailable,
isVscodeCliAvailable,
} from "../../src/helpers/plugin-install";
Expand Down Expand Up @@ -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
// -----------------------------------------------------------------------
Expand Down