Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/opencode/src/global/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ const cache = path.join(xdgCache!, app)
const config = path.join(xdgConfig!, app)
const state = path.join(xdgState!, app)

async function isDirectory(p: string): Promise<boolean> {
const stat = await Bun.file(p)
.stat()
.catch(() => undefined)
return stat?.isDirectory() ?? false
}

export namespace Global {
export const Path = {
// Allow override via OPENCODE_TEST_HOME for test isolation
Expand All @@ -23,6 +30,20 @@ export namespace Global {
config,
state,
}

export async function claudeConfigDir(): Promise<string | undefined> {
const envDir = process.env.CLAUDE_CONFIG_DIR
if (envDir && (await isDirectory(envDir))) return envDir

const xdgPath = process.env.XDG_CONFIG_HOME || path.join(Path.home, ".config")
const xdgClaude = path.join(xdgPath, "claude")
if (await isDirectory(xdgClaude)) return xdgClaude

const legacy = path.join(Path.home, ".claude")
if (await isDirectory(legacy)) return legacy

return undefined
}
}

await Promise.all([
Expand Down
18 changes: 11 additions & 7 deletions packages/opencode/src/session/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { Config } from "../config/config"

import { Instance } from "../project/instance"
import path from "path"
import os from "os"

import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
import PROMPT_ANTHROPIC_WITHOUT_TODO from "./prompt/qwen.txt"
Expand Down Expand Up @@ -61,10 +60,15 @@ export namespace SystemPrompt {
"CLAUDE.md",
"CONTEXT.md", // deprecated
]
const GLOBAL_RULE_FILES = [
path.join(Global.Path.config, "AGENTS.md"),
path.join(os.homedir(), ".claude", "CLAUDE.md"),
]

async function globalRuleFiles(): Promise<string[]> {
const files = [path.join(Global.Path.config, "AGENTS.md")]
const claudeDir = await Global.claudeConfigDir()
if (claudeDir) {
files.push(path.join(claudeDir, "CLAUDE.md"))
}
return files
}

export async function custom() {
const config = await Config.get()
Expand All @@ -78,7 +82,7 @@ export namespace SystemPrompt {
}
}

for (const globalRuleFile of GLOBAL_RULE_FILES) {
for (const globalRuleFile of await globalRuleFiles()) {
if (await Bun.file(globalRuleFile).exists()) {
paths.add(globalRuleFile)
break
Expand All @@ -88,7 +92,7 @@ export namespace SystemPrompt {
if (config.instructions) {
for (let instruction of config.instructions) {
if (instruction.startsWith("~/")) {
instruction = path.join(os.homedir(), instruction.slice(2))
instruction = path.join(Global.Path.home, instruction.slice(2))
}
let matches: string[] = []
if (path.isAbsolute(instruction)) {
Expand Down
6 changes: 2 additions & 4 deletions packages/opencode/src/skill/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { ConfigMarkdown } from "../config/markdown"
import { Log } from "../util/log"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { exists } from "fs/promises"

export namespace Skill {
const log = Log.create({ service: "skill" })
Expand Down Expand Up @@ -74,9 +73,8 @@ export namespace Skill {
stop: Instance.worktree,
}),
)
// Also include global ~/.claude/skills/
const globalClaude = `${Global.Path.home}/.claude`
if (await exists(globalClaude)) {
const globalClaude = await Global.claudeConfigDir()
if (globalClaude) {
claudeDirs.push(globalClaude)
}

Expand Down
143 changes: 143 additions & 0 deletions packages/opencode/test/global/claude-config-dir.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { test, expect, beforeEach, afterEach } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import { Global } from "../../src/global"
import path from "path"
import { mkdir } from "fs/promises"

let originalClaudeConfigDir: string | undefined
let originalTestHome: string | undefined
let originalXdgConfigHome: string | undefined

beforeEach(() => {
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
originalTestHome = process.env.OPENCODE_TEST_HOME
originalXdgConfigHome = process.env.XDG_CONFIG_HOME
})

afterEach(() => {
if (originalClaudeConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
} else {
delete process.env.CLAUDE_CONFIG_DIR
}
if (originalTestHome !== undefined) {
process.env.OPENCODE_TEST_HOME = originalTestHome
} else {
delete process.env.OPENCODE_TEST_HOME
}
if (originalXdgConfigHome !== undefined) {
process.env.XDG_CONFIG_HOME = originalXdgConfigHome
} else {
delete process.env.XDG_CONFIG_HOME
}
})

test("returns CLAUDE_CONFIG_DIR when set to valid directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await mkdir(path.join(dir, "custom-claude-config"), { recursive: true })
},
})

const customDir = path.join(tmp.path, "custom-claude-config")
process.env.CLAUDE_CONFIG_DIR = customDir
process.env.OPENCODE_TEST_HOME = tmp.path
process.env.XDG_CONFIG_HOME = path.join(tmp.path, ".config")

expect(await Global.claudeConfigDir()).toBe(customDir)
})

test("falls back when CLAUDE_CONFIG_DIR path does not exist", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await mkdir(path.join(dir, ".claude"), { recursive: true })
},
})

process.env.CLAUDE_CONFIG_DIR = path.join(tmp.path, "non-existent-dir")
process.env.OPENCODE_TEST_HOME = tmp.path
process.env.XDG_CONFIG_HOME = path.join(tmp.path, ".config")

expect(await Global.claudeConfigDir()).toBe(path.join(tmp.path, ".claude"))
})

test("falls back when CLAUDE_CONFIG_DIR is a file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "claude-file"), "not a directory")
await mkdir(path.join(dir, ".claude"), { recursive: true })
},
})

