From 735f3d17bc836e4b0905d1094794699c45a99804 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 12 Jan 2026 13:21:01 -0800 Subject: [PATCH] fix: ensure plurals are properly handled (#8070) --- packages/opencode/src/config/config.ts | 43 +++--- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/test/config/config.test.ts | 141 +++++++++++++++++++ packages/opencode/test/tool/registry.test.ts | 76 ++++++++++ 4 files changed, 237 insertions(+), 25 deletions(-) create mode 100644 packages/opencode/test/tool/registry.test.ts 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 = {} @@ -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") + }, + }) + }) +})