diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 3af3316a9d3..afcc69b32e7 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,5 +1,6 @@ import { Server } from "../../server/server" import { cmd } from "./cmd" +import { Wildcard } from "@/util/wildcard" export const ServeCommand = cmd({ command: "serve", @@ -15,14 +16,21 @@ export const ServeCommand = cmd({ type: "string", describe: "hostname to listen on", default: "127.0.0.1", + }) + .option("tools", { + type: "string", + describe: + "comma-separated tool patterns to enable/disable (e.g., '-*,read,write,webfetch' to only enable those three)", }), describe: "starts a headless opencode server", handler: async (args) => { const hostname = args.hostname const port = args.port + const tools = Wildcard.parseToolsPattern(args.tools) const server = Server.listen({ port, hostname, + tools, }) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) await new Promise(() => {}) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index fce9917c2e7..9b0e234f732 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -25,6 +25,8 @@ import { createColors, createFrames } from "../../ui/spinner.ts" import { useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { useToast } from "../../ui/toast" +import { useArgs } from "../../context/args" +import { Wildcard } from "@/util/wildcard" export type PromptProps = { sessionID?: string @@ -58,6 +60,7 @@ export function Prompt(props: PromptProps) { const sync = useSync() const dialog = useDialog() const toast = useToast() + const args = useArgs() const status = createMemo(() => sync.data.session_status[props.sessionID ?? ""] ?? { type: "idle" }) const history = usePromptHistory() const command = useCommandDialog() @@ -477,12 +480,14 @@ export function Prompt(props: PromptProps) { messageID, }) } else { + const tools = Wildcard.parseToolsPattern(args.tools) sdk.client.session.prompt({ sessionID, ...selectedModel, messageID, agent: local.agent.current().name, model: selectedModel, + ...(tools && { tools }), parts: [ { id: Identifier.ascending("part"), diff --git a/packages/opencode/src/cli/cmd/tui/context/args.tsx b/packages/opencode/src/cli/cmd/tui/context/args.tsx index ffd43009a41..8cc2fecf2d5 100644 --- a/packages/opencode/src/cli/cmd/tui/context/args.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/args.tsx @@ -6,6 +6,7 @@ export interface Args { prompt?: string continue?: boolean sessionID?: string + tools?: string } export const { use: useArgs, provider: ArgsProvider } = createSimpleContext({ diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 3cf8937a974..0e0a93da0b7 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -44,6 +44,11 @@ export const TuiThreadCommand = cmd({ type: "string", describe: "agent to use", }) + .option("tools", { + type: "string", + describe: + "comma-separated tool patterns to enable/disable (e.g., '-*,read,write,webfetch' to only enable those three)", + }) .option("port", { type: "number", describe: "port to listen on", @@ -105,6 +110,7 @@ export const TuiThreadCommand = cmd({ agent: args.agent, model: args.model, prompt, + tools: args.tools, }, onExit: async () => { await client.call("shutdown", undefined) diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 3d3036b1b07..9324a45aaed 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -3,6 +3,7 @@ import { UI } from "../ui" import { cmd } from "./cmd" import open from "open" import { networkInterfaces } from "os" +import { Wildcard } from "@/util/wildcard" function getNetworkIPs() { const nets = networkInterfaces() @@ -40,14 +41,21 @@ export const WebCommand = cmd({ type: "string", describe: "hostname to listen on", default: "127.0.0.1", + }) + .option("tools", { + type: "string", + describe: + "comma-separated tool patterns to enable/disable (e.g., '-*,read,write,webfetch' to only enable those three)", }), describe: "starts a headless opencode server", handler: async (args) => { const hostname = args.hostname const port = args.port + const tools = Wildcard.parseToolsPattern(args.tools) const server = Server.listen({ port, hostname, + tools, }) UI.empty() UI.println(UI.logo(" ")) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 6af1b490329..3e40ab89068 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -58,6 +58,8 @@ export namespace Server { Connected: BusEvent.define("server.connected", z.object({})), } + let defaultTools: Record | undefined + const app = new Hono() export const App = lazy(() => app @@ -1163,7 +1165,11 @@ export namespace Server { return stream(c, async (stream) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") - const msg = await SessionPrompt.prompt({ ...body, sessionID }) + const msg = await SessionPrompt.prompt({ + ...body, + sessionID, + tools: { ...defaultTools, ...body.tools }, + }) stream.write(JSON.stringify(msg)) }) }, @@ -2454,7 +2460,10 @@ export namespace Server { return result } - export function listen(opts: { port: number; hostname: string }) { + export function listen(opts: { port: number; hostname: string; tools?: Record }) { + if (opts.tools) { + defaultTools = opts.tools + } const server = Bun.serve({ port: opts.port, hostname: opts.hostname, diff --git a/packages/opencode/src/util/wildcard.ts b/packages/opencode/src/util/wildcard.ts index 9b595a0a9ec..94b148f6b40 100644 --- a/packages/opencode/src/util/wildcard.ts +++ b/packages/opencode/src/util/wildcard.ts @@ -40,6 +40,20 @@ export namespace Wildcard { return result } + export function parseToolsPattern(pattern: string | undefined): Record | undefined { + if (!pattern?.trim()) return undefined + const result: Record = {} + const parts = pattern.split(",").map((x) => x.trim()) + for (const part of parts) { + if (part.startsWith("-")) { + result[part.slice(1)] = false + } else { + result[part] = true + } + } + return result + } + function matchSequence(items: string[], patterns: string[]): boolean { if (patterns.length === 0) return true const [pattern, ...rest] = patterns diff --git a/packages/opencode/test/util/wildcard.test.ts b/packages/opencode/test/util/wildcard.test.ts index f7f1e15457b..d97bc39aca0 100644 --- a/packages/opencode/test/util/wildcard.test.ts +++ b/packages/opencode/test/util/wildcard.test.ts @@ -53,3 +53,42 @@ test("allStructured handles sed flags", () => { expect(Wildcard.allStructured({ head: "sed", tail: ["-n", "1p", "file"] }, rules)).toBe("allow") expect(Wildcard.allStructured({ head: "sed", tail: ["-i", "-n", "/./p", "myfile.txt"] }, rules)).toBe("ask") }) + +test("parseToolsPattern handles empty and undefined", () => { + expect(Wildcard.parseToolsPattern(undefined)).toBeUndefined() + expect(Wildcard.parseToolsPattern("")).toBeUndefined() + expect(Wildcard.parseToolsPattern(" ")).toBeUndefined() +}) + +test("parseToolsPattern disables all tools", () => { + expect(Wildcard.parseToolsPattern("-*")).toEqual({ "*": false }) +}) + +test("parseToolsPattern enables all tools", () => { + expect(Wildcard.parseToolsPattern("*")).toEqual({ "*": true }) +}) + +test("parseToolsPattern disables all and enables specific tools", () => { + expect(Wildcard.parseToolsPattern("-*,read,write,webfetch")).toEqual({ + "*": false, + read: true, + write: true, + webfetch: true, + }) +}) + +test("parseToolsPattern enables only specific tools", () => { + expect(Wildcard.parseToolsPattern("read,write,webfetch")).toEqual({ + read: true, + write: true, + webfetch: true, + }) +}) + +test("parseToolsPattern handles whitespace", () => { + expect(Wildcard.parseToolsPattern(" -* , read , write ")).toEqual({ + "*": false, + read: true, + write: true, + }) +})