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" import { pathToFileURL } from "url" 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() }, }) }) 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( expect.objectContaining({ 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", options: {}, permission: {}, }) }, }) }) 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( expect.objectContaining({ 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) }, }) }) test("resolves scoped npm plugins in config", async () => { await using tmp = await tmpdir({ init: async (dir) => { const pluginDir = path.join(dir, "node_modules", "@scope", "plugin") await fs.mkdir(pluginDir, { recursive: true }) await Bun.write( path.join(dir, "package.json"), JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2), ) await Bun.write( path.join(pluginDir, "package.json"), JSON.stringify( { name: "@scope/plugin", version: "1.0.0", type: "module", main: "./index.js", }, null, 2, ), ) await Bun.write(path.join(pluginDir, "index.js"), "export default {}\n") await Bun.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, null, 2), ) }, }) await Instance.provide({ directory: tmp.path, fn: async () => { const config = await Config.get() const pluginEntries = config.plugin ?? [] const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href const expected = import.meta.resolve("@scope/plugin", baseUrl) expect(pluginEntries.includes(expected)).toBe(true) const scopedEntry = pluginEntries.find((entry) => entry === expected) expect(scopedEntry).toBeDefined() expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true) }, }) }) test("merges plugin arrays from global and local configs", async () => { await using tmp = await tmpdir({ init: async (dir) => { // Create a nested project structure with local .opencode config const projectDir = path.join(dir, "project") const opencodeDir = path.join(projectDir, ".opencode") await fs.mkdir(opencodeDir, { recursive: true }) // Global config with plugins await Bun.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["global-plugin-1", "global-plugin-2"], }), ) // Local .opencode config with different plugins await Bun.write( path.join(opencodeDir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["local-plugin-1"], }), ) }, }) await Instance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await Config.get() const plugins = config.plugin ?? [] // Should contain both global and local plugins expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true) expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true) expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true) // Should have all 3 plugins (not replaced, but merged) const pluginNames = plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin")) expect(pluginNames.length).toBeGreaterThanOrEqual(3) }, }) }) test("does not error when only custom agent is a subagent", 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, "helper.md"), `--- model: test/model mode: subagent --- Helper subagent 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 subagent prompt", }) }, }) }) test("merges instructions arrays from global and local configs", async () => { await using tmp = await tmpdir({ init: async (dir) => { const projectDir = path.join(dir, "project") const opencodeDir = path.join(projectDir, ".opencode") await fs.mkdir(opencodeDir, { recursive: true }) await Bun.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", instructions: ["global-instructions.md", "shared-rules.md"], }), ) await Bun.write( path.join(opencodeDir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", instructions: ["local-instructions.md"], }), ) }, }) await Instance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await Config.get() const instructions = config.instructions ?? [] expect(instructions).toContain("global-instructions.md") expect(instructions).toContain("shared-rules.md") expect(instructions).toContain("local-instructions.md") expect(instructions.length).toBe(3) }, }) }) test("deduplicates duplicate instructions from global and local configs", async () => { await using tmp = await tmpdir({ init: async (dir) => { const projectDir = path.join(dir, "project") const opencodeDir = path.join(projectDir, ".opencode") await fs.mkdir(opencodeDir, { recursive: true }) await Bun.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", instructions: ["duplicate.md", "global-only.md"], }), ) await Bun.write( path.join(opencodeDir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", instructions: ["duplicate.md", "local-only.md"], }), ) }, }) await Instance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await Config.get() const instructions = config.instructions ?? [] expect(instructions).toContain("global-only.md") expect(instructions).toContain("local-only.md") expect(instructions).toContain("duplicate.md") const duplicates = instructions.filter((i) => i === "duplicate.md") expect(duplicates.length).toBe(1) expect(instructions.length).toBe(3) }, }) }) test("deduplicates duplicate plugins from global and local configs", async () => { await using tmp = await tmpdir({ init: async (dir) => { // Create a nested project structure with local .opencode config const projectDir = path.join(dir, "project") const opencodeDir = path.join(projectDir, ".opencode") await fs.mkdir(opencodeDir, { recursive: true }) // Global config with plugins await Bun.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["duplicate-plugin", "global-plugin-1"], }), ) // Local .opencode config with some overlapping plugins await Bun.write( path.join(opencodeDir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["duplicate-plugin", "local-plugin-1"], }), ) }, }) await Instance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await Config.get() const plugins = config.plugin ?? [] // Should contain all unique plugins expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true) expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true) expect(plugins.some((p) => p.includes("duplicate-plugin"))).toBe(true) // Should deduplicate the duplicate plugin const duplicatePlugins = plugins.filter((p) => p.includes("duplicate-plugin")) expect(duplicatePlugins.length).toBe(1) // Should have exactly 3 unique plugins const pluginNames = plugins.filter( (p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"), ) expect(pluginNames.length).toBe(3) }, }) }) // Legacy tools migration tests test("migrates legacy tools config to permissions - allow", 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: { tools: { bash: true, read: true, }, }, }, }), ) }, }) await Instance.provide({ directory: tmp.path, fn: async () => { const config = await Config.get() expect(config.agent?.["test"]?.permission).toEqual({ bash: "allow", read: "allow", }) }, }) }) test("migrates legacy tools config to permissions - deny", 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: { tools: { bash: false, webfetch: false, }, }, }, }), ) }, }) await Instance.provide({ directory: tmp.path, fn: async () => { const config = await Config.get() expect(config.agent?.["test"]?.permission).toEqual({ bash: "deny", webfetch: "deny", }) }, }) }) test("migrates legacy write tool to edit permission", 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: { tools: { write: true, }, }, }, }), ) }, }) await Instance.provide({ directory: tmp.path, fn: async () => { const config = await Config.get() expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow", }) }, }) }) test("migrates legacy edit tool to edit permission", 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: { tools: { edit: false, }, }, }, }), ) }, }) await Instance.provide({ directory: tmp.path, fn: async () => { const config = await Config.get() expect(config.agent?.["test"]?.permission).toEqual({ edit: "deny", }) }, }) }) test("migrates legacy patch tool to edit permission", 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: { tools: { patch: true, }, }, }, }), ) }, }) await Instance.provide({ directory: tmp.path, fn: async () => { const config = await Config.get() expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow", }) }, }) }) test("migrates legacy multiedit tool to edit permission", 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: { tools: { multiedit: false, }, }, }, }), ) }, }) await Instance.provide({ directory: tmp.path, fn: async () => { const config = await Config.get() expect(config.agent?.["test"]?.permission).toEqual({ edit: "deny", }) }, }) }) test("migrates mixed legacy tools config", 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: { tools: { bash: true, write: true, read: false, webfetch: true, }, }, }, }), ) }, }) await Instance.provide({ directory: tmp.path, fn: async () => { const config = await Config.get() expect(config.agent?.["test"]?.permission).toEqual({ bash: "allow", edit: "allow", read: "deny", webfetch: "allow", }) }, }) }) test("merges legacy tools with existing permission config", 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: { permission: { glob: "allow", }, tools: { bash: true, }, }, }, }), ) }, }) await Instance.provide({ directory: tmp.path, fn: async () => { const config = await Config.get() expect(config.agent?.["test"]?.permission).toEqual({ glob: "allow", bash: "allow", }) }, }) })