process.env.CLAUDE_CONFIG_DIR = path.join(tmp.path, "claude-file")
process.env.OPENCODE_TEST_HOME = tmp.path
process.env.XDG_CONFIG_HOME = path.join(tmp.path, ".config")

expect(await Global.claudeConfigDir()).toBe(path.join(tmp.path, ".claude"))
})

test("returns xdg path when CLAUDE_CONFIG_DIR not set", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await mkdir(path.join(dir, ".config", "claude"), { recursive: true })
await mkdir(path.join(dir, ".claude"), { recursive: true })
},
})

delete process.env.CLAUDE_CONFIG_DIR
process.env.OPENCODE_TEST_HOME = tmp.path
process.env.XDG_CONFIG_HOME = path.join(tmp.path, ".config")

expect(await Global.claudeConfigDir()).toBe(path.join(tmp.path, ".config", "claude"))
})

test("returns legacy path when only it exists", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await mkdir(path.join(dir, ".claude"), { recursive: true })
},
})

delete process.env.CLAUDE_CONFIG_DIR
process.env.OPENCODE_TEST_HOME = tmp.path
process.env.XDG_CONFIG_HOME = path.join(tmp.path, ".config")

expect(await Global.claudeConfigDir()).toBe(path.join(tmp.path, ".claude"))
})

test("returns undefined when no paths exist", async () => {
await using tmp = await tmpdir()

delete process.env.CLAUDE_CONFIG_DIR
process.env.OPENCODE_TEST_HOME = tmp.path
process.env.XDG_CONFIG_HOME = path.join(tmp.path, ".config")

expect(await Global.claudeConfigDir()).toBeUndefined()
})

test("returns undefined when CLAUDE_CONFIG_DIR is empty string", async () => {
await using tmp = await tmpdir()

process.env.CLAUDE_CONFIG_DIR = ""
process.env.OPENCODE_TEST_HOME = tmp.path
process.env.XDG_CONFIG_HOME = path.join(tmp.path, ".config")

expect(await Global.claudeConfigDir()).toBeUndefined()
})

test("priority: CLAUDE_CONFIG_DIR > XDG > legacy", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await mkdir(path.join(dir, "custom"), { recursive: true })
await mkdir(path.join(dir, ".config", "claude"), { recursive: true })
await mkdir(path.join(dir, ".claude"), { recursive: true })
},
})

const customDir = path.join(tmp.path, "custom")
process.env.CLAUDE_CONFIG_DIR = customDir
process.env.OPENCODE_TEST_HOME = tmp.path
process.env.XDG_CONFIG_HOME = path.join(tmp.path, ".config")

expect(await Global.claudeConfigDir()).toBe(customDir)
})