Răsfoiți Sursa

fix: ensure plurals are properly handled (#8070)

Aiden Cline 1 lună în urmă
părinte
comite
735f3d17bc

+ 19 - 24
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,

+ 1 - 1
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({

+ 141 - 0
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({

+ 76 - 0
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")
+      },
+    })
+  })
+})