From 5478035250d35feacf8f5003798efef5433aaa02 Mon Sep 17 00:00:00 2001 From: dbpolito Date: Wed, 25 Feb 2026 23:19:35 -0300 Subject: [PATCH 1/2] feat(opencode): Nested custom tool files --- packages/opencode/src/tool/registry.ts | 33 +++++++++-- packages/opencode/test/tool/registry.test.ts | 58 ++++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index c6d7fbc1e4b..7272fa09740 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -37,14 +37,37 @@ export namespace ToolRegistry { export const state = Instance.state(async () => { const custom = [] as Tool.Info[] - const matches = await Config.directories().then((dirs) => - dirs.flatMap((dir) => - Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }), + const dirs = await Config.directories() + const matches = dirs.flatMap((dir) => + Glob.scanSync("{tool,tools}/**/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }).map( + (match) => ({ + dir, + match, + }), ), ) if (matches.length) await Config.waitForDependencies() - for (const match of matches) { - const namespace = path.basename(match, path.extname(match)) + for (const { dir, match } of matches) { + const relativePath = path.relative(dir, match) + const pathWithoutExt = relativePath.replace(/\.(js|ts)$/, "") + const pathParts = pathWithoutExt.split(path.sep) + + if (pathParts[0] === "tool" || pathParts[0] === "tools") { + pathParts.shift() + } + + const fileName = pathParts[pathParts.length - 1] + const folderParts = pathParts.slice(0, -1) + + let namespace: string + if (fileName === "index" && folderParts.length > 0) { + namespace = folderParts.join("_") + } else if (folderParts.length > 0) { + namespace = [...folderParts, fileName].join("_") + } else { + namespace = fileName + } + const mod = await import(pathToFileURL(match).href) for (const [id, def] of Object.entries(mod)) { custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 706a9e12caf..b78c032fffc 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -119,4 +119,62 @@ describe("tool.registry", () => { }, }) }) + + test("loads tools with complete folder structure", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + + const toolsDir = path.join(opencodeDir, "tools") + await fs.mkdir(toolsDir, { recursive: true }) + + const githubDir = path.join(toolsDir, "github") + await fs.mkdir(githubDir, { recursive: true }) + + await Bun.write( + path.join(githubDir, "index.ts"), + [ + "export const list = {", + " description: 'list github repos',", + " args: {},", + " execute: async () => 'list',", + "}", + "export default {", + " description: 'github main',", + " args: {},", + " execute: async () => 'main',", + "}", + ].join("\n"), + ) + + await Bun.write( + path.join(githubDir, "pr.ts"), + [ + "export const create = {", + " description: 'create pr',", + " args: {},", + " execute: async () => 'create',", + "}", + "export default {", + " description: 'github pr',", + " args: {},", + " execute: async () => 'pr',", + "}", + ].join("\n"), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ids = await ToolRegistry.ids() + expect(ids).toContain("github") + expect(ids).toContain("github_list") + expect(ids).toContain("github_pr") + expect(ids).toContain("github_pr_create") + }, + }) + }) }) From c004354a9b0d033d434d08ce846119988469ed1a Mon Sep 17 00:00:00 2001 From: dbpolito Date: Wed, 25 Feb 2026 23:23:01 -0300 Subject: [PATCH 2/2] Adding Docs --- .../web/src/content/docs/custom-tools.mdx | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/packages/web/src/content/docs/custom-tools.mdx b/packages/web/src/content/docs/custom-tools.mdx index 4586343f0a5..e67ef19ed7f 100644 --- a/packages/web/src/content/docs/custom-tools.mdx +++ b/packages/web/src/content/docs/custom-tools.mdx @@ -20,6 +20,84 @@ They can be defined: - Locally by placing them in the `.opencode/tools/` directory of your project. - Or globally, by placing them in `~/.config/opencode/tools/`. +Tools can be organized in subdirectories for better structure. + +--- + +### Folder structure + +Tools can be nested in folders. The folder name becomes part of the tool name. + +``` +.opencode/tools/ +├── database.ts # Tool name: database +├── math.ts # Tool name: math +└── github/ + ├── index.ts # Tool name: github + ├── pr.ts # Tool name: github_pr + └── issue.ts # Tool name: github_issue +``` + +- Files at the root use the filename as the tool name +- Files in subdirectories use `folder_filename` +- `index.ts` in a folder uses the folder name + +--- + +#### Multiple tools per file in subdirectories + +When exporting multiple tools from files in subdirectories, the folder and file name are both prefixed: + +```ts title=".opencode/tools/github/index.ts" +import { tool } from "@opencode-ai/plugin" + +export const list = tool({ + description: "List repositories", + args: {}, + async execute() { + return "repos" + }, +}) + +export default tool({ + description: "GitHub main tool", + args: {}, + async execute() { + return "github" + }, +}) +``` + +This creates: + +- `github` (from default export) +- `github_list` (from list export) + +```ts title=".opencode/tools/github/pr.ts" +import { tool } from "@opencode-ai/plugin" + +export const create = tool({ + description: "Create a PR", + args: {}, + async execute() { + return "created" + }, +}) + +export default tool({ + description: "GitHub PR tool", + args: {}, + async execute() { + return "pr" + }, +}) +``` + +This creates: + +- `github_pr` (from default export) +- `github_pr_create` (from create export) + --- ### Structure