Dax Raad 4 месяцев назад
Родитель
Сommit
d0043a4a78

+ 12 - 0
packages/opencode/src/config/markdown.ts

@@ -0,0 +1,12 @@
+export namespace ConfigMarkdown {
+  export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
+  export const SHELL_REGEX = /`[^`]+`/g
+
+  export function files(template: string) {
+    return Array.from(template.matchAll(FILE_REGEX))
+  }
+
+  export function shell(template: string) {
+    return Array.from(template.matchAll(SHELL_REGEX))
+  }
+}

+ 6 - 6
packages/opencode/src/session/prompt.ts

@@ -48,6 +48,7 @@ import { ulid } from "ulid"
 import { spawn } from "child_process"
 import { Command } from "../command"
 import { $ } from "bun"
+import { ConfigMarkdown } from "../config/markdown"
 
 export namespace SessionPrompt {
   const log = Log.create({ service: "session.prompt" })
@@ -1364,7 +1365,6 @@ export namespace SessionPrompt {
    * Matches @ followed by file paths, excluding commas, periods at end of sentences, and backticks
    * Does not match when preceded by word characters or backticks (to avoid email addresses and quoted references)
    */
-  export const fileRegex = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
 
   export async function command(input: CommandInput) {
     log.info("command", input)
@@ -1373,10 +1373,10 @@ export namespace SessionPrompt {
 
     let template = command.template.replace("$ARGUMENTS", input.arguments)
 
-    const bash = Array.from(template.matchAll(bashRegex))
-    if (bash.length > 0) {
+    const shell = ConfigMarkdown.shell(template)
+    if (shell.length > 0) {
       const results = await Promise.all(
-        bash.map(async ([, cmd]) => {
+        shell.map(async ([, cmd]) => {
           try {
             return await $`${{ raw: cmd }}`.nothrow().text()
           } catch (error) {
@@ -1395,9 +1395,9 @@ export namespace SessionPrompt {
       },
     ] as PromptInput["parts"]
 
-    const matches = Array.from(template.matchAll(fileRegex))
+    const files = ConfigMarkdown.files(template)
     await Promise.all(
-      matches.map(async (match) => {
+      files.map(async (match) => {
         const name = match[1]
         const filepath = name.startsWith("~/")
           ? path.join(os.homedir(), name.slice(2))

+ 18 - 16
packages/opencode/src/tool/registry.ts

@@ -21,21 +21,23 @@ import { Plugin } from "../plugin"
 
 export namespace ToolRegistry {
   // Built-in tools that ship with opencode
-  const BUILTIN = [
-    InvalidTool,
-    BashTool,
-    EditTool,
-    WebFetchTool,
-    GlobTool,
-    GrepTool,
-    ListTool,
-    PatchTool,
-    ReadTool,
-    WriteTool,
-    TodoWriteTool,
-    TodoReadTool,
-    TaskTool,
-  ]
+  function builtin() {
+    return [
+      InvalidTool,
+      BashTool,
+      EditTool,
+      WebFetchTool,
+      GlobTool,
+      GrepTool,
+      ListTool,
+      PatchTool,
+      ReadTool,
+      WriteTool,
+      TodoWriteTool,
+      TodoReadTool,
+      TaskTool,
+    ]
+  }
 
   export const state = Instance.state(async () => {
     const custom = [] as Tool.Info[]
@@ -91,7 +93,7 @@ export namespace ToolRegistry {
 
   async function all(): Promise<Tool.Info[]> {
     const custom = await state().then((x) => x.custom)
-    return [...BUILTIN, ...custom]
+    return [...builtin(), ...custom]
   }
 
   export async function ids() {

+ 353 - 0
packages/opencode/test/config/config.test.ts

@@ -0,0 +1,353 @@
+import { test, expect } from "bun:test"
+import { Config } from "../../src/config/config"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+import path from "path"
+import fs from "fs/promises"
+
+test("loads config with defaults when no files exist", async () => {
+  await using tmp = await tmpdir()
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      expect(config.username).toBeDefined()
+      expect(config.model).toBeDefined()
+    },
+  })
+})
+
+test("loads JSON config file", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          model: "test/model",
+          username: "testuser",
+        }),
+      )
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      expect(config.model).toBe("test/model")
+      expect(config.username).toBe("testuser")
+    },
+  })
+})
+
+test("loads JSONC config file", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "opencode.jsonc"),
+        `{
+        // This is a comment
+        "$schema": "https://opencode.ai/config.json",
+        "model": "test/model",
+        "username": "testuser"
+      }`,
+      )
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      expect(config.model).toBe("test/model")
+      expect(config.username).toBe("testuser")
+    },
+  })
+})
+
+test("merges multiple config files with correct precedence", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "opencode.jsonc"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          model: "base",
+          username: "base",
+        }),
+      )
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          model: "override",
+        }),
+      )
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      expect(config.model).toBe("override")
+      expect(config.username).toBe("base")
+    },
+  })
+})
+
+test("handles environment variable substitution", async () => {
+  const originalEnv = process.env["TEST_VAR"]
+  process.env["TEST_VAR"] = "test_theme"
+
+  try {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({
+            $schema: "https://opencode.ai/config.json",
+            theme: "{env:TEST_VAR}",
+          }),
+        )
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const config = await Config.get()
+        expect(config.theme).toBe("test_theme")
+      },
+    })
+  } finally {
+    if (originalEnv !== undefined) {
+      process.env["TEST_VAR"] = originalEnv
+    } else {
+      delete process.env["TEST_VAR"]
+    }
+  }
+})
+
+test("handles file inclusion substitution", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(path.join(dir, "included.txt"), "test_theme")
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          theme: "{file:included.txt}",
+        }),
+      )
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      expect(config.theme).toBe("test_theme")
+    },
+  })
+})
+
+test("validates config schema and throws on invalid fields", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          invalid_field: "should cause error",
+        }),
+      )
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      // Strict schema should throw an error for invalid fields
+      await expect(Config.get()).rejects.toThrow()
+    },
+  })
+})
+
+test("throws error for invalid JSON", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(path.join(dir, "opencode.json"), "{ invalid json }")
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      await expect(Config.get()).rejects.toThrow()
+    },
+  })
+})
+
+test("handles agent configuration", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          agent: {
+            test_agent: {
+              model: "test/model",
+              temperature: 0.7,
+              description: "test agent",
+            },
+          },
+        }),
+      )
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      expect(config.agent?.["test_agent"]).toEqual({
+        model: "test/model",
+        temperature: 0.7,
+        description: "test agent",
+      })
+    },
+  })
+})
+
+test("handles command configuration", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          command: {
+            test_command: {
+              template: "test template",
+              description: "test command",
+              agent: "test_agent",
+            },
+          },
+        }),
+      )
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      expect(config.command?.["test_command"]).toEqual({
+        template: "test template",
+        description: "test command",
+        agent: "test_agent",
+      })
+    },
+  })
+})
+
+test("migrates autoshare to share field", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          autoshare: true,
+        }),
+      )
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      expect(config.share).toBe("auto")
+      expect(config.autoshare).toBe(true)
+    },
+  })
+})
+
+test("migrates mode field to agent field", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          mode: {
+            test_mode: {
+              model: "test/model",
+              temperature: 0.5,
+            },
+          },
+        }),
+      )
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      expect(config.agent?.["test_mode"]).toEqual({
+        model: "test/model",
+        temperature: 0.5,
+        mode: "primary",
+      })
+    },
+  })
+})
+
+test("loads config from .opencode directory", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const opencodeDir = path.join(dir, ".opencode")
+      await fs.mkdir(opencodeDir, { recursive: true })
+      const agentDir = path.join(opencodeDir, "agent")
+      await fs.mkdir(agentDir, { recursive: true })
+
+      await Bun.write(
+        path.join(agentDir, "test.md"),
+        `---
+model: test/model
+---
+Test agent prompt`,
+      )
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      expect(config.agent?.["test"]).toEqual({
+        name: "test",
+        model: "test/model",
+        prompt: "Test agent prompt",
+      })
+    },
+  })
+})
+
+test("updates config and writes to file", async () => {
+  await using tmp = await tmpdir()
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const newConfig = { model: "updated/model" }
+      await Config.update(newConfig as any)
+
+      const writtenConfig = JSON.parse(await Bun.file(path.join(tmp.path, "config.json")).text())
+      expect(writtenConfig.model).toBe("updated/model")
+    },
+  })
+})
+
+test("gets config directories", async () => {
+  await using tmp = await tmpdir()
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const dirs = await Config.directories()
+      expect(dirs.length).toBeGreaterThanOrEqual(1)
+    },
+  })
+})

+ 89 - 0
packages/opencode/test/config/markdown.test.ts

@@ -0,0 +1,89 @@
+import { expect, test } from "bun:test"
+import { ConfigMarkdown } from "../../src/config/markdown"
+
+const template = `This is a @valid/path/to/a/file and it should also match at
+the beginning of a line:
+
+@another-valid/path/to/a/file
+
+but this is not:
+
+   - Adds a "Co-authored-by:" footer which clarifies which AI agent
+     helped create this commit, using an appropriate \`noreply@...\`
+     or \`[email protected]\` email address.
+
+We also need to deal with files followed by @commas, ones
+with @file-extensions.md, even @multiple.extensions.bak,
+hidden directorys like @.config/ or files like @.bashrc
+and ones at the end of a sentence like @foo.md.
+
+Also shouldn't forget @/absolute/paths.txt with and @/without/extensions,
+as well as @~/home-files and @~/paths/under/home.txt.
+
+If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.`
+
+const matches = ConfigMarkdown.files(template)
+
+test("should extract exactly 12 file references", () => {
+  expect(matches.length).toBe(12)
+})
+
+test("should extract valid/path/to/a/file", () => {
+  expect(matches[0][1]).toBe("valid/path/to/a/file")
+})
+
+test("should extract another-valid/path/to/a/file", () => {
+  expect(matches[1][1]).toBe("another-valid/path/to/a/file")
+})
+
+test("should extract paths ignoring comma after", () => {
+  expect(matches[2][1]).toBe("commas")
+})
+
+test("should extract a path with a file extension and comma after", () => {
+  expect(matches[3][1]).toBe("file-extensions.md")
+})
+
+test("should extract a path with multiple dots and comma after", () => {
+  expect(matches[4][1]).toBe("multiple.extensions.bak")
+})
+
+test("should extract hidden directory", () => {
+  expect(matches[5][1]).toBe(".config/")
+})
+
+test("should extract hidden file", () => {
+  expect(matches[6][1]).toBe(".bashrc")
+})
+
+test("should extract a file ignoring period at end of sentence", () => {
+  expect(matches[7][1]).toBe("foo.md")
+})
+
+test("should extract an absolute path with an extension", () => {
+  expect(matches[8][1]).toBe("/absolute/paths.txt")
+})
+
+test("should extract an absolute path without an extension", () => {
+  expect(matches[9][1]).toBe("/without/extensions")
+})
+
+test("should extract an absolute path in home directory", () => {
+  expect(matches[10][1]).toBe("~/home-files")
+})
+
+test("should extract an absolute path under home directory", () => {
+  expect(matches[11][1]).toBe("~/paths/under/home.txt")
+})
+
+test("should not match when preceded by backtick", () => {
+  const backtickTest = "This `@should/not/match` should be ignored"
+  const backtickMatches = ConfigMarkdown.files(backtickTest)
+  expect(backtickMatches.length).toBe(0)
+})
+
+test("should not match email addresses", () => {
+  const emailTest = "Contact [email protected] for help"
+  const emailMatches = ConfigMarkdown.files(emailTest)
+  expect(emailMatches.length).toBe(0)
+})

