summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAiden Cline <[email protected]>2026-01-12 13:21:01 -0800
committerGitHub <[email protected]>2026-01-12 15:21:01 -0600
commit735f3d17bc836e4b0905d1094794699c45a99804 (patch)
tree0d4acf0e01666493d64523371ca9f5158f76605e
parentdb7243c3647f96c80e18f07290926046bef21d8d (diff)
downloadopencode-735f3d17bc836e4b0905d1094794699c45a99804.tar.gz
opencode-735f3d17bc836e4b0905d1094794699c45a99804.zip
fix: ensure plurals are properly handled (#8070)
-rw-r--r--packages/opencode/src/config/config.ts43
-rw-r--r--packages/opencode/src/tool/registry.ts2
-rw-r--r--packages/opencode/test/config/config.test.ts141
-rw-r--r--packages/opencode/test/tool/registry.test.ts76
4 files changed, 237 insertions, 25 deletions
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index ead3a0149..127406d1d 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -209,6 +209,19 @@ export namespace Config {
await BunProc.run(["install"], { cwd: dir }).catch(() => {})
}
+ function rel(item: string, patterns: string[]) {
+ for (const pattern of patterns) {
+ const index = item.indexOf(pattern)
+ if (index === -1) continue
+ return item.slice(index + pattern.length)
+ }
+ }
+
+ function trim(file: string) {
+ const ext = path.extname(file)
+ return ext.length ? file.slice(0, -ext.length) : file
+ }
+
const COMMAND_GLOB = new Bun.Glob("{command,commands}/**/*.md")
async function loadCommand(dir: string) {
const result: Record<string, Command> = {}
@@ -221,16 +234,9 @@ export namespace Config {
const md = await ConfigMarkdown.parse(item)
if (!md.data) continue
- const name = (() => {
- const patterns = ["/.opencode/command/", "/command/"]
- const pattern = patterns.find((p) => item.includes(p))
-
- if (pattern) {
- const index = item.indexOf(pattern)
- return item.slice(index + pattern.length, -3)
- }
- return path.basename(item, ".md")
- })()
+ const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
+ const file = rel(item, patterns) ?? path.basename(item)
+ const name = trim(file)
const config = {
name,
@@ -260,20 +266,9 @@ export namespace Config {
const md = await ConfigMarkdown.parse(item)
if (!md.data) continue
- // Extract relative path from agent folder for nested agents
- let agentName = path.basename(item, ".md")
- const agentFolderPath = item.includes("/.opencode/agent/")
- ? item.split("/.opencode/agent/")[1]
- : item.includes("/agent/")
- ? item.split("/agent/")[1]
- : agentName + ".md"
-
- // If agent is in a subfolder, include folder path in name
- if (agentFolderPath.includes("/")) {
- const relativePath = agentFolderPath.replace(".md", "")
- const pathParts = relativePath.split("/")
- agentName = pathParts.slice(0, -1).join("/") + "/" + pathParts[pathParts.length - 1]
- }
+ const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"]
+ const file = rel(item, patterns) ?? path.basename(item)
+ const agentName = trim(file)
const config = {
name: agentName,
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index eb76681de..82bf7f563 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -31,7 +31,7 @@ export namespace ToolRegistry {
export const state = Instance.state(async () => {
const custom = [] as Tool.Info[]
- const glob = new Bun.Glob("tool/*.{js,ts}")
+ const glob = new Bun.Glob("{tool,tools}/*.{js,ts}")
for (const dir of await Config.directories()) {
for await (const match of glob.scan({
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 087eb0c62..86cadca5d 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -334,6 +334,147 @@ Test agent prompt`,
})
})
+test("loads agents from .opencode/agents (plural)", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const opencodeDir = path.join(dir, ".opencode")
+ await fs.mkdir(opencodeDir, { recursive: true })
+
+ const agentsDir = path.join(opencodeDir, "agents")
+ await fs.mkdir(path.join(agentsDir, "nested"), { recursive: true })
+
+ await Bun.write(
+ path.join(agentsDir, "helper.md"),
+ `---
+model: test/model
+mode: subagent
+---
+Helper agent prompt`,
+ )
+
+ await Bun.write(
+ path.join(agentsDir, "nested", "child.md"),
+ `---
+model: test/model
+mode: subagent
+---
+Nested agent prompt`,
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await Config.get()
+
+ expect(config.agent?.["helper"]).toMatchObject({
+ name: "helper",
+ model: "test/model",
+ mode: "subagent",
+ prompt: "Helper agent prompt",
+ })
+
+ expect(config.agent?.["nested/child"]).toMatchObject({
+ name: "nested/child",
+ model: "test/model",
+ mode: "subagent",
+ prompt: "Nested agent prompt",
+ })
+ },
+ })
+})
+
+test("loads commands from .opencode/command (singular)", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const opencodeDir = path.join(dir, ".opencode")
+ await fs.mkdir(opencodeDir, { recursive: true })
+
+ const commandDir = path.join(opencodeDir, "command")
+ await fs.mkdir(path.join(commandDir, "nested"), { recursive: true })
+
+ await Bun.write(
+ path.join(commandDir, "hello.md"),
+ `---
+description: Test command
+---
+Hello from singular command`,
+ )
+
+ await Bun.write(
+ path.join(commandDir, "nested", "child.md"),
+ `---
+description: Nested command
+---
+Nested command template`,
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await Config.get()
+
+ expect(config.command?.["hello"]).toEqual({
+ description: "Test command",
+ template: "Hello from singular command",
+ })
+
+ expect(config.command?.["nested/child"]).toEqual({
+ description: "Nested command",
+ template: "Nested command template",
+ })
+ },
+ })
+})
+
+test("loads commands from .opencode/commands (plural)", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const opencodeDir = path.join(dir, ".opencode")
+ await fs.mkdir(opencodeDir, { recursive: true })
+
+ const commandsDir = path.join(opencodeDir, "commands")
+ await fs.mkdir(path.join(commandsDir, "nested"), { recursive: true })
+
+ await Bun.write(
+ path.join(commandsDir, "hello.md"),
+ `---
+description: Test command
+---
+Hello from plural commands`,
+ )
+
+ await Bun.write(
+ path.join(commandsDir, "nested", "child.md"),
+ `---
+description: Nested command
+---
+Nested command template`,
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await Config.get()
+
+ expect(config.command?.["hello"]).toEqual({
+ description: "Test command",
+ template: "Hello from plural commands",
+ })
+
+ expect(config.command?.["nested/child"]).toEqual({
+ description: "Nested command",
+ template: "Nested command template",
+ })
+ },
+ })
+})
+
test("updates config and writes to file", async () => {
await using tmp = await tmpdir()
await Instance.provide({
diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts
new file mode 100644
index 000000000..aea8b7088
--- /dev/null
+++ b/packages/opencode/test/tool/registry.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
+import { tmpdir } from "../fixture/fixture"
+import { Instance } from "../../src/project/instance"
+import { ToolRegistry } from "../../src/tool/registry"
+
+describe("tool.registry", () => {
+ test("loads tools from .opencode/tool (singular)", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const opencodeDir = path.join(dir, ".opencode")
+ await fs.mkdir(opencodeDir, { recursive: true })
+
+ const toolDir = path.join(opencodeDir, "tool")
+ await fs.mkdir(toolDir, { recursive: true })
+
+ await Bun.write(
+ path.join(toolDir, "hello.ts"),
+ [
+ "export default {",
+ " description: 'hello tool',",
+ " args: {},",
+ " execute: async () => {",
+ " return 'hello world'",
+ " },",
+ "}",
+ "",
+ ].join("\n"),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const ids = await ToolRegistry.ids()
+ expect(ids).toContain("hello")
+ },
+ })
+ })
+
+ test("loads tools from .opencode/tools (plural)", 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 })
+
+ await Bun.write(
+ path.join(toolsDir, "hello.ts"),
+ [
+ "export default {",
+ " description: 'hello tool',",
+ " args: {},",
+ " execute: async () => {",
+ " return 'hello world'",
+ " },",
+ "}",
+ "",
+ ].join("\n"),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const ids = await ToolRegistry.ids()
+ expect(ids).toContain("hello")
+ },
+ })
+ })
+})