diff --git a/packages/core/src/tool/plugin/opencode.ts b/packages/core/src/tool/plugin/opencode.ts new file mode 100644 index 000000000000..3e4b65b3775a --- /dev/null +++ b/packages/core/src/tool/plugin/opencode.ts @@ -0,0 +1,299 @@ +export * as OpenCodeTools from "./opencode.js" + +import { SystemPart, ToolFailure } from "@opencode/ai" +import type { Context } from "@opencode/plugin/effect/plugin" +import type { SessionHooks } from "@opencode/plugin/effect/session" +import { Model } from "@opencode/schema/model" +import { AbsolutePath } from "@opencode/schema/schema" +import { Session } from "@opencode/schema/session" +import { Effect, Schema } from "effect" +import { FileAccess } from "../../file-access.js" +import { Git } from "../../git.js" +import { Permission } from "../../permission.js" +import { Worktree } from "../../worktree.js" + +export const RenameInput = Schema.Struct({ + sessionID: Schema.optionalKey(Session.ID).annotate({ description: "Omit to rename the current session." }), + title: Schema.String.check(Schema.isMinLength(1)).annotate({ description: "New session title." }), +}) + +const RenameOutput = Schema.Struct({ sessionID: Session.ID, title: Schema.String }) + +export const MoveInput = Schema.Struct({ + sessionID: Schema.optionalKey(Session.ID).annotate({ description: "Omit to move the current session." }), + directory: AbsolutePath.check(Schema.isMinLength(1)).annotate({ + description: "Destination directory, relative to the target session's directory or absolute. Supports ~.", + }), +}) + +const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePath }) + +export const WorktreeCreateInput = Schema.Struct({ + name: Schema.Trim.check(Schema.isNonEmpty()).annotate({ description: "Worktree name." }), +}) + +export const WorktreeRemoveInput = Schema.Struct({ + directory: AbsolutePath.check(Schema.isMinLength(1)).annotate({ + description: "Absolute path of the worktree to remove.", + }), + force: Schema.optionalKey(Schema.Boolean).annotate({ + description: "Discard modified and untracked files. Only set this when the user has confirmed forced removal.", + }), +}) + +const WorktreeRemoveOutput = Schema.Struct({ directory: AbsolutePath, removed: Schema.Boolean }) + +export const ModelsInput = Schema.Struct({ + query: Schema.optionalKey(Schema.String).annotate({ + description: "Text to search for in model names and IDs.", + }), + provider: Schema.optionalKey(Schema.String).annotate({ + description: "Provider ID or name to filter by. Try your own provider first.", + }), + all: Schema.optionalKey(Schema.Boolean).annotate({ + description: "Include older versions of each model family. By default only the newest version is listed.", + }), + limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))).annotate({ + description: "Maximum number of models to return. Defaults to 20.", + }), + offset: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))).annotate({ + description: "Number of models to skip, for paging through results.", + }), +}) + +const ModelEntry = Schema.Struct({ + id: Schema.String.annotate({ description: "providerID/modelID" }), + name: Schema.String, + released: Model.Info.fields.time.fields.released.annotate({ + description: "Release date as a Unix timestamp in milliseconds, or 0 when unknown.", + }), + variants: Schema.Array(Model.VariantID), + cost: Model.Info.fields.cost.annotate({ description: "Pricing in USD per million tokens." }), + status: Model.Info.fields.status, +}) + +const ModelsOutput = Schema.Struct({ + providers: Schema.Array( + Schema.Struct({ + id: Schema.String, + name: Schema.String, + models: Schema.Array(ModelEntry).annotate({ description: "Newest first." }), + }), + ).annotate({ description: "Matching models grouped by provider. Your own provider comes first." }), + total: Schema.Int.annotate({ description: "Number of matching models across all pages." }), + next: Schema.NullOr(Schema.Int).annotate({ description: "Offset of the next page, or null on the last page." }), +}) + +export const Plugin = { + id: "opencode.tools", + effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) { + const access = yield* FileAccess.Service + const permission = yield* Permission.Service + const hook = (event: SessionHooks["context"]) => + Effect.sync(() => { + event.system.push( + SystemPart.make( + "When you create a worktree outside the current working directory and intend to use it as your primary working directory, consider using `execute` to call `tools.opencode.session_move` and make the worktree the session's working directory.", + ), + ) + }) + yield* ctx.session.hook("context", hook) + yield* ctx.session.hook("compaction", hook) + yield* ctx.session.hook("generate", hook) + yield* ctx.tool + .transform((draft) => { + draft.namespace({ + name: "opencode", + description: + "Tools for managing OpenCode itself, such as working with sessions and worktrees, searching the available models, and reading MCP resources.", + }) + draft.add({ + name: "session_rename", + description: + "Rename a session, or omit sessionID to rename the current session. Use a short, specific title that summarizes the work being done.", + input: RenameInput, + output: RenameOutput, + options: { namespace: "opencode", codemode: true }, + execute: (input, context) => { + const sessionID = input.sessionID ?? context.sessionID + const title = input.title.trim() + if (!title) return Effect.fail(new ToolFailure({ message: "Session title must not be empty" })) + return ctx.session.update({ sessionID, title }).pipe( + Effect.as({ + output: { sessionID, title }, + content: `Renamed session ${sessionID} to ${title}.`, + }), + Effect.mapError((error) => new ToolFailure({ message: `Unable to rename session ${sessionID}`, error })), + ) + }, + }) + draft.add({ + name: "session_move", + description: + "Move a session to another directory, or omit sessionID to move the current session. The current session moves at the next safe boundary; do not run destination-dependent tools in the same execute call.", + input: MoveInput, + output: MoveOutput, + options: { namespace: "opencode", codemode: true, pinned: true }, + execute: (input, context) => + Effect.gen(function* () { + const sessionID = input.sessionID ?? context.sessionID + yield* ctx.session.move({ + sessionID, + directory: input.directory, + delivery: "steer", + }) + return { + output: { sessionID, directory: input.directory }, + content: `Moved session ${sessionID} to ${input.directory}.`, + } + }).pipe( + Effect.mapError( + (error) => new ToolFailure({ message: `Unable to move session to ${input.directory}`, error }), + ), + ), + }) + draft.add({ + name: "worktree_create", + description: "Create a named worktree. Returns its directory.", + input: WorktreeCreateInput, + output: Worktree.Info, + options: { namespace: "opencode", codemode: true, pinned: true }, + execute: (input) => + ctx.worktree.create({ projectID: ctx.location.project.id, name: input.name }).pipe( + Effect.map((output) => ({ output, content: `Created worktree in ${output.directory}.` })), + Effect.mapError( + (error) => new ToolFailure({ message: `Unable to create worktree ${input.name}`, error }), + ), + ), + }) + draft.add({ + name: "worktree_list", + description: "List a repository's worktrees.", + input: Schema.Struct({}), + output: Schema.Struct({ worktrees: Worktree.List }), + options: { namespace: "opencode", codemode: true, pinned: true }, + execute: () => + ctx.worktree.list({ projectID: ctx.location.project.id }).pipe( + Effect.map((worktrees) => ({ output: { worktrees } })), + Effect.mapError((error) => new ToolFailure({ message: "Unable to list worktrees", error })), + ), + }) + draft.add({ + name: "worktree_remove", + description: + "Remove a worktree. The repository location selects configuration; directory identifies the worktree to remove.", + input: WorktreeRemoveInput, + output: WorktreeRemoveOutput, + options: { namespace: "opencode", codemode: true, pinned: true, permission: "edit" }, + execute: (input, context) => + Effect.gen(function* () { + // Removing a worktree deletes files, so it requires the same approval as editing them. + const target = yield* access.resolve({ path: input.directory, kind: "directory" }) + yield* permission.assert({ + action: "edit", + resources: [target.resource], + save: ["*"], + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.messageID, id: context.id }, + }) + yield* ctx.worktree + .remove({ + projectID: ctx.location.project.id, + directory: target.absolute, + force: input.force ?? false, + }) + .pipe(Effect.mapError((error) => new ToolFailure({ message: removeFailure(target.absolute, error) }))) + return { + output: { directory: target.absolute, removed: true }, + content: `Removed worktree ${target.absolute}.`, + } + }).pipe( + Effect.mapError((error) => + error instanceof ToolFailure + ? error + : new ToolFailure({ message: `Unable to remove worktree ${input.directory}`, error }), + ), + ), + }) + draft.add({ + name: "models", + description: + "Search the models available to use. Use this to turn a model name the user mentions into an exact reference before running a subagent on it. Check your own provider first.", + input: ModelsInput, + output: ModelsOutput, + options: { namespace: "opencode", codemode: true }, + execute: (input, context) => + Effect.gen(function* () { + const offset = input.offset ?? 0 + const limit = input.limit ?? 20 + const own = (yield* ctx.session.get({ sessionID: context.sessionID })).model?.providerID + const terms = input.query?.toLowerCase().split(/\s+/).filter(Boolean) ?? [] + const names = new Map((yield* ctx.provider.list()).data.map((provider) => [provider.id, provider.name])) + const provider = input.provider?.toLowerCase() + const matching = (yield* ctx.model.list()).data + .filter( + (model) => + provider === undefined || + model.providerID.toLowerCase() === provider || + names.get(model.providerID)?.toLowerCase() === provider, + ) + .filter((model) => { + const text = `${model.providerID}/${model.id} ${model.name}`.toLowerCase() + return terms.every((term) => text.includes(term)) + }) + .toSorted( + (left, right) => + Number(right.providerID === own) - Number(left.providerID === own) || + left.providerID.localeCompare(right.providerID) || + right.time.released - left.time.released, + ) + .filter((model, index, sorted) => { + if (input.all || model.family === undefined) return true + return ( + sorted.findIndex( + (other) => other.providerID === model.providerID && other.family === model.family, + ) === index + ) + }) + const page = matching.slice(offset, offset + limit) + const providers = Array.from(new Set(page.map((model) => model.providerID))).map((id) => ({ + id, + name: names.get(id) ?? id, + models: page + .filter((model) => model.providerID === id) + .map((model) => ({ + id: `${model.providerID}/${model.id}`, + name: model.name, + released: model.time.released, + variants: model.variants.map((variant) => variant.id), + cost: model.cost, + status: model.status, + })), + })) + return { + output: { + providers, + total: matching.length, + next: offset + limit < matching.length ? offset + limit : null, + }, + } + }).pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to list models", error }))), + }) + }) + .pipe(Effect.orDie) + }), +} + +// Worktree errors carry no model-facing message, and Git's own text suggests forcing removal. +function removeFailure(directory: string, error: unknown) { + if (error instanceof Worktree.DirectoryUnavailableError) + return `Unable to remove worktree ${directory}: the directory does not exist or is not a directory.` + if (error instanceof Worktree.InvalidDirectoryError) + return `Unable to remove ${directory}: it is not a worktree of this project. Use worktree_list to see the project's worktrees.` + if (error instanceof Worktree.StrategyUnavailableError) + return `Unable to remove worktree ${directory}: its worktree strategy ${error.strategy} is not available.` + if ((error instanceof Git.WorktreeError || error instanceof Worktree.OperationError) && error.forceRequired) + return `Unable to remove worktree ${directory}: it has modified or untracked files.` + return `Unable to remove worktree ${directory}.` +} diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 0d5b52180aac..3631e1f94191 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -1,52 +1,125 @@ -import { AgentV2 } from "@opencode-ai/core/agent" -import { AISDK } from "@opencode-ai/core/aisdk" -import { Catalog } from "@opencode-ai/core/catalog" -import { CommandV2 } from "@opencode-ai/core/command" -import { Credential } from "@opencode-ai/core/credential" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { EventV2 } from "@opencode-ai/core/event" -import { FileSystem } from "@opencode-ai/core/filesystem" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { Integration } from "@opencode-ai/core/integration" -import { Location } from "@opencode-ai/core/location" -import { Npm } from "@opencode-ai/core/npm" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { Reference } from "@opencode-ai/core/reference" -import { SkillV2 } from "@opencode-ai/core/skill" +import { Agent } from "@opencode/core/agent" +import { AISDK } from "@opencode/core/aisdk" +import { Command } from "@opencode/core/command" +import { Config } from "@opencode/core/config" +import { Credential } from "@opencode/core/credential" +import { Database } from "@opencode/core/database/database" +import { FileAccess } from "@opencode/core/file-access" +import { LayerNodePlatform } from "@opencode/util/effect/app-node-platform" +import { AppProcess } from "@opencode/util/process" +import { LayerNode } from "@opencode/util/effect/layer-node" +import { Bus } from "@opencode/core/bus" +import { FileSystem } from "@opencode/core/filesystem" +import { FSUtil } from "@opencode/util/fs-util" +import { Form } from "@opencode/core/form" +import { Generate } from "@opencode/core/generate" +import { Integration } from "@opencode/core/integration" +import { KV } from "@opencode/core/kv" +import { Location } from "@opencode/core/location" +import { ManagedPolicy } from "@opencode/core/managed-policy" +import { Mcp } from "@opencode/core/mcp/index" +import { Model } from "@opencode/core/model" +import { Npm } from "@opencode/util/npm" +import { Plugin } from "@opencode/core/plugin" +import { PluginHooks } from "@opencode/core/plugin/hooks" +import { Provider } from "@opencode/core/provider" +import { Session } from "@opencode/core/session" +import { PersistentPty } from "@opencode/core/persistent-pty" +import { LocationServiceMap } from "@opencode/core/location-service-map" +import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" +import { Permission } from "@opencode/core/permission" +import { Reference } from "@opencode/core/reference" +import { Rpc } from "@opencode/core/rpc" +import { Skill } from "@opencode/core/skill" +import { SkillDiscovery } from "@opencode/core/skill/discovery" +import { Watcher } from "@opencode/core/filesystem/watcher" +import { Tool } from "@opencode/core/tool" +import { Vcs } from "@opencode/core/vcs" +import { WebSearch } from "@opencode/core/websearch" +import { Worktree } from "@opencode/core/worktree" +import { WorktreeStrategies } from "@opencode/core/worktree/strategies" import { Effect, Layer } from "effect" import { tempLocationLayer } from "../fixture/location" +import { emptyMcpLayer } from "../fixture/mcp" const npmLayer = Layer.succeed( Npm.Service, Npm.Service.of({ - add: () => Effect.succeed({ directory: "", entrypoint: undefined }), - install: () => Effect.void, - which: () => Effect.succeed(undefined), + add: (name) => Effect.succeed({ directory: "", name }), + resolve: (name) => Effect.succeed({ directory: "", name }), + check: () => Effect.succeed(false), + update: (name) => Effect.succeed({ directory: "", name }), + which: () => Effect.undefined, }), ) -export const PluginTestLayer = AppNodeBuilder.build( - LayerNode.group([ - FileSystem.node, - FSUtil.node, - Location.node, - Npm.node, - Credential.node, - EventV2.node, - LayerNodePlatform.httpClient, - PluginV2.node, - AgentV2.node, - AISDK.node, - Catalog.node, - CommandV2.node, - Integration.node, - Reference.node, - SkillV2.node, - ]), - [ - [Location.node, tempLocationLayer], - [Npm.node, npmLayer], - ], +const generateLayer = Layer.succeed(Generate.Service, Generate.Service.of({ text: () => Effect.succeed("") })) + +const configLayer = Config.testLayer() + +const permissionLayer = Layer.succeed( + Permission.Service, + Permission.Service.of({ + close: Effect.void, + ask: (input) => Effect.succeed({ id: input.id ?? Permission.ID.create(), effect: "ask" }), + assert: () => Effect.void, + reply: () => Effect.void, + get: () => Effect.succeed(undefined), + forSession: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }), ) + +export const makePluginTestLayer = (permission = permissionLayer) => + AppNodeBuilder.build( + LayerNode.group([ + AppProcess.node, + FileSystem.node, + FSUtil.node, + Location.node, + Npm.node, + Credential.node, + Config.node, + Database.node, + FileAccess.node, + Bus.node, + Form.node, + Generate.node, + LayerNodePlatform.httpClient, + Plugin.node, + Agent.node, + AISDK.node, + Provider.node, + Model.node, + Command.node, + Integration.node, + KV.node, + ManagedPolicy.node, + Mcp.node, + Session.node, + PersistentPty.node, + LocationServiceMap.node, + Permission.node, + PluginHooks.node, + Reference.node, + Rpc.node, + Skill.node, + SkillDiscovery.node, + Tool.node, + Vcs.node, + Watcher.node, + WebSearch.node, + Worktree.node, + WorktreeStrategies.node, + ]), + [ + Location.node.replace(tempLocationLayer), + Npm.node.replace(npmLayer), + Config.node.replace(configLayer), + Mcp.node.replace(emptyMcpLayer), + Generate.node.replace(generateLayer), + Permission.node.replace(permission), + ], + ).pipe(Layer.provideMerge(configLayer)) + +export const PluginTestLayer = makePluginTestLayer() diff --git a/packages/core/test/tool-opencode.test.ts b/packages/core/test/tool-opencode.test.ts new file mode 100644 index 000000000000..42737e345765 --- /dev/null +++ b/packages/core/test/tool-opencode.test.ts @@ -0,0 +1,258 @@ +import { expect } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Database } from "@opencode/core/database/database" +import { Location } from "@opencode/core/location" +import { Permission } from "@opencode/core/permission" +import { Plugin } from "@opencode/core/plugin" +import { PluginHost } from "@opencode/core/plugin/host" +import { ProjectTable } from "@opencode/core/project/sql" +import { Provider } from "@opencode/core/provider" +import { AbsolutePath } from "@opencode/core/schema" +import { Session } from "@opencode/core/session" +import { Tool } from "@opencode/core/tool" +import { OpenCodeTools } from "@opencode/core/tool/plugin/opencode" +import { WorktreeTable } from "@opencode/core/worktree/sql" +import { WorktreeStrategies } from "@opencode/core/worktree/strategies" +import { Model } from "@opencode/schema/model" +import { Effect } from "effect" +import { initRepo } from "./fixture/git" +import { tmpdirScoped } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" +import { permissionLayer } from "./lib/permission" +import { executeTool, toolIdentity } from "./lib/tool" +import { makePluginTestLayer, PluginTestLayer } from "./plugin/fixture" + +const it = testEffect(PluginTestLayer) + +const alpha = { id: "test/alpha", name: "Alpha", released: 300, variants: ["fast"], cost: [], status: "beta" } +const beta = { id: "other/beta", name: "Beta", released: 200, variants: [], cost: [], status: "active" } +const gamma = { id: "other/gamma", name: "Gamma Flash", released: 100, variants: [], cost: [], status: "active" } +const gammaOld = { + id: "other/gamma-old", + name: "Gamma Flash Old", + released: 50, + variants: [], + cost: [], + status: "active", +} + +it.effect("groups available models by provider with paging", () => + Effect.gen(function* () { + const catalog = yield* Provider.Service + const plugins = yield* Plugin.Service + const sessions = yield* Session.Service + const location = yield* Location.Service + const pluginHost = yield* PluginHost.make(plugins) + yield* catalog.transform((editor) => { + editor.update(Provider.ID.make("other"), (provider) => { + provider.name = "Other Provider" + }) + editor.models.update(Provider.ID.make("test"), Model.ID.make("alpha"), (model) => { + model.name = "Alpha" + model.time.released = 300 + model.variants = [{ id: Model.VariantID.make("fast") }] + model.status = "beta" + }) + editor.models.update(Provider.ID.make("other"), Model.ID.make("beta"), (model) => { + model.name = "Beta" + model.time.released = 200 + }) + editor.models.update(Provider.ID.make("other"), Model.ID.make("gamma"), (model) => { + model.name = "Gamma Flash" + model.time.released = 100 + model.family = Model.Family.make("gamma") + }) + editor.models.update(Provider.ID.make("other"), Model.ID.make("gamma-old"), (model) => { + model.name = "Gamma Flash Old" + model.time.released = 50 + model.family = Model.Family.make("gamma") + }) + editor.models.update(Provider.ID.make("other"), Model.ID.make("disabled"), (model) => { + model.time.released = 400 + model.enabled = false + }) + }) + yield* OpenCodeTools.Plugin.effect(pluginHost) + // The caller runs on `test`, which sorts first despite `other` coming earlier alphabetically. + const session = yield* sessions.create({ + location: Location.Ref.make({ directory: location.directory }), + model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("alpha") }), + }) + const registry = yield* Tool.Service + const run = (input: Record) => + executeTool(registry, { + sessionID: session.id, + ...toolIdentity, + call: { + type: "tool-call", + id: `call-${JSON.stringify(input)}`, + name: "execute", + input: { code: `return await tools.opencode.models(${JSON.stringify(input)})` }, + }, + }).pipe(Effect.map((result) => JSON.parse(result.content?.[0]?.type === "text" ? result.content[0].text : ""))) + + // Grouped by provider, newest first within each, disabled models excluded. + expect(yield* run({})).toEqual({ + providers: [ + { id: "test", name: "test", models: [alpha] }, + { id: "other", name: "Other Provider", models: [beta, gamma] }, + ], + total: 3, + next: null, + }) + + // Paging slices the ordered list, so a page can end inside a provider group. + expect(yield* run({ limit: 2 })).toEqual({ + providers: [ + { id: "test", name: "test", models: [alpha] }, + { id: "other", name: "Other Provider", models: [beta] }, + ], + total: 3, + next: 2, + }) + expect(yield* run({ limit: 2, offset: 2 })).toEqual({ + providers: [{ id: "other", name: "Other Provider", models: [gamma] }], + total: 3, + next: null, + }) + + expect(yield* run({ provider: "other provider" })).toMatchObject({ total: 2, providers: [{ id: "other" }] }) + expect(yield* run({ provider: "test" })).toEqual({ + providers: [{ id: "test", name: "test", models: [alpha] }], + total: 1, + next: null, + }) + + // Every word of the query must appear somewhere in the reference or display name, ignoring case. + expect(yield* run({ query: "GAMMA" })).toEqual({ + providers: [{ id: "other", name: "Other Provider", models: [gamma] }], + total: 1, + next: null, + }) + expect(yield* run({ query: "test/" })).toMatchObject({ total: 1, providers: [{ id: "test" }] }) + expect(yield* run({ query: "other flash" })).toMatchObject({ total: 1, providers: [{ models: [gamma] }] }) + expect(yield* run({ query: "gamma beta" })).toEqual({ providers: [], total: 0, next: null }) + + // Only the newest model of each family is listed unless `all` is set; the query is applied first. + expect(yield* run({ all: true })).toMatchObject({ + total: 4, + providers: [{ id: "test" }, { id: "other", models: [beta, gamma, gammaOld] }], + }) + expect(yield* run({ query: "old" })).toMatchObject({ total: 1, providers: [{ models: [gammaOld] }] }) + expect(yield* run({ provider: "other", query: "alpha" })).toEqual({ providers: [], total: 0, next: null }) + }), +) + +const approvals = { assertions: [] as Permission.AssertInput[], denyEdit: false } +const worktreeIt = testEffect( + makePluginTestLayer( + permissionLayer({ + assert: (input) => + Effect.sync(() => approvals.assertions.push(input)).pipe( + Effect.andThen( + approvals.denyEdit && input.action === "edit" + ? Effect.fail( + new Permission.BlockedError({ rules: [], permission: input.action, resources: input.resources }), + ) + : Effect.void, + ), + ), + }), + ), +) + +worktreeIt.live("creates, lists, and removes worktrees for the plugin's project", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const sessions = yield* Session.Service + const location = yield* Location.Service + const strategies = yield* WorktreeStrategies.Service + const database = yield* Database.Service + const parent = yield* tmpdirScoped() + yield* Effect.promise(() => initRepo(location.directory)) + yield* database.db + .insert(ProjectTable) + .values({ + id: location.project.id, + worktree: location.directory, + sandboxes: [], + time_created: 1, + time_updated: 1, + }) + .onConflictDoUpdate({ target: ProjectTable.id, set: { worktree: location.directory } }) + .run() + .pipe(Effect.orDie) + yield* database.db + .insert(WorktreeTable) + .values({ project_id: location.project.id, directory: location.directory }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* strategies.transform((editor) => editor.configure({ directory: AbsolutePath.make(parent.path) })) + yield* OpenCodeTools.Plugin.effect(yield* PluginHost.make(plugins)) + const session = yield* sessions.create({ location: Location.Ref.make({ directory: location.directory }) }) + const registry = yield* Tool.Service + const call = (name: string, input: Record) => + executeTool(registry, { + sessionID: session.id, + ...toolIdentity, + call: { + type: "tool-call", + id: `call-${name}-${JSON.stringify(input)}`, + name: "execute", + input: { code: `return await tools.opencode.${name}(${JSON.stringify(input)})` }, + }, + }) + const run = (name: string, input: Record) => + call(name, input).pipe( + Effect.map((result) => JSON.parse(result.content?.[0]?.type === "text" ? result.content[0].text : "")), + ) + const directory = path.join(parent.path, "task") + + expect(yield* run("worktree_create", { name: " task " })).toEqual({ directory }) + expect(yield* Effect.promise(() => fs.stat(directory).then((stat) => stat.isDirectory()))).toBe(true) + expect((yield* run("worktree_list", {})).worktrees).toContainEqual({ directory, strategy: "git" }) + + // The project checkout is not a removable worktree. + expect(yield* call("worktree_remove", { directory: location.directory })).toMatchObject({ + content: [ + { + type: "text", + text: `Unable to remove ${location.directory}: it is not a worktree of this project. Use worktree_list to see the project's worktrees.`, + }, + ], + metadata: { error: true }, + }) + expect(yield* Effect.promise(() => fs.stat(location.directory).then((stat) => stat.isDirectory()))).toBe(true) + + // A dirty worktree is kept, and the failure leaves the choice to force removal to the model. + yield* Effect.promise(() => fs.writeFile(path.join(directory, "draft.txt"), "draft")) + expect(yield* call("worktree_remove", { directory })).toMatchObject({ + content: [{ type: "text", text: `Unable to remove worktree ${directory}: it has modified or untracked files.` }], + }) + expect(yield* Effect.promise(() => Bun.file(path.join(directory, "draft.txt")).exists())).toBe(true) + yield* Effect.promise(() => fs.rm(path.join(directory, "draft.txt"))) + + // Removal requires the same approval as editing files in the worktree. + approvals.assertions.length = 0 + approvals.denyEdit = true + expect(yield* call("worktree_remove", { directory })).toMatchObject({ metadata: { error: true } }) + expect(yield* Effect.promise(() => Bun.file(path.join(directory, ".git")).exists())).toBe(true) + approvals.denyEdit = false + + // Relative paths resolve from the session's directory for both approval and removal. + expect(yield* run("worktree_remove", { directory: path.relative(location.directory, directory) })).toEqual({ + directory, + removed: true, + }) + expect(yield* Effect.promise(() => Bun.file(path.join(directory, ".git")).exists())).toBe(false) + // Permission resources always use forward slashes, including on Windows. + const resource = directory.replaceAll("\\", "/") + expect(approvals.assertions).toMatchObject([ + { action: "edit", resources: [resource], save: ["*"], sessionID: session.id }, + { action: "edit", resources: [resource], save: ["*"], sessionID: session.id }, + ]) + expect((yield* run("worktree_list", {})).worktrees).not.toContainEqual({ directory, strategy: "git" }) + }), +) diff --git a/services/www/src/docs/content/tools.mdx b/services/www/src/docs/content/tools.mdx new file mode 100644 index 000000000000..884119a2908a --- /dev/null +++ b/services/www/src/docs/content/tools.mdx @@ -0,0 +1,284 @@ +--- +title: "Tools" +--- + +Ask for the outcome you want. OpenCode chooses the available tools, requests +approval when required, and returns the result. + +```text +Find where request timeouts are configured, change the default to 30 seconds, +and run the relevant existing tests. +``` + +This task typically uses `grep` or `glob`, `read`, an editing tool, and `shell`. +You can also name a tool when you want a specific approach. + +```text +Use grep to find every reference to DEFAULT_TIMEOUT, then read the matching files. +``` + +## Files + +### Read + +`read` returns a file or a non-recursive directory listing. Give it a `path`; +use `offset` and `limit` to page through large results. + +```text +Read src/server.ts starting at line 120 and return at most 80 lines. +``` + +Text reads contain numbered lines and are limited to 2,000 lines and 50 KiB per +page. Individual lines are shortened after 2,000 characters. The tool also +passes PNG, JPEG, GIF, WebP, and PDF files to the model, up to 20 MiB of source +data. Other binary files fail. See [Attachments](/attachments) for supported +media and image processing. + +Permission: `read` with the normalized path as its resource. + +### Glob + +`glob` finds file paths from a pattern such as `**/*.ts`. It searches the +current directory unless `path` selects another relative directory; `hidden` +includes hidden entries and `limit` caps the results. + +```text +Use glob with **/*.test.ts under packages/core and return at most 50 files. +``` + +The default limit is 100 files and the search timeout is 30 seconds. +Permission: `glob` with the requested pattern as its resource. + +### Grep + +`grep` searches file contents and returns paths, line numbers, and previews. +Its `pattern` uses regular-expression syntax unless `literal` is true. Narrow a +search with `path`, `include`, `caseSensitive`, or `limit`. + +```text +Use grep for the literal text "request timed out" in src, including only *.ts files. +``` + +The default limit is 100 matching lines and the search timeout is 30 seconds. +Permission: `grep` with the requested pattern, not the search path, as its +resource. + +### Edit + +`edit` replaces text in an existing file. Supply `path`, `oldString`, and a +different `newString`; the old text must match exactly once unless `replaceAll` +is true. + +```text +In src/config.ts, replace the unique text "timeout: 60" with "timeout: 30". +``` + +Use `edit` for a focused change. OpenCode may format the file after writing it. +Permission: `edit` with the target path as its resource. + +### Write + +`write` creates or completely replaces a text file from `path` and `content`. +It creates missing parent directories and may run the configured formatter. + +```text +Create docs/example.md with a heading and one setup command. +``` + +Use `edit` instead when only part of an existing file should change. +Permission: `edit` with the target path as its resource. + +### Patch + +`patch` applies one patch that can add, update, move, or delete several files. +Its `patchText` uses `*** Add File`, `*** Update File`, and `*** Delete File` +sections. + +```text +Use patch to rename src/old.ts to src/new.ts and update its exported name. +``` + +OpenCode exposes `patch` for supported GPT models; other models receive `edit` +and `write` instead. Permission: `edit` with every affected path as a resource. + +Paths outside the active Location or its project worktree also require +`external_directory` approval. See [Permissions](/permissions) for path +normalization, rule order, and saved approvals. To make outside material +available by name, configure [References](/references). + +## Commands + +### Shell + +`shell` executes a command in the host user's shell. Set `workdir` instead of +putting `cd` in the command, and set `timeout` in milliseconds when the default +two-minute foreground timeout is not suitable. + +```text +Run bun test from packages/core with a five-minute timeout. +``` + +Set `background` to true for a dev server or another long-running process. +Background calls return immediately and notify the session when they finish; +they have no timeout unless one is supplied. Large output is shortened and the +complete output is retained in a managed file. + +Permission: `shell` with each scanner-produced command as a resource. The +scanner also checks an external working directory and directories it can infer +from the command. Shell has the host user's filesystem, process, and network +authority, so use narrow rules in [Permissions](/permissions). + +## Web + +### Webfetch + +`webfetch` retrieves one HTTP or HTTPS URL as `markdown`, `text`, or `html`. +Markdown is the default; `timeout` accepts up to 120 seconds. + +```text +Fetch https://example.com/docs as markdown. +``` + +It accepts textual responses such as HTML, plain text, JSON, and XML, but not +images or other binary downloads. Large output can be shortened while the full +text is retained in managed storage. Permission: `webfetch` with the requested +URL as its resource. + +### Websearch + +`websearch` searches the selected integration for current information. Its +input is a search `query`. + +```text +Search the web for the latest Bun release notes. +``` + +Permission: `websearch` with the query as its resource. See [Websearch](/websearch) +for providers, selection, rate limits, and disabling the tool. + +## Interaction + +### Question + +`question` pauses execution and presents one or more questions to the user. +Each question has a short header, prompt, and choices; `multiple` allows more +than one choice. A free-form answer is always available. + +```text +Before changing the API, ask me to choose between a breaking change and a compatibility layer. +``` + +Permission: `question` with `*` as its resource. A client must support the +interactive form, and dismissing it cancels the question. + +### Skill + +`skill` loads the instructions and bundled resources for one available skill +ID into the conversation. + +```text +Load the effect skill before changing this Effect code. +``` + +The ID must be in the advertised skill list or explicitly named by the user. +Permission: `skill` with the skill ID as its resource. See [Skills](/skills) for +creation, discovery, and loading rules. + +## Automation + +### Subagent + +`subagent` starts a child session with a configured subagent. Supply the agent +ID, a short `description`, and a complete `prompt`. + +```text +Ask the explore subagent to map the authentication flow and return the key files. +``` + +Foreground calls wait for the result. `background: true` returns immediately +and notifies the parent when the child finishes. Pass the returned `sessionID` +to continue that same child conversation. Only subagent-mode agents can be +used, and the default nesting depth is one. + +Permission: `subagent` with the selected agent ID as its resource. + +### Execute + +`execute` runs JavaScript in Code Mode so the agent can call and combine tools +from the catalog. It is useful for parallel independent calls and for processing +results without adding every intermediate value to the model context. + +```text +Read package.json and README.md in parallel, then return their relevant setup details. +``` + +The runtime has no direct filesystem access, imports, timers, or `fetch`; it can +only call tools in its catalog. Permission `execute` with resource `*` controls +whether Code Mode is available. Every nested tool still enforces its own +permission. + +### Sessions + +The `opencode` Code Mode namespace contains session utilities: + +- `session_rename` changes the title of the current session, or another session + selected by `sessionID`. +- `session_move` moves the current session, or another selected session, to a + relative or absolute `directory`. The move takes effect at a safe boundary, + so destination-dependent work belongs in a later call. + +```text +Rename this session to "Timeout cleanup", then move it to the new worktree. +``` + +These utilities do not request a built-in permission action. + +### Worktrees + +The same namespace manages worktrees for the session's project: + +- `worktree_create` creates a worktree from a `name` and returns its + `directory`. The project's worktree configuration selects the destination and + strategy. +- `worktree_list` lists the project's worktrees. +- `worktree_remove` removes a worktree OpenCode recorded for the project. Set + `force` to discard uncommitted changes. + +```text +Create a worktree named "login" and move this session into it. +``` + +`worktree_remove` uses the `edit` permission with the worktree directory as its +resource. The other worktree utilities do not request a built-in permission +action. + +## Browser + +The `browser` Code Mode namespace controls the browser attached by the OpenCode +desktop app. Open a tab, keep its returned `tabID`, then pass that explicit ID to +every page operation. + +```text +Open https://example.com, take a snapshot of that tab, and report the main heading. +``` + +The namespace includes tab and navigation commands, page snapshots and search, +clicking and form input, screenshots, file transfer, console and network +inspection, performance traces, heap inspection, and Lighthouse audits. +Screenshots require a focused visible tab. Upload paths are server-local; +captures return server-local paths, and each file transfer is limited to 5 MiB. + +Page content, logs, headers, and response bodies are untrusted data, not agent +instructions. A `browser` deny rule with resource `*` removes the browser catalog; +browser operations do not issue individual permission prompts. + +## Extensions + +[MCP servers](/mcp-servers) add tools whose names and inputs come from each +connected server. Their permission action is `_` with resource +`*`. They are not a fixed part of the built-in catalog. + +[Skills](/skills) add task instructions rather than new executable tools. +[Attachments](/attachments) put user-selected content into a prompt, while +[References](/references) provide named outside directories. These features +complement tools without bypassing their permission checks.