+ 5 - 5
packages/opencode/test/fixture/fixture.ts

@@ -2,12 +2,12 @@ import { $ } from "bun"
 import os from "os"
 import path from "path"
 
-type TmpDirOptions<Init extends Record<string, any>> = {
+type TmpDirOptions<T> = {
   git?: boolean
-  init?: (dir: string) => Promise<Init>
-  dispose?: (dir: string) => Promise<void>
+  init?: (dir: string) => Promise<T>
+  dispose?: (dir: string) => Promise<T>
 }
-export async function tmpdir<Init extends Record<string, any>>(options?: TmpDirOptions<Init>) {
+export async function tmpdir<T>(options?: TmpDirOptions<T>) {
   const dirpath = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2))
   await $`mkdir -p ${dirpath}`.quiet()
   if (options?.git) await $`git init`.cwd(dirpath).quiet()
@@ -18,7 +18,7 @@ export async function tmpdir<Init extends Record<string, any>>(options?: TmpDirO
       await $`rm -rf ${dirpath}`.quiet()
     },
     path: dirpath,
-    extra: extra as Init,
+    extra: extra as T,
   }
   return result
 }

+ 0 - 91
packages/opencode/test/session/fileRegex.test.ts

@@ -1,91 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { SessionPrompt } from "../../src/session/prompt"
-
-describe("fileRegex", () => {
-  const template = `This is a @valid/path/to/a/file and it should also match at
-the beginning of a line:
-
-@another-valid/path/to/a/file
-
-but this is not:
-
-   - Adds a "Co-authored-by:" footer which clarifies which AI agent
-     helped create this commit, using an appropriate \`noreply@...\`
-     or \`[email protected]\` email address.
-
-We also need to deal with files followed by @commas, ones
-with @file-extensions.md, even @multiple.extensions.bak,
-hidden directorys like @.config/ or files like @.bashrc
-and ones at the end of a sentence like @foo.md.
-
-Also shouldn't forget @/absolute/paths.txt with and @/without/extensions,
-as well as @~/home-files and @~/paths/under/home.txt.
-
-If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.`
-
-  const matches = Array.from(template.matchAll(SessionPrompt.fileRegex))
-
-  test("should extract exactly 12 file references", () => {
-    expect(matches.length).toBe(12)
-  })
-
-  test("should extract valid/path/to/a/file", () => {
-    expect(matches[0][1]).toBe("valid/path/to/a/file")
-  })
-
-  test("should extract another-valid/path/to/a/file", () => {
-    expect(matches[1][1]).toBe("another-valid/path/to/a/file")
-  })
-
-  test("should extract paths ignoring comma after", () => {
-    expect(matches[2][1]).toBe("commas")
-  })
-
-  test("should extract a path with a file extension and comma after", () => {
-    expect(matches[3][1]).toBe("file-extensions.md")
-  })
-
-  test("should extract a path with multiple dots and comma after", () => {
-    expect(matches[4][1]).toBe("multiple.extensions.bak")
-  })
-
-  test("should extract hidden directory", () => {
-    expect(matches[5][1]).toBe(".config/")
-  })
-
-  test("should extract hidden file", () => {
-    expect(matches[6][1]).toBe(".bashrc")
-  })
-
-  test("should extract a file ignoring period at end of sentence", () => {
-    expect(matches[7][1]).toBe("foo.md")
-  })
-
-  test("should extract an absolute path with an extension", () => {
-    expect(matches[8][1]).toBe("/absolute/paths.txt")
-  })
-
-  test("should extract an absolute path without an extension", () => {
-    expect(matches[9][1]).toBe("/without/extensions")
-  })
-
-  test("should extract an absolute path in home directory", () => {
-    expect(matches[10][1]).toBe("~/home-files")
-  })
-
-  test("should extract an absolute path under home directory", () => {
-    expect(matches[11][1]).toBe("~/paths/under/home.txt")
-  })
-
-  test("should not match when preceded by backtick", () => {
-    const backtickTest = "This `@should/not/match` should be ignored"
-    const backtickMatches = Array.from(backtickTest.matchAll(SessionPrompt.fileRegex))
-    expect(backtickMatches.length).toBe(0)
-  })
-
-  test("should not match email addresses", () => {
-    const emailTest = "Contact [email protected] for help"
-    const emailMatches = Array.from(emailTest.matchAll(SessionPrompt.fileRegex))
-    expect(emailMatches.length).toBe(0)
-  })
-})

