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: 4 additions & 0 deletions apps/cli/src/__tests__/capability-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const allOk = (): ClientCapabilities => ({
codex: ok(),
cursor: ok(),
"kimi-code": ok(),
opencode: ok(),
});

const codexMissing = (): ClientCapabilities => ({
Expand All @@ -45,6 +46,7 @@ const codexMissing = (): ClientCapabilities => ({
codex: missing(),
cursor: ok(),
"kimi-code": ok(),
opencode: ok(),
});

// Detection is install-only, so a provider mid-login is one whose binary is not
Expand Down Expand Up @@ -73,6 +75,7 @@ const codexPendingSnapshot = (): ClientCapabilities => ({
codex: codexPending(),
cursor: ok(),
"kimi-code": ok(),
opencode: ok(),
});

/** What a re-probe sees while the login is still in flight: still not installed. */
Expand All @@ -82,6 +85,7 @@ const codexUnauthSnapshot = (): ClientCapabilities => ({
codex: codexUnauth(),
cursor: ok(),
"kimi-code": ok(),
opencode: ok(),
});

const BASE = 100;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export function registerAgentConfigSetReasoningEffortCommand(config: Command): v
config
.command("set-reasoning-effort <agent> <level>")
.description(
'Set reasoning effort. claude-code: "" (inherit local) | low | medium | high | max. codex: low | medium | high | xhigh | max | ultra (model-dependent). Cursor and Kimi Code have no separate effort field.',
'Set reasoning effort. claude-code: "" (inherit local) | low | medium | high | max. codex: low | medium | high | xhigh | max | ultra (model-dependent). Cursor, Kimi Code, and OpenCode have no separate effort field.',
)
.action(async (agentName: string, level: string) => {
const serverUrl = resolveServerUrl(process.env.FIRST_TREE_SERVER_URL);
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/commands/agent/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function registerAgentCreateCommand(agent: Command): void {
)
.option(
"--runtime <runtime>",
"Runtime handler — one of: claude-code, claude-code-tui, codex, cursor, kimi-code (default: claude-code)",
"Runtime handler — one of: claude-code, claude-code-tui, codex, cursor, kimi-code, opencode (default: claude-code)",
"claude-code",
)
.option("--display-name <name>", "Display name")
Expand Down
5 changes: 4 additions & 1 deletion apps/cli/src/core/client-switch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,7 +856,10 @@ export function parseSwitchProcessEnvValue(envText: string, key: string): string
}

function isKnownProviderCommand(command: string): boolean {
if (/(^|[/\s])(claude|codex|cursor-agent)(\s|$)/i.test(command) || /@openai\/codex|claude-code/i.test(command)) {
if (
/(^|[/\s])(claude|codex|cursor-agent|opencode)(\s|$)/i.test(command) ||
/@openai\/codex|claude-code|opencode-ai/i.test(command)
) {
return true;
}
// Cursor's official main command is the generic name `agent`. Match it ONLY
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/core/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ export async function checkWebSocket(): Promise<CheckResult> {
// `daemon probe`)
// ---------------------------------------------------------------------------

const RUNTIME_PROVIDER_ORDER = ["claude-code", "claude-code-tui", "codex", "cursor", "kimi-code"];
const RUNTIME_PROVIDER_ORDER = ["claude-code", "claude-code-tui", "codex", "cursor", "kimi-code", "opencode"];

function formatCapabilityDetail(entry: CapabilityEntry): string {
if (entry.state === "ok") {
Expand Down
24 changes: 23 additions & 1 deletion packages/client/src/__tests__/auth-error-hint.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { formatAuthHint, isClaudeAuthError, isCodexAuthError } from "../handlers/auth-error-hint.js";
import {
formatAuthHint,
isClaudeAuthError,
isCodexAuthError,
isOpenCodeAuthError,
} from "../handlers/auth-error-hint.js";

/**
* Locks the behavioural contract of the auth-error hint module that
Expand Down Expand Up @@ -93,6 +98,15 @@ describe("isClaudeAuthError", () => {
});
});

describe("isOpenCodeAuthError", () => {
it("matches provider-owned credential failures without treating capacity failures as auth", () => {
expect(isOpenCodeAuthError("Provider returned 401 Unauthorized: invalid API key")).toBe(true);
expect(isOpenCodeAuthError("Run opencode auth login before using this provider")).toBe(true);
expect(isOpenCodeAuthError("rate limit exceeded")).toBe(false);
expect(isOpenCodeAuthError("")).toBe(false);
});
});

describe("formatAuthHint", () => {
it("targets `codex login` for the codex runtime and quotes the original SDK message", () => {
const hint = formatAuthHint(
Expand All @@ -115,6 +129,14 @@ describe("formatAuthHint", () => {
expect(hint).toContain("authentication_failed");
});

it("keeps OpenCode authentication host-local and points at the provider-owned login", () => {
const hint = formatAuthHint("opencode", "Provider returned 401 Unauthorized");
expect(hint).toContain("opencode");
expect(hint).toContain("`opencode auth login`");
expect(hint).toContain("OpenCode's selected provider");
expect(hint).toContain("not First Tree's");
});

it("falls back to a placeholder when the SDK gives no message", () => {
const hint = formatAuthHint("codex", "");
expect(hint).toContain("(no message from SDK)");
Expand Down
13 changes: 13 additions & 0 deletions packages/client/src/__tests__/builtin-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,19 @@ describe("Built-in Handlers", () => {
expect(typeof handler.shutdown).toBe("function");
});

it("registers opencode handler with a valid session-oriented shape", () => {
registerBuiltinHandlers();

const factory = getHandlerFactory("opencode");
expect(typeof factory).toBe("function");
const handler = factory({ workspaceRoot: "/tmp/test", runtimeProvider: "opencode" });
expect(typeof handler.start).toBe("function");
expect(typeof handler.resume).toBe("function");
expect(typeof handler.inject).toBe("function");
expect(typeof handler.suspend).toBe("function");
expect(typeof handler.shutdown).toBe("function");
});

it("codex factory returns a valid session-oriented handler", () => {
registerBuiltinHandlers();

Expand Down
12 changes: 11 additions & 1 deletion packages/client/src/__tests__/capability-probes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -739,25 +739,30 @@ describe("probeCapabilities (aggregator)", () => {
vi.doMock("../runtime/capabilities/kimi-code.js", () => ({
probeKimiCodeCapability: vi.fn().mockResolvedValue(fakeEntry("ok")),
}));
vi.doMock("../runtime/capabilities/opencode.js", () => ({
probeOpenCodeCapability: vi.fn().mockResolvedValue(fakeEntry("ok")),
}));
const mod = await import("../runtime/capabilities/index.js");

const caps = await mod.probeCapabilities();

// claude-code-tui is in DISABLED_RUNTIME_PROVIDERS — it is skipped, so it
// gets no capability entry AND its probe is never called (no binary spawn).
expect(Object.keys(caps).sort()).toEqual(["claude-code", "codex", "cursor", "kimi-code"]);
expect(Object.keys(caps).sort()).toEqual(["claude-code", "codex", "cursor", "kimi-code", "opencode"]);
expect(caps["claude-code"]?.state).toBe("ok");
expect(caps["claude-code-tui"]).toBeUndefined();
expect(caps.codex?.state).toBe("ok");
expect(caps.cursor?.state).toBe("ok");
expect(caps["kimi-code"]?.state).toBe("ok");
expect(caps.opencode?.state).toBe("ok");
expect(tuiProbe).not.toHaveBeenCalled();

vi.doUnmock("../runtime/capabilities/claude-code.js");
vi.doUnmock("../runtime/capabilities/claude-code-tui.js");
vi.doUnmock("../runtime/capabilities/codex.js");
vi.doUnmock("../runtime/capabilities/cursor.js");
vi.doUnmock("../runtime/capabilities/kimi-code.js");
vi.doUnmock("../runtime/capabilities/opencode.js");
vi.resetModules();
});

Expand All @@ -778,6 +783,9 @@ describe("probeCapabilities (aggregator)", () => {
vi.doMock("../runtime/capabilities/kimi-code.js", () => ({
probeKimiCodeCapability: vi.fn().mockRejectedValue("kimi probe failed"),
}));
vi.doMock("../runtime/capabilities/opencode.js", () => ({
probeOpenCodeCapability: vi.fn().mockRejectedValue("opencode probe failed"),
}));
const mod = await import("../runtime/capabilities/index.js");

const caps = await mod.probeCapabilities();
Expand All @@ -790,6 +798,7 @@ describe("probeCapabilities (aggregator)", () => {
expect(caps.codex).toMatchObject({ state: "error", error: "codex probe failed" });
expect(caps.cursor).toMatchObject({ state: "error", error: "cursor probe failed" });
expect(caps["kimi-code"]).toMatchObject({ state: "error", error: "kimi probe failed" });
expect(caps.opencode).toMatchObject({ state: "error", error: "opencode probe failed" });
// Disabled provider is never probed, so no entry (not even an error one).
expect(caps["claude-code-tui"]).toBeUndefined();

Expand All @@ -798,6 +807,7 @@ describe("probeCapabilities (aggregator)", () => {
vi.doUnmock("../runtime/capabilities/claude-code-tui.js");
vi.doUnmock("../runtime/capabilities/cursor.js");
vi.doUnmock("../runtime/capabilities/kimi-code.js");
vi.doUnmock("../runtime/capabilities/opencode.js");
vi.resetModules();
});
});
1 change: 1 addition & 0 deletions packages/client/src/__tests__/capability-reprobe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ describe("hasNonOkProvider", () => {
codex: okEntry(),
cursor: okEntry(),
"kimi-code": okEntry(),
opencode: okEntry(),
}),
).toBe(false);
});
Expand Down
16 changes: 14 additions & 2 deletions packages/client/src/__tests__/managed-skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,14 @@ import {
} from "../runtime/managed-state.js";
import { spawnWorkspaceLockWorker } from "./workspace-file-lock-worker.js";

const PROVIDERS: readonly RuntimeProvider[] = ["claude-code", "claude-code-tui", "codex", "cursor", "kimi-code"];
const PROVIDERS: readonly RuntimeProvider[] = [
"claude-code",
"claude-code-tui",
"codex",
"cursor",
"kimi-code",
"opencode",
];

function teamSkill(overrides: Partial<RuntimeResourceSkill> = {}): RuntimeResourceSkill {
return {
Expand Down Expand Up @@ -173,6 +180,7 @@ describe("managed Skill reconciler", () => {
["codex", ".agents/skills"],
["cursor", ".cursor/skills"],
["kimi-code", ".kimi-code/skills"],
["opencode", ".opencode/skills"],
]);
});

Expand All @@ -190,19 +198,23 @@ describe("managed Skill reconciler", () => {
"src/handlers/codex/app-server/index.ts",
"src/handlers/cursor/index.ts",
"src/handlers/kimi-code.ts",
"src/handlers/opencode/index.ts",
].map((path) => readFileSync(join(process.cwd(), path), "utf-8"));
expect(
handlerSources.reduce(
(count, source) => count + (source.match(/reconcileManagedSkillsForConfig\(/g)?.length ?? 0),
0,
),
).toBe(14);
).toBe(15);
expect(handlerSources.every((source) => !source.includes("reconcileManagedSkills({"))).toBe(true);
for (const source of [handlerSources[2] ?? "", handlerSources[3] ?? "", handlerSources[4] ?? ""]) {
expect(source).toContain("if (isManagedSkillsUnsafeDiscoveryError(err)) throw err;");
}
expect(handlerSources[0]).toContain('failFatalSessionForRecovery(sessionCtx, "claude_config_restart_failed")');
expect(handlerSources[3]).toContain('retryBatch(batch, "codex_managed_skills_unsafe")');
expect(handlerSources[6]).toContain("teamSkillBundleResolverFromSdk(sessionCtx.sdk)");
expect(handlerSources[6]).toContain('token.retry(messages, "opencode_managed_skills_unsafe")');
expect(handlerSources[6]).toContain("if (isManagedSkillsUnsafeDiscoveryError(error))");
});

it.each(PROVIDERS)("projects Core Skills only into the active %s discovery root", async (provider) => {
Expand Down
142 changes: 142 additions & 0 deletions packages/client/src/__tests__/opencode-binary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
findOpenCodeExecutableOnPath,
formatOpenCodeBinaryMissingMessage,
isSupportedOpenCodeVersion,
parseOpenCodeVersionOutput,
resolveOpenCodeRuntimeBinary,
} from "../runtime/opencode-binary.js";

const roots: string[] = [];

afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});

describe("OpenCode binary resolution", () => {
it("finds the operator-installed binary without launching it", () => {
const root = mkdtempSync(join(tmpdir(), "ft-opencode-bin-"));
roots.push(root);
const binary = join(root, "opencode");
writeFileSync(binary, "#!/bin/sh\nexit 0\n");
chmodSync(binary, 0o755);

expect(
findOpenCodeExecutableOnPath(
{ PATH: root },
{ platform: "linux", wellKnownDirs: () => [], loginShellPathDirs: () => [] },
),
).toBe(binary);
});

it("finds the official OpenCode home even when the daemon PATH is empty", () => {
const root = mkdtempSync(join(tmpdir(), "ft-opencode-home-"));
roots.push(root);
const binary = join(root, ".opencode", "bin", "opencode");
mkdirSync(join(root, ".opencode", "bin"), { recursive: true });
writeFileSync(binary, "#!/bin/sh\nexit 0\n");
chmodSync(binary, 0o755);

expect(
findOpenCodeExecutableOnPath(
{ HOME: root, PATH: "" },
{ platform: "linux", wellKnownDirs: () => [], loginShellPathDirs: () => [] },
),
).toBe(binary);
});

it("resolves without launching; the handler performs its gate through the process supervisor", () => {
const result = resolveOpenCodeRuntimeBinary({}, { findOnPath: () => "/opt/bin/opencode" });
expect(result).toEqual({ ok: true, binary: "/opt/bin/opencode" });
});

it("resolves npm's native Windows executable instead of the opencode.cmd shim", () => {
const root = mkdtempSync(join(tmpdir(), "ft-opencode-win-bin-"));
roots.push(root);
const native = join(root, "node_modules", "opencode-ai", "bin", "opencode.exe");
mkdirSync(join(root, "node_modules", "opencode-ai", "bin"), { recursive: true });
writeFileSync(join(root, "opencode.cmd"), "@echo off\r\n");
writeFileSync(native, "native");

expect(
findOpenCodeExecutableOnPath(
{ PATH: root },
{
platform: "win32",
pathDelimiter: ";",
wellKnownDirs: () => [],
loginShellPathDirs: () => [],
},
),
).toBe(native);
});

it("resolves a global Windows npm prefix when the daemon PATH is empty", () => {
const root = mkdtempSync(join(tmpdir(), "ft-opencode-win-prefix-"));
roots.push(root);
const native = join(root, "npm", "node_modules", "opencode-ai", "bin", "opencode.exe");
mkdirSync(join(root, "npm", "node_modules", "opencode-ai", "bin"), { recursive: true });
writeFileSync(native, "native");

expect(
findOpenCodeExecutableOnPath(
{ USERPROFILE: join(root, "profile"), APPDATA: root, PATH: "" },
{
platform: "win32",
pathDelimiter: ";",
loginShellPathDirs: () => [],
},
),
).toBe(native);
});

it("resolves the official Windows home without a daemon PATH or cmd shim", () => {
const root = mkdtempSync(join(tmpdir(), "ft-opencode-win-home-"));
roots.push(root);
const native = join(root, ".opencode", "bin", "opencode.exe");
mkdirSync(join(root, ".opencode", "bin"), { recursive: true });
writeFileSync(native, "native");

expect(
findOpenCodeExecutableOnPath(
{ USERPROFILE: root, PATH: "" },
{
platform: "win32",
pathDelimiter: ";",
wellKnownDirs: () => [],
loginShellPathDirs: () => [],
},
),
).toBe(native);
});

it("surfaces external install and provider-owned auth instructions", () => {
expect(formatOpenCodeBinaryMissingMessage("not found")).toContain("npm install -g opencode-ai@^1.18.7");
expect(formatOpenCodeBinaryMissingMessage("not found")).toContain("opencode auth login");
});

it("parses a stable semver without accepting prerelease or partial versions", () => {
expect(parseOpenCodeVersionOutput("opencode 1.18.7")).toBe("1.18.7");
expect(parseOpenCodeVersionOutput("opencode 1.18.9-beta.1")).toBeNull();
expect(parseOpenCodeVersionOutput("opencode 01.18.7")).toBeNull();
expect(parseOpenCodeVersionOutput("opencode 1.18.7_suffix")).toBeNull();
expect(parseOpenCodeVersionOutput("opencode 1.18")).toBeNull();
expect(parseOpenCodeVersionOutput("not-a-version")).toBeNull();
expect(parseOpenCodeVersionOutput(`${"0".repeat(100_000)}.x opencode 1.18.7`)).toBe("1.18.7");
});

it("accepts the supported major-one range and fails closed outside it", () => {
expect(isSupportedOpenCodeVersion("1.18.7")).toBe(true);
expect(isSupportedOpenCodeVersion("1.18.9")).toBe(true);
expect(isSupportedOpenCodeVersion("1.19.0")).toBe(true);
expect(isSupportedOpenCodeVersion("1.18.6")).toBe(false);
expect(isSupportedOpenCodeVersion("2.0.0")).toBe(false);
expect(isSupportedOpenCodeVersion("1.18.9-beta.1")).toBe(false);
expect(isSupportedOpenCodeVersion("01.18.7")).toBe(false);
expect(isSupportedOpenCodeVersion("1.18.7_suffix")).toBe(false);
expect(isSupportedOpenCodeVersion(null)).toBe(false);
});
});
Loading
Loading