From da469556dc3ec5a6e0fbab1e073a2a1cf4c4295f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 08:05:43 +0000 Subject: [PATCH 1/4] fix(cli): accept --standalone before subcommands Papercut pc_16ace9037047 Register --standalone and --server as global settings so they parse before or after a subcommand, including plugin list. --- packages/cli/src/commands/commands.ts | 30 ------------- packages/cli/src/commands/handlers/api.ts | 2 - .../cli/src/commands/handlers/auth/export.ts | 2 +- .../cli/src/commands/handlers/auth/import.ts | 2 +- .../cli/src/commands/handlers/auth/list.ts | 4 +- .../cli/src/commands/handlers/auth/login.ts | 6 +-- .../cli/src/commands/handlers/auth/logout.ts | 6 +-- .../cli/src/commands/handlers/auth/shared.ts | 2 +- .../cli/src/commands/handlers/auth/switch.ts | 6 +-- packages/cli/src/commands/handlers/default.ts | 5 +-- packages/cli/src/commands/handlers/mini.ts | 3 -- packages/cli/src/commands/handlers/models.ts | 9 ++-- packages/cli/src/commands/handlers/reload.ts | 9 ++-- packages/cli/src/commands/handlers/run.ts | 5 +-- .../src/commands/handlers/session/delete.ts | 7 +-- .../src/commands/handlers/session/export.ts | 5 +-- .../src/commands/handlers/session/import.ts | 5 +-- .../cli/src/commands/handlers/session/list.ts | 5 +-- packages/cli/src/commands/handlers/stats.ts | 5 +-- packages/cli/src/framework/runtime.ts | 6 ++- .../cli/src/services/server-connection.ts | 13 ++++-- packages/cli/src/services/server-flags.ts | 27 +++++++++++ packages/cli/test/mini-config.test.ts | 4 +- packages/cli/test/server-flags.test.ts | 45 +++++++++++++++++++ 24 files changed, 111 insertions(+), 102 deletions(-) create mode 100644 packages/cli/src/services/server-flags.ts create mode 100644 packages/cli/test/server-flags.test.ts diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 2e66b4e4a039..9bafc46560ee 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -12,17 +12,6 @@ export const PrintLogs = GlobalFlag.setting("print-logs")({ declare const OPENCODE_CLI_NAME: string | undefined -const ServerParams = { - standalone: Flag.boolean("standalone").pipe( - Flag.withDescription("Run with a private server instead of the background service"), - Flag.withDefault(false), - ), - server: Flag.string("server").pipe( - Flag.withDescription("Connect to a server URL instead of the background service"), - Flag.optional, - ), -} - const PermissionParams = { auto: Flag.boolean("auto").pipe( Flag.withDescription("Auto-approve permissions that are not explicitly denied"), @@ -38,7 +27,6 @@ const PermissionParams = { const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "shuvcode", { description: "Shuvcode command line interface", params: { - ...ServerParams, ...PermissionParams, directory: Argument.string("directory").pipe( Argument.withDescription("Directory to start Shuvcode in"), @@ -100,7 +88,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME Spec.make("api", { description: "Make a request to the running server", params: { - ...ServerParams, request: Argument.string("operation | method path").pipe( Argument.withDescription("OpenAPI operation ID, or an HTTP method followed by a path"), Argument.variadic({ min: 1, max: 2 }), @@ -147,7 +134,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME Spec.make("list", { description: "list integrations and credentials", params: { - ...ServerParams, format: Flag.choice("format", ["default", "json"]).pipe( Flag.withDescription("Output format"), Flag.withDefault("default"), @@ -157,7 +143,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME Spec.make("login", { description: "connect an integration", params: { - ...ServerParams, target: Argument.string("target").pipe( Argument.withDescription("Integration ID, name, or well-known provider URL"), Argument.optional, @@ -172,7 +157,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME Spec.make("logout", { description: "log out of a saved account", params: { - ...ServerParams, target: Argument.string("target").pipe( Argument.withDescription("Integration ID or name"), Argument.optional, @@ -186,7 +170,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME Spec.make("export", { description: "print stored credentials, including secrets, as JSON", params: { - ...ServerParams, target: Argument.string("target").pipe( Argument.withDescription("Integration ID or name (exports every integration when omitted)"), Argument.optional, @@ -196,7 +179,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME Spec.make("import", { description: "import credentials exported by auth export", params: { - ...ServerParams, file: Argument.string("file").pipe( Argument.withDescription("JSON file to import (reads stdin when omitted)"), Argument.optional, @@ -206,7 +188,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME Spec.make("switch", { description: "switch the active account for an integration", params: { - ...ServerParams, target: Argument.string("target").pipe( Argument.withDescription("Integration ID or name"), Argument.optional, @@ -304,12 +285,10 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME }), Spec.make("models", { description: "List all available models", - params: ServerParams, }), Spec.make("stats", { description: "Show shareable usage statistics", params: { - ...ServerParams, days: Flag.integer("days").pipe( Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), Flag.withDescription("Show the last N days; 0 means today"), @@ -340,7 +319,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME Spec.make("mini", { description: "Start the minimal interactive interface", params: { - ...ServerParams, continue: Flag.boolean("continue").pipe( Flag.withAlias("c"), Flag.withDescription("Continue the last session"), @@ -376,7 +354,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME Spec.make("run", { description: "Run Shuvcode with a message", params: { - ...ServerParams, message: Argument.string("message").pipe( Argument.withDescription("Message to send"), Argument.variadic({ min: 0 }), @@ -421,7 +398,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME Spec.make("list", { description: "List top-level sessions in the current project, newest first", params: { - ...ServerParams, maxCount: Flag.integer("max-count").pipe( Flag.withAlias("n"), Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))), @@ -437,14 +413,12 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME Spec.make("delete", { description: "Delete a session and its child sessions", params: { - ...ServerParams, sessionID: Argument.string("sessionID").pipe(Argument.withDescription("Session ID to delete")), }, }), Spec.make("export", { description: "Export session data as JSON", params: { - ...ServerParams, session: Argument.string("session").pipe( Argument.withDescription("Session ID to export"), Argument.optional, @@ -458,7 +432,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME Spec.make("import", { description: "Import session data from a JSON file or URL", params: { - ...ServerParams, file: Argument.string("file").pipe(Argument.withDescription("JSON file or URL to import")), directory: Flag.string("directory").pipe( Flag.withDescription("Directory in which to import the session"), @@ -512,9 +485,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME }), Spec.make("reload", { description: "Reload configuration", - params: { - ...ServerParams, - }, }), Spec.make("pair", { description: "Print one-time links to connect a browser or app", diff --git a/packages/cli/src/commands/handlers/api.ts b/packages/cli/src/commands/handlers/api.ts index ed511cbec401..f24157ec77cb 100644 --- a/packages/cli/src/commands/handlers/api.ts +++ b/packages/cli/src/commands/handlers/api.ts @@ -19,8 +19,6 @@ export default Runtime.handler( Commands.commands.api, Effect.fn("cli.api")(function* (input) { const server = yield* ServerConnection.resolve({ - server: Option.getOrUndefined(input.server), - standalone: input.standalone, mismatch: "ignore", }) const endpoint = server.endpoint diff --git a/packages/cli/src/commands/handlers/auth/export.ts b/packages/cli/src/commands/handlers/auth/export.ts index d95336496c9b..47be9936b358 100644 --- a/packages/cli/src/commands/handlers/auth/export.ts +++ b/packages/cli/src/commands/handlers/auth/export.ts @@ -9,7 +9,7 @@ export default Runtime.handler( Commands.commands.auth.commands.export, Effect.fn("cli.auth.export")( function* (input) { - const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone }) + const client = yield* createClient() const target = Option.getOrUndefined(input.target) const integrationID = target ? (yield* resolveIntegration(yield* loadIntegrations(client), target)).id : undefined const credentials = (yield* request((signal) => client.credential.list({ signal }))).filter( diff --git a/packages/cli/src/commands/handlers/auth/import.ts b/packages/cli/src/commands/handlers/auth/import.ts index e1434a24bb06..2bcf6a02d5c0 100644 --- a/packages/cli/src/commands/handlers/auth/import.ts +++ b/packages/cli/src/commands/handlers/auth/import.ts @@ -17,7 +17,7 @@ export default Runtime.handler( return yield* Effect.fail(new Error("Pipe auth export output into stdin or pass a file to import")) const text = yield* request(() => (file ? Bun.file(file).text() : readStdin())) const credentials = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Credential.Entry)))(text) - const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone }) + const client = yield* createClient() const existing = yield* request((signal) => client.credential.list({ signal })) const ids = new Set(existing.map((credential) => credential.id)) const integrations = new Set(existing.map((credential) => credential.integrationID)) diff --git a/packages/cli/src/commands/handlers/auth/list.ts b/packages/cli/src/commands/handlers/auth/list.ts index feb5f751136d..cf93491295e4 100644 --- a/packages/cli/src/commands/handlers/auth/list.ts +++ b/packages/cli/src/commands/handlers/auth/list.ts @@ -1,5 +1,5 @@ import { EOL } from "node:os" -import { Effect, Option } from "effect" +import { Effect } from "effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { createClient, loadIntegrations } from "./shared" @@ -17,7 +17,7 @@ export default Runtime.handler(Commands.commands.auth.commands.list, (input) => ) const list = Effect.fn("cli.auth.list")(function* (input) { - const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone }) + const client = yield* createClient() const integrations = (yield* loadIntegrations(client)).filter((integration) => integration.connections.length > 0) if (input.format === "json") { process.stdout.write( diff --git a/packages/cli/src/commands/handlers/auth/login.ts b/packages/cli/src/commands/handlers/auth/login.ts index 2a4859ff1906..c1fda8af8615 100644 --- a/packages/cli/src/commands/handlers/auth/login.ts +++ b/packages/cli/src/commands/handlers/auth/login.ts @@ -33,8 +33,6 @@ export default Runtime.handler( target: Option.getOrUndefined(input.target), method: Option.getOrUndefined(input.method), answer: input.answer, - server: Option.getOrUndefined(input.server), - standalone: input.standalone, }).pipe(handlePromptErrors), ), ) @@ -43,13 +41,11 @@ const login = Effect.fn("cli.auth.login.run")(function* (input: { target?: string method?: string answer?: ReadonlyArray - server?: string - standalone: boolean }) { if (!input.target) yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal") intro("Connect an integration") - const client = yield* createClient({ server: input.server, standalone: input.standalone }) + const client = yield* createClient() const integration = yield* findIntegration(client, input.target) const methods = connectMethods(integration) if (methods.length === 0) yield* Effect.fail(new Error(`${integration.name} has no interactive login methods`)) diff --git a/packages/cli/src/commands/handlers/auth/logout.ts b/packages/cli/src/commands/handlers/auth/logout.ts index 2bd0de1d1667..1f1016ea7a68 100644 --- a/packages/cli/src/commands/handlers/auth/logout.ts +++ b/packages/cli/src/commands/handlers/auth/logout.ts @@ -12,8 +12,6 @@ export default Runtime.handler( logout({ target: Option.getOrUndefined(input.target), credential: Option.getOrUndefined(input.credential), - server: Option.getOrUndefined(input.server), - standalone: input.standalone, }).pipe(handlePromptErrors), ), ) @@ -21,15 +19,13 @@ export default Runtime.handler( const logout = Effect.fn("cli.auth.logout.run")(function* (input: { target?: string credential?: string - server?: string - standalone: boolean }) { if (!input.target) yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal") if (!input.credential) yield* requireInteractive("Pass a credential ID or label when running without an interactive terminal") intro("Log out of an account") - const client = yield* createClient({ server: input.server, standalone: input.standalone }) + const client = yield* createClient() const integrations = yield* loadIntegrations(client) const integration = yield* chooseIntegration(integrations, input.target) const credentialID = yield* chooseCredential(integration, "log out", input.credential) diff --git a/packages/cli/src/commands/handlers/auth/shared.ts b/packages/cli/src/commands/handlers/auth/shared.ts index b3d91dd7889b..737bffab56bd 100644 --- a/packages/cli/src/commands/handlers/auth/shared.ts +++ b/packages/cli/src/commands/handlers/auth/shared.ts @@ -5,7 +5,7 @@ import { ServerConnection } from "../../../services/server-connection" export const location = { directory: process.cwd() } -export const createClient = Effect.fn("cli.auth.client")(function* (input: ServerConnection.Args) { +export const createClient = Effect.fn("cli.auth.client")(function* (input: ServerConnection.Args = {}) { const server = yield* ServerConnection.resolve(input) return OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }) }) diff --git a/packages/cli/src/commands/handlers/auth/switch.ts b/packages/cli/src/commands/handlers/auth/switch.ts index 93eac23f0d64..6fc3aeb50dfc 100644 --- a/packages/cli/src/commands/handlers/auth/switch.ts +++ b/packages/cli/src/commands/handlers/auth/switch.ts @@ -12,8 +12,6 @@ export default Runtime.handler( switchAccount({ target: Option.getOrUndefined(input.target), credential: Option.getOrUndefined(input.credential), - server: Option.getOrUndefined(input.server), - standalone: input.standalone, }).pipe(handlePromptErrors), ), ) @@ -21,15 +19,13 @@ export default Runtime.handler( const switchAccount = Effect.fn("cli.auth.switch.run")(function* (input: { target?: string credential?: string - server?: string - standalone: boolean }) { if (!input.target) yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal") if (!input.credential) yield* requireInteractive("Pass a credential ID or label when running without an interactive terminal") intro("Switch account") - const client = yield* createClient({ server: input.server, standalone: input.standalone }) + const client = yield* createClient() const integrations = yield* loadIntegrations(client) const integration = yield* chooseIntegration(integrations, input.target) const credentialID = yield* chooseCredential(integration, "switch to", input.credential) diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 8a4a3f05460a..360f70a484aa 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -6,6 +6,7 @@ import { Runtime } from "../../framework/runtime" import { Config } from "../../config" import { Context, Effect, FileSystem, Option, Queue, Schedule, Semaphore } from "effect" import { ServerConnection } from "../../services/server-connection" +import { ServerFlags } from "../../services/server-flags" import { Updater } from "../../services/updater" import { UpdatePreflight } from "../../services/update-preflight" import { Npm } from "@opencode/util/npm" @@ -19,7 +20,7 @@ import { errorMessage } from "../../util/error" export default Runtime.handler(Commands, (input) => Effect.gen(function* () { const requestedDirectory = Option.getOrUndefined(input.directory) - const requestedServer = Option.getOrUndefined(input.server) + const requestedServer = (yield* ServerFlags.read()).server if (requestedDirectory !== undefined) process.chdir(requestedDirectory) const preflight = UpdatePreflight.make() yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close())) @@ -33,8 +34,6 @@ export default Runtime.handler(Commands, (input) => Effect.forkScoped, ) const server = yield* ServerConnection.resolve({ - server: requestedServer, - standalone: input.standalone, mismatch: "replace", onStart: (reason, previousVersion) => { Queue.offerUnsafe(serviceStarts, { reason, previousVersion }) diff --git a/packages/cli/src/commands/handlers/mini.ts b/packages/cli/src/commands/handlers/mini.ts index 9175311de1c8..6bcc6f5e8097 100644 --- a/packages/cli/src/commands/handlers/mini.ts +++ b/packages/cli/src/commands/handlers/mini.ts @@ -10,10 +10,7 @@ export default Runtime.handler(Commands.commands.mini, (input) => Effect.gen(function* () { const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini")) yield* Effect.promise(async () => validateMiniTerminal()) - const serverURL = Option.getOrUndefined(input.server) const server = yield* ServerConnection.resolve({ - server: serverURL, - standalone: input.standalone, mismatch: "replace", }) const config = yield* Config.Service diff --git a/packages/cli/src/commands/handlers/models.ts b/packages/cli/src/commands/handlers/models.ts index 2ad341ec4e20..f7a66bd7d0d5 100644 --- a/packages/cli/src/commands/handlers/models.ts +++ b/packages/cli/src/commands/handlers/models.ts @@ -1,6 +1,6 @@ import { OpenCode } from "@opencode/client" import { Service } from "@opencode/client/effect/service" -import { Effect, Option } from "effect" +import { Effect } from "effect" import { EOL } from "node:os" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" @@ -8,11 +8,8 @@ import { ServerConnection } from "../../services/server-connection" export default Runtime.handler( Commands.commands.models, - Effect.fn("cli.models")(function* (input) { - const server = yield* ServerConnection.resolve({ - server: Option.getOrUndefined(input.server), - standalone: input.standalone, - }) + Effect.fn("cli.models")(function* () { + const server = yield* ServerConnection.resolve() const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint), diff --git a/packages/cli/src/commands/handlers/reload.ts b/packages/cli/src/commands/handlers/reload.ts index 5d4ca38445b8..fc6c5e4e0065 100644 --- a/packages/cli/src/commands/handlers/reload.ts +++ b/packages/cli/src/commands/handlers/reload.ts @@ -1,5 +1,5 @@ import { EOL } from "node:os" -import { Effect, Option } from "effect" +import { Effect } from "effect" import { OpenCode } from "@opencode/client" import { Service } from "@opencode/client/effect/service" import { Commands } from "../commands" @@ -8,11 +8,8 @@ import { ServerConnection } from "../../services/server-connection" export default Runtime.handler( Commands.commands.reload, - Effect.fn("cli.reload")(function* (input: Runtime.Input) { - const server = yield* ServerConnection.resolve({ - server: Option.getOrUndefined(input.server), - standalone: input.standalone, - }) + Effect.fn("cli.reload")(function* () { + const server = yield* ServerConnection.resolve() const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }) yield* Effect.tryPromise({ try: (signal) => client.location.reload({ signal }), diff --git a/packages/cli/src/commands/handlers/run.ts b/packages/cli/src/commands/handlers/run.ts index b86e1bfc05cb..b7c258e631b3 100644 --- a/packages/cli/src/commands/handlers/run.ts +++ b/packages/cli/src/commands/handlers/run.ts @@ -7,10 +7,7 @@ export default Runtime.handler(Commands.commands.run, (input) => Effect.gen(function* () { const { runNonInteractive } = yield* Effect.promise(() => import("../../run/run")) const separator = process.argv.indexOf("--", 2) - const server = yield* ServerConnection.resolve({ - server: Option.getOrUndefined(input.server), - standalone: input.standalone, - }) + const server = yield* ServerConnection.resolve() yield* Effect.promise(() => runNonInteractive({ server, diff --git a/packages/cli/src/commands/handlers/session/delete.ts b/packages/cli/src/commands/handlers/session/delete.ts index 549e517309b2..181c7815d24b 100644 --- a/packages/cli/src/commands/handlers/session/delete.ts +++ b/packages/cli/src/commands/handlers/session/delete.ts @@ -1,6 +1,6 @@ import { OpenCode } from "@opencode/client" import { Service } from "@opencode/client/effect/service" -import { Effect, Option } from "effect" +import { Effect } from "effect" import { EOL } from "node:os" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" @@ -10,10 +10,7 @@ import { errorMessage } from "../../../util/error" const handler = Effect.fn("cli.session.delete")(function* ( input: Runtime.Input, ) { - const server = yield* ServerConnection.resolve({ - server: Option.getOrUndefined(input.server), - standalone: input.standalone, - }) + const server = yield* ServerConnection.resolve() const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }) yield* Effect.tryPromise({ try: (signal) => client.session.remove({ sessionID: input.sessionID }, { signal }), diff --git a/packages/cli/src/commands/handlers/session/export.ts b/packages/cli/src/commands/handlers/session/export.ts index 4cf2be2a760d..52a155b7bf64 100644 --- a/packages/cli/src/commands/handlers/session/export.ts +++ b/packages/cli/src/commands/handlers/session/export.ts @@ -16,10 +16,7 @@ export default Runtime.handler( if (!requested && !process.stdin.isTTY) { yield* Effect.fail(new Error("Pass a session ID when running without an interactive terminal")) } - const server = yield* ServerConnection.resolve({ - server: Option.getOrUndefined(input.server), - standalone: input.standalone, - }) + const server = yield* ServerConnection.resolve() const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint), diff --git a/packages/cli/src/commands/handlers/session/import.ts b/packages/cli/src/commands/handlers/session/import.ts index 59a7b62fef57..af4109e7a996 100644 --- a/packages/cli/src/commands/handlers/session/import.ts +++ b/packages/cli/src/commands/handlers/session/import.ts @@ -25,10 +25,7 @@ export default Runtime.handler( }) const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SessionTransfer.Data))(text) const encoded = Schema.encodeSync(SessionTransfer.Data)(data) - const server = yield* ServerConnection.resolve({ - server: Option.getOrUndefined(input.server), - standalone: input.standalone, - }) + const server = yield* ServerConnection.resolve() const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint), diff --git a/packages/cli/src/commands/handlers/session/list.ts b/packages/cli/src/commands/handlers/session/list.ts index 5589070c4dad..82c1e2dbc6ff 100644 --- a/packages/cli/src/commands/handlers/session/list.ts +++ b/packages/cli/src/commands/handlers/session/list.ts @@ -10,10 +10,7 @@ import { errorMessage } from "../../../util/error" const handler = Effect.fn("cli.session.list")(function* ( input: Runtime.Input, ) { - const server = yield* ServerConnection.resolve({ - server: Option.getOrUndefined(input.server), - standalone: input.standalone, - }) + const server = yield* ServerConnection.resolve() const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }) const location = yield* Effect.tryPromise({ try: (signal) => client.location.get({ location: { directory: process.cwd() } }, { signal }), diff --git a/packages/cli/src/commands/handlers/stats.ts b/packages/cli/src/commands/handlers/stats.ts index 226f0bba4a39..88ea6e3e9fef 100644 --- a/packages/cli/src/commands/handlers/stats.ts +++ b/packages/cli/src/commands/handlers/stats.ts @@ -16,10 +16,7 @@ const handler = Effect.fn("cli.stats")(function* (input: Runtime.Input 1) yield* Effect.fail(new Error("--days, --year, and --all cannot be combined")) - const server = yield* ServerConnection.resolve({ - server: Option.getOrUndefined(input.server), - standalone: input.standalone, - }) + const server = yield* ServerConnection.resolve() const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }) const range = statsRange({ days, year, all: input.all }) const projectID = diff --git a/packages/cli/src/framework/runtime.ts b/packages/cli/src/framework/runtime.ts index 76c99ab9df5e..528d834cd665 100644 --- a/packages/cli/src/framework/runtime.ts +++ b/packages/cli/src/framework/runtime.ts @@ -1,6 +1,7 @@ import { Effect, FileSystem, Scope } from "effect" import { Command } from "effect/unstable/cli" import { PrintLogs } from "../commands/commands" +import { ServerSetting, StandaloneSetting } from "../services/server-flags" import { Spec } from "./spec" import { Global } from "@opencode/util/global" import { Updater } from "../services/updater" @@ -82,7 +83,10 @@ export function handlers(root: Root, handlers: Hand } export function run(commands: Spec.Any, handlers: ReadonlyArray, options: { readonly version: string }) { - return Command.run(provide(commands, handlers).pipe(Command.withGlobalFlags([PrintLogs])), options) as Effect.Effect< + return Command.run( + provide(commands, handlers).pipe(Command.withGlobalFlags([PrintLogs, StandaloneSetting, ServerSetting])), + options, + ) as Effect.Effect< void, unknown, Command.Environment diff --git a/packages/cli/src/services/server-connection.ts b/packages/cli/src/services/server-connection.ts index db0d07ce2b75..4882807a3225 100644 --- a/packages/cli/src/services/server-connection.ts +++ b/packages/cli/src/services/server-connection.ts @@ -4,6 +4,7 @@ import { OPENCODE_VERSION } from "../version" import { Effect, Redacted } from "effect" import { Env } from "../env" import { ServiceConfig } from "./service-config" +import { ServerFlags } from "./server-flags" import { Standalone } from "./standalone" export type Args = { @@ -19,12 +20,16 @@ export type Resolved = { } export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args = {}) { - if (args.server !== undefined && args.standalone) + // Omitted overrides follow the global flags. Direct callers still work when those settings are absent. + const configured = yield* ServerFlags.read() + const standalone = args.standalone ?? configured.standalone + const server = args.server ?? configured.server + if (server !== undefined && standalone) return yield* Effect.fail(new Error("--server and --standalone cannot be combined")) - if (args.server !== undefined) { + if (server !== undefined) { const password = yield* Env.password const endpoint = { - url: args.server, + url: server, auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined, } satisfies Endpoint const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) @@ -38,7 +43,7 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg ) return { endpoint } satisfies Resolved } - if (args.standalone || (yield* ServiceConfig.read()).disabled === true) { + if (standalone || (yield* ServiceConfig.read()).disabled === true) { return { endpoint: yield* Standalone.start() } satisfies Resolved } diff --git a/packages/cli/src/services/server-flags.ts b/packages/cli/src/services/server-flags.ts new file mode 100644 index 000000000000..f57d0612c691 --- /dev/null +++ b/packages/cli/src/services/server-flags.ts @@ -0,0 +1,27 @@ +import { Effect, Option } from "effect" +import { Flag, GlobalFlag } from "effect/unstable/cli" + +export const StandaloneSetting = GlobalFlag.setting("standalone")({ + flag: Flag.boolean("standalone").pipe( + Flag.withDescription("Run with a private server instead of the background service"), + Flag.withDefault(false), + ), +}) + +export const ServerSetting = GlobalFlag.setting("server")({ + flag: Flag.string("server").pipe( + Flag.withDescription("Connect to a server URL instead of the background service"), + Flag.optional, + ), +}) + +export const read = Effect.fn("cli.server-flags.read")(function* () { + const standaloneSetting = yield* Effect.serviceOption(StandaloneSetting) + const serverSetting = yield* Effect.serviceOption(ServerSetting) + return { + standalone: Option.getOrElse(standaloneSetting, () => false), + server: Option.getOrUndefined(Option.flatMap(serverSetting, (value) => value)), + } +}) + +export * as ServerFlags from "./server-flags" diff --git a/packages/cli/test/mini-config.test.ts b/packages/cli/test/mini-config.test.ts index 3daa39a7a62b..c3eff2ddf878 100644 --- a/packages/cli/test/mini-config.test.ts +++ b/packages/cli/test/mini-config.test.ts @@ -6,6 +6,7 @@ import { mkdir, rm } from "node:fs/promises" import path from "node:path" import { Config } from "../src/config" import type { MiniCommandInput } from "../src/mini" +import { ServerSetting } from "../src/services/server-flags" import { OPENCODE_VERSION } from "../src/version" test("mini handler passes resolved CLI keybinds to the runtime", async () => { @@ -39,8 +40,6 @@ test("mini handler passes resolved CLI keybinds to the runtime", async () => { try { await Effect.runPromise( handler({ - server: Option.some(server.url.toString()), - standalone: false, continue: false, session: Option.none(), fork: false, @@ -51,6 +50,7 @@ test("mini handler passes resolved CLI keybinds to the runtime", async () => { prompt: Option.none(), demo: false, }).pipe( + Effect.provideService(ServerSetting, Option.some(server.url.toString())), Effect.provide(Config.layer), Effect.provide(Global.layerWith({ config: configDirectory, state: stateDirectory })), Effect.provide(NodeFileSystem.layer), diff --git a/packages/cli/test/server-flags.test.ts b/packages/cli/test/server-flags.test.ts new file mode 100644 index 000000000000..0aac479b0917 --- /dev/null +++ b/packages/cli/test/server-flags.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import path from "node:path" + +describe("global server flags", () => { + test("shows --standalone on plugin help", async () => { + const [root, plugin] = await Promise.all([cli(["--help"]), cli(["plugin", "list", "--help"])]) + + expect(root.exitCode).toBe(0) + expect(root.stdout).toContain("GLOBAL FLAGS") + expect(root.stdout).toContain("Run with a private server instead of the background service") + expect(plugin.exitCode).toBe(0) + expect(plugin.stdout).toContain("GLOBAL FLAGS") + expect(plugin.stdout).toContain("Run with a private server instead of the background service") + expect(plugin.stdout).toContain("Connect to a server URL instead of the background service") + }) + + test("accepts --standalone before or after plugin list", async () => { + const placements = [ + ["--standalone", "--server", "http://127.0.0.1:1", "plugin", "list"], + ["plugin", "--standalone", "list", "--server", "http://127.0.0.1:1"], + ["plugin", "list", "--server", "http://127.0.0.1:1", "--standalone"], + ] + const results = await Promise.all(placements.map((args) => cli(args))) + + for (const result of results) { + expect(result.stderr).toContain("--server and --standalone cannot be combined") + expect(result.stderr).not.toContain("Unrecognized flag") + expect(result.exitCode).not.toBe(0) + } + }) +}) + +async function cli(args: string[]) { + const child = Bun.spawn([process.execPath, "run", path.join(import.meta.dir, "../src/index.ts"), ...args], { + cwd: path.join(import.meta.dir, ".."), + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + return { stdout, stderr, exitCode } +} From 886fc7a2dd7baf3aa0a897e21077c603080bd8a6 Mon Sep 17 00:00:00 2001 From: shuv Date: Sun, 4 Oct 2026 19:52:01 -0700 Subject: [PATCH 2/4] fix(cli): reject unsupported connection flags before side effects --- packages/cli/src/commands/commands.ts | 9 +- packages/cli/src/framework/runtime.ts | 22 +- packages/cli/src/framework/spec.ts | 5 +- packages/cli/src/services/server-flags.ts | 17 +- packages/cli/test/server-flags.test.ts | 246 +++++++++++++++++++--- 5 files changed, 249 insertions(+), 50 deletions(-) diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 9bafc46560ee..c04de7917125 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -84,7 +84,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME ), }, }), - Spec.make("acp", { description: "Start an Agent Client Protocol server" }), + Spec.make("acp", { description: "Start an Agent Client Protocol server", connectionFlags: "unsupported" }), Spec.make("api", { description: "Make a request to the running server", params: { @@ -121,7 +121,9 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME "log", "repos", ]).pipe( - Argument.withDescription("Print only one path: db, home, data, config, cache, state, tmp, bin, log, repos"), + Argument.withDescription( + "Print only one path: db, home, data, config, cache, state, tmp, bin, log, repos", + ), Argument.optional, ), }, @@ -443,6 +445,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME }), Spec.make("service", { description: "Manage the background server", + connectionFlags: "unsupported", commands: [ Spec.make("start", { description: "Start the background server" }), Spec.make("restart", { description: "Restart the background server" }), @@ -488,6 +491,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME }), Spec.make("pair", { description: "Print one-time links to connect a browser or app", + connectionFlags: "unsupported", params: { url: Flag.string("url").pipe( Flag.withDescription("Use an external HTTP(S) server URL in pairing links"), @@ -506,6 +510,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME }), Spec.make("serve", { description: "Start the v2 API and web server", + connectionFlags: "unsupported", params: { hostname: Flag.string("hostname").pipe(Flag.optional), port: Flag.integer("port").pipe(Flag.optional), diff --git a/packages/cli/src/framework/runtime.ts b/packages/cli/src/framework/runtime.ts index 528d834cd665..ee3cde81b803 100644 --- a/packages/cli/src/framework/runtime.ts +++ b/packages/cli/src/framework/runtime.ts @@ -1,7 +1,7 @@ import { Effect, FileSystem, Scope } from "effect" import { Command } from "effect/unstable/cli" import { PrintLogs } from "../commands/commands" -import { ServerSetting, StandaloneSetting } from "../services/server-flags" +import { ServerFlags, ServerSetting, StandaloneSetting } from "../services/server-flags" import { Spec } from "./spec" import { Global } from "@opencode/util/global" import { Updater } from "../services/updater" @@ -68,12 +68,14 @@ export function handlers(root: Root, handlers: Hand function add(node: Spec.Any, value: RuntimeHandlers) { if (typeof value === "function") { result.push({ spec: node.spec, load: value as () => Promise<{ default: RuntimeHandler }> }) - for (const alias of node.aliases) result.push({ spec: alias.spec, load: value as () => Promise<{ default: RuntimeHandler }> }) + for (const alias of node.aliases) + result.push({ spec: alias.spec, load: value as () => Promise<{ default: RuntimeHandler }> }) return } if (value.$) { result.push({ spec: node.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> }) - for (const alias of node.aliases) result.push({ spec: alias.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> }) + for (const alias of node.aliases) + result.push({ spec: alias.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> }) } for (const [name, child] of Object.entries(node.commands)) add(child, value[name] as RuntimeHandlers) } @@ -86,19 +88,17 @@ export function run(commands: Spec.Any, handlers: ReadonlyArray, op return Command.run( provide(commands, handlers).pipe(Command.withGlobalFlags([PrintLogs, StandaloneSetting, ServerSetting])), options, - ) as Effect.Effect< - void, - unknown, - Command.Environment - > + ) as Effect.Effect } -function provide(node: Spec.Any, handlers: ReadonlyArray): ProvidedCommand { +function provide(node: Spec.Any, handlers: ReadonlyArray, localCommand?: string): ProvidedCommand { + const unsupported = localCommand ?? (node.connectionFlags === "unsupported" ? node.name : undefined) const handler = handlers.find((handler) => handler.spec === node.spec) const spec = handler ? node.spec.pipe( Command.withHandler((input) => Effect.gen(function* () { + if (unsupported) yield* ServerFlags.reject(unsupported) if (yield* PrintLogs) process.env.OPENCODE_PRINT_LOGS = "1" const module = yield* Effect.promise(handler.load) return yield* module.default(input) @@ -110,8 +110,8 @@ function provide(node: Spec.Any, handlers: ReadonlyArray): Provided const children = Object.values(node.commands) return spec.pipe( Command.withSubcommands([ - ...children.map((child) => provide(child, handlers)), - ...children.flatMap((child) => child.aliases.map((alias) => provide(alias, handlers))), + ...children.map((child) => provide(child, handlers, unsupported)), + ...children.flatMap((child) => child.aliases.map((alias) => provide(alias, handlers, unsupported))), ]), ) as ProvidedCommand } diff --git a/packages/cli/src/framework/spec.ts b/packages/cli/src/framework/spec.ts index 587993299740..fffe86813e6d 100644 --- a/packages/cli/src/framework/spec.ts +++ b/packages/cli/src/framework/spec.ts @@ -5,6 +5,7 @@ type Options readonly params?: Config readonly commands?: Commands + readonly connectionFlags?: "unsupported" } export interface Node< @@ -16,6 +17,7 @@ export interface Node< readonly spec: Spec readonly commands: Commands readonly aliases: ReadonlyArray + readonly connectionFlags?: "unsupported" } export type Any = Node, Children> @@ -41,13 +43,14 @@ export function make< const aliasSpec = options.description ? aliasCommand.pipe(Command.withDescription(options.description)) : aliasCommand - return { name: alias, spec: aliasSpec, commands, aliases: [] } + return { name: alias, spec: aliasSpec, commands, aliases: [], connectionFlags: options.connectionFlags } }) return { name, spec, commands, aliases: extra, + connectionFlags: options.connectionFlags, } } diff --git a/packages/cli/src/services/server-flags.ts b/packages/cli/src/services/server-flags.ts index f57d0612c691..ec10685f8b9b 100644 --- a/packages/cli/src/services/server-flags.ts +++ b/packages/cli/src/services/server-flags.ts @@ -3,14 +3,14 @@ import { Flag, GlobalFlag } from "effect/unstable/cli" export const StandaloneSetting = GlobalFlag.setting("standalone")({ flag: Flag.boolean("standalone").pipe( - Flag.withDescription("Run with a private server instead of the background service"), - Flag.withDefault(false), + Flag.withDescription("Run client commands with a private server; unsupported by service, pair, acp, and serve"), + Flag.optional, ), }) export const ServerSetting = GlobalFlag.setting("server")({ flag: Flag.string("server").pipe( - Flag.withDescription("Connect to a server URL instead of the background service"), + Flag.withDescription("Connect client commands to a server URL; unsupported by service, pair, acp, and serve"), Flag.optional, ), }) @@ -19,9 +19,18 @@ export const read = Effect.fn("cli.server-flags.read")(function* () { const standaloneSetting = yield* Effect.serviceOption(StandaloneSetting) const serverSetting = yield* Effect.serviceOption(ServerSetting) return { - standalone: Option.getOrElse(standaloneSetting, () => false), + standalone: Option.getOrElse(Option.flatten(standaloneSetting), () => false), server: Option.getOrUndefined(Option.flatMap(serverSetting, (value) => value)), } }) +export const reject = Effect.fn("cli.server-flags.reject")(function* (command: string) { + const standalone = yield* Effect.serviceOption(StandaloneSetting) + const server = yield* Effect.serviceOption(ServerSetting) + if (Option.isNone(Option.flatten(standalone)) && Option.isNone(Option.flatten(server))) return + return yield* Effect.fail( + new Error(`${command} does not support --server or --standalone; use a client command instead`), + ) +}) + export * as ServerFlags from "./server-flags" diff --git a/packages/cli/test/server-flags.test.ts b/packages/cli/test/server-flags.test.ts index 0aac479b0917..98c02febcf37 100644 --- a/packages/cli/test/server-flags.test.ts +++ b/packages/cli/test/server-flags.test.ts @@ -1,45 +1,227 @@ import { describe, expect, test } from "bun:test" +import fs from "node:fs/promises" import path from "node:path" +import { cleanProcessEnv } from "./fixture/clean-env" +import { ServiceConfig } from "../src/services/service-config" +import { OPENCODE_VERSION } from "../src/version" + +const unsupported = [ + ["service", "stop"], + ["service", "restart"], + ["service", "start"], + ["service", "set", "disabled", "false"], + ["service", "set", "password", "changed"], + ["service", "set", "port", "12345"], + ["service", "set", "env", "TEST_FLAG", "changed"], + ["service", "unset", "disabled"], + ["service", "unset", "password"], + ["service", "unset", "port"], + ["service", "unset", "env", "TEST_FLAG"], + ["service", "get", "password"], + ["service", "status"], + ["pair"], + ["acp"], + ["serve", "--port", "0"], +] describe("global server flags", () => { - test("shows --standalone on plugin help", async () => { - const [root, plugin] = await Promise.all([cli(["--help"]), cli(["plugin", "list", "--help"])]) - - expect(root.exitCode).toBe(0) - expect(root.stdout).toContain("GLOBAL FLAGS") - expect(root.stdout).toContain("Run with a private server instead of the background service") - expect(plugin.exitCode).toBe(0) - expect(plugin.stdout).toContain("GLOBAL FLAGS") - expect(plugin.stdout).toContain("Run with a private server instead of the background service") - expect(plugin.stdout).toContain("Connect to a server URL instead of the background service") - }) + test("documents the client-command support boundary", async () => { + const root = await fs.mkdtemp(path.join(import.meta.dir, ".server-flags-")) + try { + for (const args of [["--help"], ["plugin", "list", "--help"], ["service", "stop", "--help"], ["acp", "--help"]]) { + const result = await cli(root, args) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain("GLOBAL FLAGS") + expect(result.stdout).toContain("Run client commands with a private server") + expect(result.stdout).toContain("Connect client commands to a server URL") + expect(result.stdout).toContain("unsupported by service, pair, acp, and serve") + } + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }, 30_000) - test("accepts --standalone before or after plugin list", async () => { - const placements = [ - ["--standalone", "--server", "http://127.0.0.1:1", "plugin", "list"], - ["plugin", "--standalone", "list", "--server", "http://127.0.0.1:1"], - ["plugin", "list", "--server", "http://127.0.0.1:1", "--standalone"], - ] - const results = await Promise.all(placements.map((args) => cli(args))) - - for (const result of results) { - expect(result.stderr).toContain("--server and --standalone cannot be combined") - expect(result.stderr).not.toContain("Unrecognized flag") - expect(result.exitCode).not.toBe(0) + test.each(unsupported.map((args) => ({ args })))( + "rejects connection flags without local effects: $args", + async ({ args }) => { + const root = await fs.mkdtemp(path.join(import.meta.dir, ".server-flags-")) + const owner = Bun.spawn([process.execPath, "-e", "setInterval(() => {}, 1000)"], { + stdout: "ignore", + stderr: "ignore", + }) + const requests: string[] = [] + using local = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + requests.push(new URL(request.url).pathname) + return Response.json({ version: OPENCODE_VERSION, pid: owner.pid, urls: [] }) + }, + }) + const config = path.join(root, "config", ServiceConfig.filename()) + const registration = path.join(root, "state", "shuvcode", ServiceConfig.filename()) + const settings = JSON.stringify({ + disabled: false, + password: "fixture", + port: local.port, + env: { TEST_FLAG: "original" }, + }) + const registered = JSON.stringify({ + id: "fixture", + version: OPENCODE_VERSION, + url: local.url.toString(), + pid: owner.pid, + }) + try { + await fs.mkdir(path.dirname(config), { recursive: true }) + await fs.mkdir(path.dirname(registration), { recursive: true }) + await Bun.write(config, settings) + await Bun.write(registration, registered) + for (const flags of [["--server", local.url.toString()], ["--standalone"], ["--no-standalone"]]) { + for (const placement of placements(args, flags)) { + const result = await cli(root, placement) + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain("does not support --server or --standalone") + expect(result.stderr).not.toContain("Unrecognized flag") + expect(await Bun.file(config).text()).toBe(settings) + expect(await Bun.file(registration).text()).toBe(registered) + expect(requests).toEqual([]) + expect(owner.exitCode).toBe(null) + } + } + } finally { + owner.kill("SIGKILL") + await owner.exited + await fs.rm(root, { recursive: true, force: true }) + } + }, + 45_000, + ) + + test("supported clients still reject mutually exclusive flags across placements", async () => { + const root = await fs.mkdtemp(path.join(import.meta.dir, ".server-flags-")) + try { + for (const args of placements(["plugin", "list"], ["--standalone", "--server", "http://127.0.0.1:1"])) { + const result = await cli(root, args) + expect(result.stderr).toContain("--server and --standalone cannot be combined") + expect(result.stderr).not.toContain("Unrecognized flag") + expect(result.exitCode).not.toBe(0) + } + } finally { + await fs.rm(root, { recursive: true, force: true }) } - }) + }, 30_000) + + test("plugin list uses the requested endpoint across placements", async () => { + const root = await fs.mkdtemp(path.join(import.meta.dir, ".server-flags-")) + const requests: string[] = [] + using remote = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + requests.push(new URL(request.url).pathname) + if (new URL(request.url).pathname === "/api/info") + return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] }) + return Response.json({ data: [], context: {} }) + }, + }) + try { + for (const args of placements(["plugin", "list"], ["--server", remote.url.toString()])) { + const result = await cli(root, args) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain("No plugins found") + } + expect(requests).toEqual(Array.from({ length: 3 }, () => ["/api/info", "/api/plugin"]).flat()) + expect(await Bun.file(path.join(root, "state", "shuvcode", ServiceConfig.filename())).exists()).toBe(false) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }, 30_000) + + test("standalone uses a private endpoint across placements instead of a registered service", async () => { + const root = await fs.mkdtemp(path.join(import.meta.dir, ".server-flags-")) + const requests: string[] = [] + using local = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + requests.push(new URL(request.url).pathname) + return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] }) + }, + }) + const registration = path.join(root, "state", "shuvcode", ServiceConfig.filename()) + const registered = JSON.stringify({ + id: "local", + version: OPENCODE_VERSION, + url: local.url.toString(), + pid: process.pid, + }) + try { + await fs.mkdir(path.dirname(registration), { recursive: true }) + await Bun.write(registration, registered) + for (const args of placements(["api", "GET", "/api/info"], ["--standalone"])) { + const result = await cli(root, args) + expect(result.exitCode).toBe(0) + const info = JSON.parse(result.stdout) + expect(info.version).toBe(OPENCODE_VERSION) + expect(info.pid).toBeGreaterThan(0) + expect(info.pid).not.toBe(process.pid) + expect(await Bun.file(registration).text()).toBe(registered) + } + for (const args of placements(["plugin", "list"], ["--standalone"])) { + const result = await cli(root, args) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain("No plugins found") + expect(await Bun.file(registration).text()).toBe(registered) + } + expect(requests).toEqual([]) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }, 45_000) }) -async function cli(args: string[]) { +function placements(args: string[], flags: string[]) { + return [ + [...flags, ...args], + [args[0]!, ...flags, ...args.slice(1)], + [...args, ...flags], + ] +} + +async function cli(root: string, args: string[]) { const child = Bun.spawn([process.execPath, "run", path.join(import.meta.dir, "../src/index.ts"), ...args], { - cwd: path.join(import.meta.dir, ".."), + cwd: root, + env: { + ...cleanProcessEnv(), + HOME: root, + OPENCODE_TEST_HOME: root, + OPENCODE_CONFIG_DIR: path.join(root, "config"), + OPENCODE_CONFIG_CONTENT: "{}", + OPENCODE_DISABLE_PROJECT_CONFIG: "true", + OPENCODE_DISABLE_MODELS_FETCH: "true", + OPENCODE_DISABLE_FILEWATCHER: "true", + OPENCODE_DISABLE_FFF: "true", + OPENCODE_DB: path.join(root, "opencode.db"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_STATE_HOME: path.join(root, "state"), + }, stdout: "pipe", stderr: "pipe", }) - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(child.stdout).text(), - new Response(child.stderr).text(), - child.exited, - ]) - return { stdout, stderr, exitCode } + const timeout = setTimeout(() => child.kill("SIGKILL"), 15_000) + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + return { stdout, stderr, exitCode } + } finally { + clearTimeout(timeout) + child.kill("SIGKILL") + await child.exited + } } From 1d96d61a4dde007ed986d46d6ab9ad277d779a23 Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Sun, 4 Oct 2026 19:58:12 -0700 Subject: [PATCH 3/4] no-mistakes(review): Reject connection flags on mcp add and plugin writers --- packages/cli/src/commands/commands.ts | 4 +++ packages/cli/src/framework/runtime.ts | 15 +++++++--- packages/cli/src/services/server-flags.ts | 4 +-- packages/cli/test/server-flags.test.ts | 36 ++++++++++++++++++++++- 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index c04de7917125..3b18f89cc777 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -208,6 +208,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME Spec.make("list", { description: "List configured MCP servers and their status" }), Spec.make("add", { description: "Add an MCP server to your configuration", + connectionFlags: "unsupported", params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")), command: Argument.string("command").pipe( @@ -255,6 +256,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME }), Spec.make("add", { description: "Install a plugin and add it to the global configuration", + connectionFlags: "unsupported", params: { package: Argument.string("package").pipe(Argument.withDescription("npm registry or Git package specifier")), }, @@ -270,6 +272,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME }), Spec.make("update", { description: "Update package plugins", + connectionFlags: "unsupported", params: { target: Argument.string("target").pipe( Argument.withDescription("Configured package target; omit to update all outdated plugins"), @@ -279,6 +282,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME }), Spec.make("remove", { description: "Remove a plugin from global configuration", + connectionFlags: "unsupported", params: { package: Argument.string("package").pipe(Argument.withDescription("configured package specifier")), }, diff --git a/packages/cli/src/framework/runtime.ts b/packages/cli/src/framework/runtime.ts index ee3cde81b803..179065b6bee3 100644 --- a/packages/cli/src/framework/runtime.ts +++ b/packages/cli/src/framework/runtime.ts @@ -91,8 +91,14 @@ export function run(commands: Spec.Any, handlers: ReadonlyArray, op ) as Effect.Effect } -function provide(node: Spec.Any, handlers: ReadonlyArray, localCommand?: string): ProvidedCommand { - const unsupported = localCommand ?? (node.connectionFlags === "unsupported" ? node.name : undefined) +function provide( + node: Spec.Any, + handlers: ReadonlyArray, + localCommand?: string, + parents?: ReadonlyArray, +): ProvidedCommand { + const unsupported = + localCommand ?? (node.connectionFlags === "unsupported" ? [...(parents ?? []), node.name].join(" ") : undefined) const handler = handlers.find((handler) => handler.spec === node.spec) const spec = handler ? node.spec.pipe( @@ -108,10 +114,11 @@ function provide(node: Spec.Any, handlers: ReadonlyArray, localComm : node.spec if (!Object.keys(node.commands).length) return spec as ProvidedCommand const children = Object.values(node.commands) + const path = parents ? [...parents, node.name] : [] return spec.pipe( Command.withSubcommands([ - ...children.map((child) => provide(child, handlers, unsupported)), - ...children.flatMap((child) => child.aliases.map((alias) => provide(alias, handlers, unsupported))), + ...children.map((child) => provide(child, handlers, unsupported, path)), + ...children.flatMap((child) => child.aliases.map((alias) => provide(alias, handlers, unsupported, path))), ]), ) as ProvidedCommand } diff --git a/packages/cli/src/services/server-flags.ts b/packages/cli/src/services/server-flags.ts index ec10685f8b9b..1d4bab05d47b 100644 --- a/packages/cli/src/services/server-flags.ts +++ b/packages/cli/src/services/server-flags.ts @@ -3,14 +3,14 @@ import { Flag, GlobalFlag } from "effect/unstable/cli" export const StandaloneSetting = GlobalFlag.setting("standalone")({ flag: Flag.boolean("standalone").pipe( - Flag.withDescription("Run client commands with a private server; unsupported by service, pair, acp, and serve"), + Flag.withDescription("Run client commands with a private server; unsupported by service, pair, acp, serve, mcp add, and plugin add/update/remove"), Flag.optional, ), }) export const ServerSetting = GlobalFlag.setting("server")({ flag: Flag.string("server").pipe( - Flag.withDescription("Connect client commands to a server URL; unsupported by service, pair, acp, and serve"), + Flag.withDescription("Connect client commands to a server URL; unsupported by service, pair, acp, serve, mcp add, and plugin add/update/remove"), Flag.optional, ), }) diff --git a/packages/cli/test/server-flags.test.ts b/packages/cli/test/server-flags.test.ts index 98c02febcf37..c821e57fda44 100644 --- a/packages/cli/test/server-flags.test.ts +++ b/packages/cli/test/server-flags.test.ts @@ -34,7 +34,9 @@ describe("global server flags", () => { expect(result.stdout).toContain("GLOBAL FLAGS") expect(result.stdout).toContain("Run client commands with a private server") expect(result.stdout).toContain("Connect client commands to a server URL") - expect(result.stdout).toContain("unsupported by service, pair, acp, and serve") + expect(result.stdout).toContain( + "unsupported by service, pair, acp, serve, mcp add, and plugin add/update/remove", + ) } } finally { await fs.rm(root, { recursive: true, force: true }) @@ -98,6 +100,38 @@ describe("global server flags", () => { 45_000, ) + test.each( + [ + ["mcp", "add", "fixture", "--url", "http://127.0.0.1:1/mcp"], + ["mcp", "add", "fixture", "--url", "http://127.0.0.1:1/mcp", "--global"], + ["plugin", "add", "fixture-plugin"], + ["plugin", "update", "fixture-plugin"], + ["plugin", "remove", "fixture-plugin"], + ].map((args) => ({ args })), + )( + "rejects connection flags before config writes: $args", + async ({ args }) => { + const root = await fs.mkdtemp(path.join(import.meta.dir, ".server-flags-")) + const original = JSON.stringify({ plugin: ["fixture-plugin"], plugins: ["fixture-plugin"] }) + const files = [path.join(root, "opencode.json"), path.join(root, "config", "opencode.json")] + try { + await fs.mkdir(path.join(root, "config"), { recursive: true }) + await Promise.all(files.map((file) => Bun.write(file, original))) + for (const flags of [["--server", "http://127.0.0.1:1"], ["--standalone"]]) { + for (const placement of placements(args, flags)) { + const result = await cli(root, placement) + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain(`${args[0]} ${args[1]} does not support --server or --standalone`) + for (const file of files) expect(await Bun.file(file).text()).toBe(original) + } + } + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }, + 45_000, + ) + test("supported clients still reject mutually exclusive flags across placements", async () => { const root = await fs.mkdtemp(path.join(import.meta.dir, ".server-flags-")) try { From e6fc678dff8c73c2b649795557a93c757362cbe2 Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Sun, 4 Oct 2026 20:02:35 -0700 Subject: [PATCH 4/4] no-mistakes(document): Document global connection flags and format CLI files --- packages/cli/src/commands/handlers/auth/logout.ts | 5 +---- packages/cli/src/commands/handlers/auth/switch.ts | 5 +---- packages/cli/src/commands/handlers/default.ts | 5 ++++- packages/cli/src/services/server-flags.ts | 8 ++++++-- services/www/src/docs/content/cli/commands.mdx | 2 +- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/commands/handlers/auth/logout.ts b/packages/cli/src/commands/handlers/auth/logout.ts index 1f1016ea7a68..0d0498739f8b 100644 --- a/packages/cli/src/commands/handlers/auth/logout.ts +++ b/packages/cli/src/commands/handlers/auth/logout.ts @@ -16,10 +16,7 @@ export default Runtime.handler( ), ) -const logout = Effect.fn("cli.auth.logout.run")(function* (input: { - target?: string - credential?: string -}) { +const logout = Effect.fn("cli.auth.logout.run")(function* (input: { target?: string; credential?: string }) { if (!input.target) yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal") if (!input.credential) diff --git a/packages/cli/src/commands/handlers/auth/switch.ts b/packages/cli/src/commands/handlers/auth/switch.ts index 6fc3aeb50dfc..6b497e97ddc5 100644 --- a/packages/cli/src/commands/handlers/auth/switch.ts +++ b/packages/cli/src/commands/handlers/auth/switch.ts @@ -16,10 +16,7 @@ export default Runtime.handler( ), ) -const switchAccount = Effect.fn("cli.auth.switch.run")(function* (input: { - target?: string - credential?: string -}) { +const switchAccount = Effect.fn("cli.auth.switch.run")(function* (input: { target?: string; credential?: string }) { if (!input.target) yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal") if (!input.credential) diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 360f70a484aa..18c0d891e2b9 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -55,7 +55,10 @@ export default Runtime.handler(Commands, (input) => session !== undefined && (yield* Effect.tryPromise({ try: () => - findSession(OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }), session), + findSession( + OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }), + session, + ), catch: (cause) => new Error(errorMessage(cause)), })) !== undefined const updater = yield* Updater.Service diff --git a/packages/cli/src/services/server-flags.ts b/packages/cli/src/services/server-flags.ts index 1d4bab05d47b..47ef8733859e 100644 --- a/packages/cli/src/services/server-flags.ts +++ b/packages/cli/src/services/server-flags.ts @@ -3,14 +3,18 @@ import { Flag, GlobalFlag } from "effect/unstable/cli" export const StandaloneSetting = GlobalFlag.setting("standalone")({ flag: Flag.boolean("standalone").pipe( - Flag.withDescription("Run client commands with a private server; unsupported by service, pair, acp, serve, mcp add, and plugin add/update/remove"), + Flag.withDescription( + "Run client commands with a private server; unsupported by service, pair, acp, serve, mcp add, and plugin add/update/remove", + ), Flag.optional, ), }) export const ServerSetting = GlobalFlag.setting("server")({ flag: Flag.string("server").pipe( - Flag.withDescription("Connect client commands to a server URL; unsupported by service, pair, acp, serve, mcp add, and plugin add/update/remove"), + Flag.withDescription( + "Connect client commands to a server URL; unsupported by service, pair, acp, serve, mcp add, and plugin add/update/remove", + ), Flag.optional, ), }) diff --git a/services/www/src/docs/content/cli/commands.mdx b/services/www/src/docs/content/cli/commands.mdx index b7f8a512b002..a2f3ef45be94 100644 --- a/services/www/src/docs/content/cli/commands.mdx +++ b/services/www/src/docs/content/cli/commands.mdx @@ -3,7 +3,7 @@ title: "Commands" description: "Reference for the opencode command line." --- -Every command accepts `--help` for its full flag list, for example `opencode run --help`. Some server-backed commands accept `--standalone` to run a private server or `--server ` to target a specific one; check the command's help for its supported flags. +Every command accepts `--help` for its full flag list, for example `opencode run --help`. `--standalone` (run a private server) and `--server ` (target a specific server) are global flags accepted before or after the subcommand. `service`, `pair`, `acp`, `serve`, `mcp add`, and `plugin add`/`update`/`remove` act on the local machine and reject them. ## run