+ 0 - 70
packages/opencode/test/tool/tool.test.ts

@@ -1,70 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { GlobTool } from "../../src/tool/glob"
-import { ListTool } from "../../src/tool/ls"
-import path from "path"
-import { Instance } from "../../src/project/instance"
-
-const ctx = {
-  sessionID: "test",
-  messageID: "",
-  toolCallID: "",
-  agent: "build",
-  abort: AbortSignal.any([]),
-  metadata: () => {},
-}
-const glob = await GlobTool.init()
-const list = await ListTool.init()
-
-const projectRoot = path.join(__dirname, "../..")
-const fixturePath = path.join(__dirname, "../fixtures/example")
-
-describe("tool.glob", () => {
-  test("truncate", async () => {
-    await Instance.provide({
-      directory: projectRoot,
-      fn: async () => {
-        let result = await glob.execute(
-          {
-            pattern: "**/*",
-            path: "../../node_modules",
-          },
-          ctx,
-        )
-        expect(result.metadata.truncated).toBe(true)
-      },
-    })
-  })
-  test("basic", async () => {
-    await Instance.provide({
-      directory: projectRoot,
-      fn: async () => {
-        let result = await glob.execute(
-          {
-            pattern: "*.json",
-            path: undefined,
-          },
-          ctx,
-        )
-        expect(result.metadata).toMatchObject({
-          truncated: false,
-          count: 2,
-        })
-      },
-    })
-  })
-})
-
-describe("tool.ls", () => {
-  test("basic", async () => {
-    const result = await Instance.provide({
-      directory: projectRoot,
-      fn: async () => {
-        return await list.execute({ path: fixturePath, ignore: [".git"] }, ctx)
-      },
-    })
-
-    // Normalize absolute path to relative for consistent snapshots
-    const normalizedOutput = result.output.replace(fixturePath, "packages/opencode/test/fixtures/example")
-    expect(normalizedOutput).toMatchSnapshot()
-  })
-})