summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/config/markdown.ts12
-rw-r--r--packages/opencode/src/session/prompt.ts12
-rw-r--r--packages/opencode/src/tool/registry.ts34
-rw-r--r--packages/opencode/test/config/config.test.ts353
-rw-r--r--packages/opencode/test/config/markdown.test.ts89
-rw-r--r--packages/opencode/test/fixture/fixture.ts10
-rw-r--r--packages/opencode/test/session/fileRegex.test.ts91
-rw-r--r--packages/opencode/test/tool/tool.test.ts70
8 files changed, 483 insertions, 188 deletions
diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts
new file mode 100644
index 000000000..e0e5afe42
--- /dev/null
+++ b/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))
+ }
+}
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index d385ea922..6ebae336b 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/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))
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index b97575ece..65c546408 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/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() {
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
new file mode 100644
index 000000000..cae895dca
--- /dev/null
+++ b/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)
+ },
+ })
+})
diff --git a/packages/opencode/test/config/markdown.test.ts b/packages/opencode/test/config/markdown.test.ts
new file mode 100644
index 000000000..392ca3911
--- /dev/null
+++ b/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)
+})
diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts
index 761b377c6..0b83bb316 100644
--- a/packages/opencode/test/fixture/fixture.ts
+++ b/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
}
diff --git a/packages/opencode/test/session/fileRegex.test.ts b/packages/opencode/test/session/fileRegex.test.ts
deleted file mode 100644
index a1f0875e0..000000000
--- a/packages/opencode/test/session/fileRegex.test.ts
+++ /dev/null
@@ -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)
- })
-})
diff --git a/packages/opencode/test/tool/tool.test.ts b/packages/opencode/test/tool/tool.test.ts
deleted file mode 100644
index 0560fa09a..000000000
--- a/packages/opencode/test/tool/tool.test.ts
+++ /dev/null
@@ -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()
- })
-})