Skip to content

Commit 13f1767

Browse files
fix(cli): recognize OpenCode Desktop installs without a CLI (#196)
A Desktop-only install ships no invocable `opencode` CLI on any OS (the server runs as a JS sidecar inside Electron), so setup/doctor's binary+PATH check reported "OpenCode not found" and refused to proceed, and model discovery (`opencode models`) returned nothing. Add detectOpenCode(): cli (a runnable binary via stock bin, PATH, or a version/package-manager shim) -> desktop (the Electron userData `opencode.settings` marker under appId ai.opencode.desktop[.beta|.dev], or the GUI app path) -> none. Markers confirmed against OpenCode Desktop source. Do NOT use ~/.config/opencode as a Desktop marker (shared core config). Detection is pure filesystem (no exec) and dependency-injected for hermetic tests. setup: Desktop-only is recognized and falls through to the existing manual model-entry path (the authed/resolved model list has no on-disk equivalent, so we do not fabricate it from the models.dev catalog) instead of the scary not-found prompt. doctor + diagnostics + the adapter report kind cli/desktop/none. Removed the now-dead isOpenCodeInstalled / isOpenCodeInstalledOnSystem. Markers are mirrored on the dashboard (Rust) side; kept in lockstep via .alfonso/plans/opencode-desktop-detection.md. Gate: CLI 223/0, tsc + biome clean. Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
1 parent 1d97bf8 commit 13f1767

8 files changed

Lines changed: 310 additions & 34 deletions

File tree

packages/cli/src/adapters/opencode.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
22
import { dirname } from "node:path";
33
import { parse as parseJsonc, stringify as stringifyJsonc } from "comment-json";
44
import { writeFileAtomic } from "../lib/atomic-write";
5-
import { isOpenCodeInstalledOnSystem } from "../lib/opencode-install";
5+
import { detectOpenCode } from "../lib/opencode-detect";
66
import {
77
getOpenCodePluginPackageJsonPaths,
88
OPENCODE_PLUGIN_ENTRY_WITH_VERSION as PLUGIN_ENTRY,
@@ -27,7 +27,10 @@ export class OpenCodeAdapter implements HarnessAdapter {
2727
readonly pluginPackageName = PLUGIN_NAME;
2828

2929
isInstalled(): boolean {
30-
return isOpenCodeInstalledOnSystem();
30+
// A Desktop-only install (no CLI on PATH) still counts as installed:
31+
// OpenCode Desktop ships no invocable `opencode` binary, so a binary
32+
// check alone would wrongly report OpenCode as absent.
33+
return detectOpenCode().kind !== "none";
3134
}
3235

3336
hasPluginEntry(): boolean {

packages/cli/src/commands/doctor-opencode.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { checkLocalEmbeddingRuntime } from "../lib/embedding-runtime";
2525
import { bundleIssueReport } from "../lib/logs-opencode";
2626
import { migrateDreamerV2ForDoctor } from "../lib/migrate-dreamer-v2-doctor";
2727
import { migrateExperimentalPinKeyFilesForDoctor } from "../lib/migrate-experimental-doctor";
28-
import { isOpenCodeInstalled } from "../lib/opencode-helpers";
28+
import { detectOpenCode } from "../lib/opencode-detect";
2929
import {
3030
getOpenCodePluginCacheRoots,
3131
OPENCODE_PLUGIN_ENTRY_WITH_VERSION as PLUGIN_ENTRY_WITH_VERSION,
@@ -529,7 +529,8 @@ export async function runDoctor(
529529
};
530530

531531
// 1. Check OpenCode is installed
532-
if (!isOpenCodeInstalled()) {
532+
const detection = detectOpenCode();
533+
if (detection.kind === "none") {
533534
fail("OpenCode is not installed or not in PATH");
534535
// Help users whose binary IS on PATH but is shadowed by a wrapper
535536
// script or lives in a directory not searched by our detection
@@ -541,7 +542,13 @@ export async function runDoctor(
541542
outro("Doctor failed — install OpenCode first");
542543
return 1;
543544
}
544-
pass("OpenCode installed");
545+
if (detection.kind === "desktop") {
546+
// Desktop ships no invocable CLI; the rest of doctor operates on config
547+
// and the plugin cache (both present for a Desktop install), so continue.
548+
pass("OpenCode Desktop detected (CLI not installed)");
549+
} else {
550+
pass("OpenCode installed");
551+
}
545552

546553
// 1b. CLI vs npm latest
547554
const selfVersion = getSelfVersion();

packages/cli/src/commands/setup-opencode.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,8 @@ import {
1111
} from "../lib/config-location-migration";
1212
import { runDreamerSetup } from "../lib/dreamer-setup";
1313
import { pickModel } from "../lib/model-picker";
14-
import {
15-
getAvailableModels,
16-
getOpenCodeVersion,
17-
isOpenCodeInstalled,
18-
} from "../lib/opencode-helpers";
14+
import { detectOpenCode } from "../lib/opencode-detect";
15+
import { getAvailableModels, getOpenCodeVersion } from "../lib/opencode-helpers";
1916
import {
2017
OPENCODE_PLUGIN_ENTRY_WITH_VERSION as PLUGIN_ENTRY,
2118
OPENCODE_PLUGIN_NAME as PLUGIN_NAME,
@@ -249,8 +246,8 @@ export async function runSetup(dryRun = false): Promise<number> {
249246
const s = spinner();
250247
s.start("Checking OpenCode installation");
251248

252-
const installed = isOpenCodeInstalled();
253-
if (!installed) {
249+
const detection = detectOpenCode();
250+
if (detection.kind === "none") {
254251
s.stop("OpenCode not found");
255252
const shouldContinue = await confirm(
256253
"OpenCode not found on PATH. Continue setup anyway?",
@@ -261,6 +258,16 @@ export async function runSetup(dryRun = false): Promise<number> {
261258
outro("Setup cancelled");
262259
return 1;
263260
}
261+
} else if (detection.kind === "desktop") {
262+
// OpenCode Desktop ships no invocable `opencode` CLI on any OS (its
263+
// server runs as a JS sidecar inside Electron), so `opencode models`
264+
// and `opencode --version` are unavailable. Recognize the install and
265+
// fall through to manual model entry instead of claiming OpenCode is
266+
// absent.
267+
s.stop("OpenCode Desktop detected (CLI not installed)");
268+
log.info(
269+
"Model auto-discovery needs the OpenCode CLI; you will enter models manually. Install the CLI to auto-populate: https://opencode.ai",
270+
);
264271
} else {
265272
const version = getOpenCodeVersion();
266273
s.stop(`OpenCode ${version ?? ""} detected`);
@@ -269,7 +276,10 @@ export async function runSetup(dryRun = false): Promise<number> {
269276
// ─── Step 2: Get available models ───────────────────
270277
s.start("Fetching available models");
271278

272-
const allModels = installed ? getAvailableModels() : [];
279+
// Only the CLI can enumerate the authed/resolved model list; Desktop-only
280+
// installs have no on-disk equivalent, so models stay empty and the model
281+
// prompts fall back to free-text entry.
282+
const allModels = detection.kind === "cli" ? getAvailableModels() : [];
273283
if (allModels.length > 0) {
274284
s.stop(`Found ${allModels.length} models`);
275285
} else {

packages/cli/src/lib/diagnostics-opencode.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import { parseCompartmentOutput } from "@magic-context/core/hooks/magic-context/
1616
import { detectConflicts } from "@magic-context/core/shared/conflict-detector";
1717
import { getProjectMagicContextHistorianDir } from "@magic-context/core/shared/data-path";
1818
import { parse as parseJsonc } from "comment-json";
19-
import { getOpenCodeVersion, isOpenCodeInstalled } from "./opencode-helpers";
19+
import { detectOpenCode } from "./opencode-detect";
20+
import { getOpenCodeVersion } from "./opencode-helpers";
2021
import {
2122
getOpenCodePluginCacheRoots,
2223
getOpenCodePluginPackageJsonPaths,
@@ -38,6 +39,7 @@ export interface DiagnosticReport {
3839
nodeVersion: string;
3940
pluginVersion: string;
4041
opencodeInstalled: boolean;
42+
opencodeInstallKind: "cli" | "desktop" | "none";
4143
opencodeVersion: string | null;
4244
configPaths: ConfigPaths;
4345
opencodeConfigHasPlugin: boolean;
@@ -724,14 +726,16 @@ export async function collectDiagnostics(): Promise<DiagnosticReport> {
724726

725727
const conflictResult = detectConflicts(process.cwd());
726728
const recentSessions = await collectRecentSessions();
729+
const openCodeInstallKind = detectOpenCode().kind;
727730

728731
return {
729732
timestamp: new Date().toISOString(),
730733
platform: process.platform,
731734
arch: process.arch,
732735
nodeVersion: process.version,
733736
pluginVersion,
734-
opencodeInstalled: isOpenCodeInstalled(),
737+
opencodeInstalled: openCodeInstallKind !== "none",
738+
opencodeInstallKind: openCodeInstallKind,
735739
opencodeVersion: getOpenCodeVersion(),
736740
configPaths,
737741
opencodeConfigHasPlugin: configHasPluginEntry(opencodeConfig.value),
@@ -822,7 +826,7 @@ export function renderDiagnosticsMarkdown(report: DiagnosticReport): string {
822826
`- Plugin: v${report.pluginVersion}`,
823827
`- OS: ${report.platform} ${report.arch}`,
824828
`- Node: ${report.nodeVersion}`,
825-
`- OpenCode installed: ${report.opencodeInstalled}${report.opencodeVersion ? ` (${report.opencodeVersion})` : ""}`,
829+
`- OpenCode installed: ${report.opencodeInstalled} [${report.opencodeInstallKind}]${report.opencodeVersion ? ` (${report.opencodeVersion})` : ""}`,
826830
`- Plugin registered in opencode config: ${report.opencodeConfigHasPlugin}`,
827831
`- Plugin registered in tui config: ${report.tuiConfigHasPlugin}`,
828832
`- magic-context.jsonc parse error: ${report.magicContextConfig.parseError ?? "none"}`,
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, expect, it } from "bun:test";
2+
import { join } from "node:path";
3+
import {
4+
type DetectDeps,
5+
detectOpenCode,
6+
OPENCODE_DESKTOP_APP_IDS,
7+
openCodeDesktopSettingsMarkers,
8+
} from "./opencode-detect";
9+
10+
const HOME = "/virt/home";
11+
12+
// Build hermetic deps over a virtual set of "existing" paths and a fixed OS, so
13+
// no host filesystem / real `opencode` install can leak into the result.
14+
function deps(
15+
existing: Set<string>,
16+
platform: NodeJS.Platform = "darwin",
17+
onPath: (b: string) => string | null = () => null,
18+
): DetectDeps {
19+
return {
20+
exists: (p) => existing.has(p),
21+
home: HOME,
22+
platform,
23+
env: {
24+
APPDATA: join(HOME, "AppData", "Roaming"),
25+
LOCALAPPDATA: join(HOME, "AppData", "Local"),
26+
USERPROFILE: HOME,
27+
XDG_CONFIG_HOME: join(HOME, ".config"),
28+
XDG_DATA_HOME: join(HOME, ".local", "share"),
29+
},
30+
onPath,
31+
};
32+
}
33+
34+
describe("detectOpenCode", () => {
35+
it("exposes exactly the three Desktop channel appIds", () => {
36+
expect([...OPENCODE_DESKTOP_APP_IDS]).toEqual([
37+
"ai.opencode.desktop",
38+
"ai.opencode.desktop.beta",
39+
"ai.opencode.desktop.dev",
40+
]);
41+
});
42+
43+
it("reports none when nothing OpenCode-ish exists", () => {
44+
expect(detectOpenCode(deps(new Set())).kind).toBe("none");
45+
});
46+
47+
it("reports cli when the stock ~/.opencode/bin binary exists", () => {
48+
const bin = join(HOME, ".opencode", "bin", "opencode");
49+
const result = detectOpenCode(deps(new Set([bin])));
50+
expect(result).toEqual({ kind: "cli", binary: bin });
51+
});
52+
53+
it("reports cli when a bare opencode is on PATH", () => {
54+
const result = detectOpenCode(deps(new Set(), "linux", () => "/somewhere/opencode"));
55+
expect(result).toEqual({ kind: "cli", binary: "/somewhere/opencode" });
56+
});
57+
58+
it("reports desktop when a channel's opencode.settings marker exists", () => {
59+
const d = deps(new Set());
60+
const marker = openCodeDesktopSettingsMarkers(d)[0]; // prod channel
61+
const result = detectOpenCode(deps(new Set([marker])));
62+
expect(result).toEqual({ kind: "desktop", marker });
63+
});
64+
65+
it("reports desktop for the beta/dev channels too", () => {
66+
const markers = openCodeDesktopSettingsMarkers(deps(new Set()));
67+
for (const marker of markers) {
68+
expect(detectOpenCode(deps(new Set([marker]))).kind).toBe("desktop");
69+
}
70+
});
71+
72+
it("reports desktop from the GUI app path when never run (no settings marker)", () => {
73+
const appPath = "/Applications/OpenCode.app";
74+
expect(detectOpenCode(deps(new Set([appPath]), "darwin")).kind).toBe("desktop");
75+
});
76+
77+
it("does NOT treat ~/.config/opencode (shared core config) as Desktop", () => {
78+
const coreConfig = join(HOME, ".config", "opencode", "opencode.jsonc");
79+
expect(detectOpenCode(deps(new Set([coreConfig]))).kind).toBe("none");
80+
});
81+
82+
it("prefers cli over desktop when both are present", () => {
83+
const bin = join(HOME, ".opencode", "bin", "opencode");
84+
const marker = openCodeDesktopSettingsMarkers(deps(new Set()))[0];
85+
expect(detectOpenCode(deps(new Set([bin, marker]))).kind).toBe("cli");
86+
});
87+
88+
it("finds the Windows Desktop settings marker under %APPDATA%", () => {
89+
const win = deps(new Set(), "win32");
90+
const marker = openCodeDesktopSettingsMarkers(win)[0];
91+
expect(marker).toBe(
92+
join(HOME, "AppData", "Roaming", "ai.opencode.desktop", "opencode.settings"),
93+
);
94+
expect(detectOpenCode(deps(new Set([marker]), "win32")).kind).toBe("desktop");
95+
});
96+
});

0 commit comments

Comments
 (0)