diff --git a/packages/cli/src/commands/handlers/debug/agents.ts b/packages/cli/src/commands/handlers/debug/agents.ts index 784d8648360..8386155a330 100644 --- a/packages/cli/src/commands/handlers/debug/agents.ts +++ b/packages/cli/src/commands/handlers/debug/agents.ts @@ -4,12 +4,12 @@ import { OpenCode } from "@opencode/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { Service } from "@opencode/client/effect/service" -import { ServiceConfig } from "../../../services/service-config" +import { ServerConnection } from "../../../services/server-connection" export default Runtime.handler( Commands.commands.debug.commands.agents, Effect.fn("cli.debug.agents")(function* () { - const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) + const { endpoint } = yield* ServerConnection.resolve() const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } })) process.stdout.write( diff --git a/packages/cli/src/commands/handlers/debug/config.ts b/packages/cli/src/commands/handlers/debug/config.ts index a91a295151a..e85e6324920 100644 --- a/packages/cli/src/commands/handlers/debug/config.ts +++ b/packages/cli/src/commands/handlers/debug/config.ts @@ -4,13 +4,13 @@ import { OpenCode } from "@opencode/client" import { Service } from "@opencode/client/effect/service" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { ServiceConfig } from "../../../services/service-config" +import { ServerConnection } from "../../../services/server-connection" import { redactConfig } from "./redact" export default Runtime.handler( Commands.commands.debug.commands.config, Effect.fn("cli.debug.config")(function* () { - const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) + const { endpoint } = yield* ServerConnection.resolve() const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const entries = yield* Effect.promise(() => client.config.get({ location: { directory: process.cwd() } })) process.stdout.write(JSON.stringify(redactConfig(entries), null, 2) + EOL) diff --git a/packages/cli/src/commands/handlers/mcp/auth.ts b/packages/cli/src/commands/handlers/mcp/auth.ts index 627c2ccf9e6..77fe7bfb637 100644 --- a/packages/cli/src/commands/handlers/mcp/auth.ts +++ b/packages/cli/src/commands/handlers/mcp/auth.ts @@ -4,7 +4,7 @@ import { OpenCode, type IntegrationInfo, type IntegrationOAuthMethod, type McpSe import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { Service } from "@opencode/client/effect/service" -import { ServiceConfig } from "../../../services/service-config" +import { ServerConnection } from "../../../services/server-connection" import { selectIntegration, type IntegrationChoice } from "../../../ui/integration-picker" import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt" import { answerForm } from "../auth/form" @@ -21,7 +21,7 @@ export default Runtime.handler( const authenticate = Effect.fn("cli.mcp.auth.run")(function* (name?: string) { if (!name) yield* requireInteractive("Pass an MCP server name when running without an interactive terminal") intro("Authenticate an MCP server") - const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) + const { endpoint } = yield* ServerConnection.resolve() const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const integrations = yield* loadIntegrations(client) const servers = yield* request((signal) => client.mcp.list({ location }, { signal })) diff --git a/packages/cli/src/commands/handlers/mcp/list.ts b/packages/cli/src/commands/handlers/mcp/list.ts index e1dbf956e19..ba95b8eb0ad 100644 --- a/packages/cli/src/commands/handlers/mcp/list.ts +++ b/packages/cli/src/commands/handlers/mcp/list.ts @@ -4,12 +4,12 @@ import { OpenCode, type McpServer } from "@opencode/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { Service } from "@opencode/client/effect/service" -import { ServiceConfig } from "../../../services/service-config" +import { ServerConnection } from "../../../services/server-connection" export default Runtime.handler( Commands.commands.mcp.commands.list, Effect.fn("cli.mcp.list")(function* () { - const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) + const { endpoint } = yield* ServerConnection.resolve() const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } })) const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name)) diff --git a/packages/cli/src/commands/handlers/mcp/logout.ts b/packages/cli/src/commands/handlers/mcp/logout.ts index aff2efeaa40..07be1131baa 100644 --- a/packages/cli/src/commands/handlers/mcp/logout.ts +++ b/packages/cli/src/commands/handlers/mcp/logout.ts @@ -4,7 +4,7 @@ import { OpenCode } from "@opencode/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { Service } from "@opencode/client/effect/service" -import { ServiceConfig } from "../../../services/service-config" +import { ServerConnection } from "../../../services/server-connection" import { resolveIntegration } from "./resolve" const location = { directory: process.cwd() } @@ -12,7 +12,7 @@ const location = { directory: process.cwd() } export default Runtime.handler( Commands.commands.mcp.commands.logout, Effect.fn("cli.mcp.logout")(function* (input) { - const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) + const { endpoint } = yield* ServerConnection.resolve() const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const integration = yield* resolveIntegration(client, input.name, location) diff --git a/packages/cli/src/commands/handlers/pair.ts b/packages/cli/src/commands/handlers/pair.ts index 3fc91d406ab..91cd973d0ec 100644 --- a/packages/cli/src/commands/handlers/pair.ts +++ b/packages/cli/src/commands/handlers/pair.ts @@ -10,6 +10,10 @@ import { ServiceConfig } from "../../services/service-config" export default Runtime.handler( Commands.commands.pair, Effect.fn("cli.pair")(function* (input: Runtime.Input) { + if ((yield* ServiceConfig.read()).disabled === true) + return yield* Effect.fail( + new Error("Pairing requires the background service; run `opencode service unset disabled` first"), + ) const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const urls = Option.isSome(input.url) diff --git a/packages/cli/src/commands/handlers/plugin/inventory.ts b/packages/cli/src/commands/handlers/plugin/inventory.ts index 5c19e1ee1d8..8e40b027708 100644 --- a/packages/cli/src/commands/handlers/plugin/inventory.ts +++ b/packages/cli/src/commands/handlers/plugin/inventory.ts @@ -4,7 +4,7 @@ import { OpenCode, type PluginInfo } from "@opencode/client" import { Service } from "@opencode/client/effect/service" import { Npm } from "@opencode/util/npm" import { Config } from "../../../config" -import { ServiceConfig } from "../../../services/service-config" +import { ServerConnection } from "../../../services/server-connection" export interface Item { readonly runtime: "Server" | "TUI" @@ -16,7 +16,7 @@ export interface Item { } export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: string) { - const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) + const { endpoint } = yield* ServerConnection.resolve() const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const location = { directory: process.cwd() } const listed = yield* Effect.promise(() => client.plugin.list({ location })) diff --git a/packages/cli/src/commands/handlers/plugin/list.ts b/packages/cli/src/commands/handlers/plugin/list.ts index f35c2898182..a27205ee7f9 100644 --- a/packages/cli/src/commands/handlers/plugin/list.ts +++ b/packages/cli/src/commands/handlers/plugin/list.ts @@ -5,7 +5,7 @@ import { OpenCode, type PluginInfo } from "@opencode/client" import { Service } from "@opencode/client/effect/service" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { ServiceConfig } from "../../../services/service-config" +import { ServerConnection } from "../../../services/server-connection" import { Config } from "../../../config" import { Global } from "@opencode/util/global" import { Npm } from "@opencode/util/npm" @@ -16,7 +16,7 @@ import { discoverPluginTargets, localPluginDirectories, localSource } from "@ope export default Runtime.handler( Commands.commands.plugin.commands.list, Effect.fn("cli.plugin.list")(function* (input) { - const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) + const { endpoint } = yield* ServerConnection.resolve() const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } })) const config = yield* Config.Service diff --git a/packages/cli/src/services/server-connection.ts b/packages/cli/src/services/server-connection.ts index 5d2619ad424..db0d07ce2b7 100644 --- a/packages/cli/src/services/server-connection.ts +++ b/packages/cli/src/services/server-connection.ts @@ -18,7 +18,7 @@ export type Resolved = { readonly service?: ReturnType } -export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) { +export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args = {}) { if (args.server !== undefined && args.standalone) return yield* Effect.fail(new Error("--server and --standalone cannot be combined")) if (args.server !== undefined) { @@ -38,7 +38,7 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg ) return { endpoint } satisfies Resolved } - if (args.standalone) { + if (args.standalone || (yield* ServiceConfig.read()).disabled === true) { return { endpoint: yield* Standalone.start() } satisfies Resolved } diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index d857efa919c..e6528fe8aaf 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -12,6 +12,7 @@ import { selfCommand } from "../util/process" // registration file (by channel), which version, and how to spawn opencode. export const Info = Schema.Struct({ + disabled: Schema.optional(Schema.Boolean), hostname: Schema.optional(Schema.String), port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))), password: Schema.optional(Schema.String), @@ -20,7 +21,7 @@ export const Info = Schema.Struct({ }) export type Info = typeof Info.Type -const keys = ["hostname", "port", "password", "cors", "env"] as const +const keys = ["disabled", "hostname", "port", "password", "cors", "env"] as const type Key = (typeof keys)[number] const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) @@ -78,7 +79,15 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file: }) function configKey(key: string): Key { - if (key === "hostname" || key === "port" || key === "password" || key === "cors" || key === "env") return key + if ( + key === "disabled" || + key === "hostname" || + key === "port" || + key === "password" || + key === "cors" || + key === "env" + ) + return key throw new Error(`Unknown service config key: ${key}`) } @@ -151,6 +160,9 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string, const selected = configKey(key) if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service get ${selected}`) switch (selected) { + case "disabled": { + return String((yield* read()).disabled ?? false) + } case "hostname": { return (yield* read()).hostname ?? "" } @@ -177,6 +189,12 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v if (selected !== "env" && nestedValue !== undefined) throw new Error(`Usage: opencode service set ${selected} `) switch (selected) { + case "disabled": { + if (value !== "true" && value !== "false") throw new Error("Disabled must be true or false") + if (value === "true") yield* Service.stop(yield* options()) + yield* write({ ...(yield* read()), disabled: value === "true" }) + return + } case "hostname": { yield* Service.stop(yield* options()) yield* write({ ...(yield* read()), hostname: value }) @@ -221,6 +239,11 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin const selected = configKey(key) if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service unset ${selected}`) switch (selected) { + case "disabled": { + const { disabled: _disabled, ...next } = yield* read() + yield* write(next) + return + } case "hostname": { yield* Service.stop(yield* options()) const { hostname: _hostname, ...next } = yield* read() diff --git a/packages/cli/test/server-connection.test.ts b/packages/cli/test/server-connection.test.ts index e08089152d7..33fa8abb347 100644 --- a/packages/cli/test/server-connection.test.ts +++ b/packages/cli/test/server-connection.test.ts @@ -8,6 +8,7 @@ import os from "node:os" import path from "node:path" import { ServerConnection } from "../src/services/server-connection" import { ServiceConfig } from "../src/services/service-config" +import { isolatedEnv } from "./fixture/environment" test("resolution groups Effect-native lifecycle operations only for the managed service", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-server-resolution-")) @@ -38,7 +39,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed pid: process.pid, }), ) - const resolved = await runPromise(ServerConnection.resolve({})) + const resolved = await runPromise(ServerConnection.resolve()) expect(resolved.endpoint.url).toBe(server.url.toString()) expect(resolved.service).toBeDefined() @@ -69,3 +70,61 @@ test("service options only require a matching version when requested", async () await fs.rm(root, { recursive: true, force: true }) } }) + +test("disabled background service creates a private server per CLI call without registering a daemon", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-disabled-")) + const env = isolatedEnv(root) + const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts")] + const execute = async (...args: string[]) => { + const child = Bun.spawn([...command, ...args], { + cwd: path.join(import.meta.dir, ".."), + env, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, exit] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + return { stdout, stderr, exit } + } + const run = async (...args: string[]) => { + const { stdout, stderr, exit } = await execute(...args) + expect(exit, stderr).toBe(0) + expect(stderr).not.toContain("Starting background server") + return stdout + } + + try { + await run("service", "set", "disabled", "true") + expect(await run("service", "get", "disabled")).toBe("true\n") + const first = JSON.parse(await run("api", "get", "/api/info")) + const second = JSON.parse(await run("api", "get", "/api/info")) + expect(first.pid).toBeGreaterThan(0) + expect(second.pid).toBeGreaterThan(0) + expect(second.pid).not.toBe(first.pid) + expect(await run("mcp", "list")).toContain("No MCP servers configured") + const pairing = await execute("pair") + expect(pairing.exit).not.toBe(0) + expect(pairing.stderr).toContain("Pairing requires the background service") + + const explicit = Bun.serve({ + port: 0, + fetch() { + return Response.json({ version: OPENCODE_VERSION, pid: 12345, urls: [] }) + }, + }) + try { + expect(JSON.parse(await run("api", "--server", explicit.url.toString(), "get", "/api/info")).pid).toBe(12345) + } finally { + await explicit.stop(true) + } + expect(await fs.readdir(path.join(root, "state", "opencode")).catch(() => [])).toEqual([]) + + await run("service", "unset", "disabled") + expect(await run("service", "get", "disabled")).toBe("false\n") + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}, 30_000) diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index b5bcd821794..e75719bef72 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -3,7 +3,7 @@ import { Service, type Info } from "@opencode/client/effect/service" import { Global } from "@opencode/util/global" import { OPENCODE_VERSION } from "../src/version" import { expect, test } from "bun:test" -import { Effect, Schema } from "effect" +import { Effect, FileSystem, Schema } from "effect" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -21,6 +21,24 @@ test("managed service ports are stable per installation channel", () => { expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b")) }) +test("service disabled accepts only booleans without changing configuration on invalid input", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-disabled-config-")) + const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") }) + const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer))) + try { + expect(await run(ServiceConfig.get("disabled"))).toBe("false") + await expect(run(ServiceConfig.set("disabled", "yes"))).rejects.toThrow("Disabled must be true or false") + expect(await run(ServiceConfig.read())).toEqual({}) + await run(ServiceConfig.set("disabled", "true")) + expect(await run(ServiceConfig.read())).toEqual({ disabled: true }) + await run(ServiceConfig.unset("disabled")) + expect(await run(ServiceConfig.read())).toEqual({}) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + test("local channel stores service config with the local service filename", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-")) try { diff --git a/services/www/src/docs/content/cli/index.mdx b/services/www/src/docs/content/cli/index.mdx index a45c4a11b49..0ac71cd34b7 100644 --- a/services/www/src/docs/content/cli/index.mdx +++ b/services/www/src/docs/content/cli/index.mdx @@ -55,6 +55,17 @@ opencode --standalone opencode --server http://localhost:4096 ``` +To make private servers the default for CLI commands instead of starting the shared service, disable it: + +```bash +opencode service set disabled true +opencode +``` + +This stops the running background service. `--server` still connects to an explicitly selected server; `opencode service start` +can still start the shared service explicitly. To return to automatic service use, run `opencode service unset disabled`. +Pairing requires the shared service and is unavailable while it is disabled. + See [Troubleshooting](/troubleshooting) for shared service diagnostics and the [API reference](/api) for server endpoints. ## Paths