Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToolDefinition>(mod)) {
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
Expand Down
58 changes: 58 additions & 0 deletions packages/opencode/test/tool/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
},
})
})
})
78 changes: 78 additions & 0 deletions packages/web/src/content/docs/custom-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading