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
43 changes: 33 additions & 10 deletions packages/opencode/src/global/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,46 @@ import os from "os"

const app = "opencode"

const data = path.join(xdgData!, app)
const cache = path.join(xdgCache!, app)
const config = path.join(xdgConfig!, app)
const state = path.join(xdgState!, app)
// Allow tests to override XDG paths
let configOverride: string | undefined

function getXdgDir(xdgValue: string | undefined, xdgFallback: string, override?: string) {
// Prefer explicit override for test isolation
if (override) return override
// Then prefer explicit XDG env var
if (xdgValue) return path.join(xdgValue, app)
// Fall back to test home
const home = process.env.OPENCODE_TEST_HOME || os.homedir()
return path.join(home, xdgFallback, app)
}

export namespace Global {
export const Path = {
// Allow override via OPENCODE_TEST_HOME for test isolation
get home() {
return process.env.OPENCODE_TEST_HOME || os.homedir()
},
data,
bin: path.join(data, "bin"),
log: path.join(data, "log"),
cache,
config,
state,
get data() {
return getXdgDir(xdgData, ".local/share")
},
get bin() {
return path.join(this.data, "bin")
},
get log() {
return path.join(this.data, "log")
},
get cache() {
return getXdgDir(xdgCache, ".cache")
},
get config() {
return getXdgDir(xdgConfig, ".config", configOverride)
},
set config(value: string) {
configOverride = value
},
get state() {
return getXdgDir(xdgState, ".local/state")
},
}
}

Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/skill/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ export namespace Skill {

// External skill directories to search for (project-level and global)
// These follow the directory layout used by Claude Code and other agents.
// Priority order: .claude (legacy), agents (XDG-compliant ~/.config/agents, fallback to ~/.agents)
const EXTERNAL_DIRS = [".claude", ".agents"]
const EXTERNAL_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
const XDG_AGENTS_DIR = "agents" // XDG-compliant location in config dir

const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
const SKILL_GLOB = new Bun.Glob("**/SKILL.md")
Expand Down Expand Up @@ -106,12 +108,19 @@ export namespace Skill {
// Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
// Load global (home) first, then project-level (so project-level overwrites)
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
// Scan legacy locations (~/.claude, ~/.agents) first
for (const dir of EXTERNAL_DIRS) {
const root = path.join(Global.Path.home, dir)
if (!(await Filesystem.isDir(root))) continue
await scanExternal(root, "global")
}

// Then scan XDG-compliant ~/.config/agents/skills (takes priority over legacy)
const xdgAgentsRoot = path.join(Global.Path.config, XDG_AGENTS_DIR)
if (await Filesystem.isDir(xdgAgentsRoot)) {
await scanExternal(xdgAgentsRoot, "global")
}

for await (const root of Filesystem.up({
targets: EXTERNAL_DIRS,
start: Instance.directory,
Expand Down
166 changes: 132 additions & 34 deletions packages/opencode/test/skill/skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { test, expect } from "bun:test"
import { Skill } from "../../src/skill"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { Global } from "../../src/global"
import { Filesystem } from "../../src/util/filesystem"
import path from "path"
import fs from "fs/promises"

Expand Down Expand Up @@ -250,53 +252,140 @@ description: A skill in the .agents/skills directory.
})
})

test("discovers global skills from ~/.agents/skills/ directory", async () => {
test("discovers global skills from ~/.config/agents/skills/ directory (XDG-compliant)", async () => {
await using tmp = await tmpdir({ git: true })

const originalHome = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path
const uniqueId = Math.random().toString(36).slice(2, 8)
const skillName = `xdg-agent-skill-${uniqueId}`

const skillDir = path.join(Global.Path.config, "agents", "skills", skillName)
await fs.mkdir(skillDir, { recursive: true })
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: ${skillName}
description: A global skill from ~/.config/agents/skills for testing.
---

# XDG Agent Skill
`,
)

try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
const foundSkill = skills.find((s) => s.name === skillName)
expect(foundSkill).toBeDefined()
expect(foundSkill!.description).toBe("A global skill from ~/.config/agents/skills for testing.")
expect(foundSkill!.location).toContain("agents/skills/")
},
})
} finally {
await fs.rm(skillDir, { recursive: true, force: true })
}
})

test("prioritizes ~/.config/agents/skills over ~/.agents/skills", async () => {
await using tmp = await tmpdir({ git: true })

// Use unique skill names to avoid conflicts with other tests
const uniqueId = Math.random().toString(36).slice(2, 8)
const skillName = `priority-skill-${uniqueId}`

// Create XDG skill in global config location (higher priority)
const xdgSkillDir = path.join(Global.Path.config, "agents", "skills", skillName)
await fs.mkdir(xdgSkillDir, { recursive: true })
await Bun.write(
path.join(xdgSkillDir, "SKILL.md"),
`---
name: ${skillName}
description: XDG-compliant skill from ~/.config/agents/skills.
---

# Priority Skill (XDG)
`,
)

// Create legacy skill in home directory (lower priority)
const legacySkillDir = path.join(Global.Path.home, ".agents", "skills", skillName)
await fs.mkdir(legacySkillDir, { recursive: true })
await Bun.write(
path.join(legacySkillDir, "SKILL.md"),
`---
name: ${skillName}
description: Legacy skill from ~/.agents/skills.
---

# Priority Skill (Legacy)
`,
)

try {
const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill")
await fs.mkdir(skillDir, { recursive: true })
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: global-agent-skill
await Instance.provide({
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
const prioritySkill = skills.find((s) => s.name === skillName)
expect(prioritySkill).toBeDefined()
expect(prioritySkill!.description).toBe("XDG-compliant skill from ~/.config/agents/skills.")
expect(prioritySkill!.location).toContain("agents/skills/")
},
})
} finally {
await fs.rm(xdgSkillDir, { recursive: true, force: true })
await fs.rm(legacySkillDir, { recursive: true, force: true })
}
})

test("discovers global skills from ~/.agents/skills/ directory", async () => {
await using tmp = await tmpdir({ git: true })

const uniqueId = Math.random().toString(36).slice(2, 8)
const skillName = `global-agent-skill-${uniqueId}`

const skillDir = path.join(Global.Path.home, ".agents", "skills", skillName)
await fs.mkdir(skillDir, { recursive: true })
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: ${skillName}
description: A global skill from ~/.agents/skills for testing.
---

# Global Agent Skill

This skill is loaded from the global home directory.
`,
)
)

try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
expect(skills.length).toBe(1)
expect(skills[0].name).toBe("global-agent-skill")
expect(skills[0].description).toBe("A global skill from ~/.agents/skills for testing.")
expect(skills[0].location).toContain(".agents/skills/global-agent-skill/SKILL.md")
const foundSkill = skills.find((s) => s.name === skillName)
expect(foundSkill).toBeDefined()
expect(foundSkill!.description).toBe("A global skill from ~/.agents/skills for testing.")
expect(foundSkill!.location).toContain(".agents/skills/")
},
})
} finally {
process.env.OPENCODE_TEST_HOME = originalHome
await fs.rm(skillDir, { recursive: true, force: true })
}
})

test("discovers skills from both .claude/skills/ and .agents/skills/", async () => {
const uniqueId = Math.random().toString(36).slice(2, 8)

await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const claudeDir = path.join(dir, ".claude", "skills", "claude-skill")
const agentDir = path.join(dir, ".agents", "skills", "agent-skill")
const claudeDir = path.join(dir, ".claude", "skills", `claude-skill-${uniqueId}`)
const agentDir = path.join(dir, ".agents", "skills", `agent-skill-${uniqueId}`)
await Bun.write(
path.join(claudeDir, "SKILL.md"),
`---
name: claude-skill
name: claude-skill-${uniqueId}
description: A skill in the .claude/skills directory.
---

Expand All @@ -306,7 +395,7 @@ description: A skill in the .claude/skills directory.
await Bun.write(
path.join(agentDir, "SKILL.md"),
`---
name: agent-skill
name: agent-skill-${uniqueId}
description: A skill in the .agents/skills directory.
---

Expand All @@ -320,25 +409,26 @@ description: A skill in the .agents/skills directory.
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
expect(skills.length).toBe(2)
expect(skills.find((s) => s.name === "claude-skill")).toBeDefined()
expect(skills.find((s) => s.name === "agent-skill")).toBeDefined()
expect(skills.find((s) => s.name === `claude-skill-${uniqueId}`)).toBeDefined()
expect(skills.find((s) => s.name === `agent-skill-${uniqueId}`)).toBeDefined()
},
})
})

test("properly resolves directories that skills live in", async () => {
const uniqueId = Math.random().toString(36).slice(2, 8)

await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const opencodeSkillDir = path.join(dir, ".opencode", "skill", "agent-skill")
const opencodeSkillsDir = path.join(dir, ".opencode", "skills", "agent-skill")
const claudeDir = path.join(dir, ".claude", "skills", "claude-skill")
const agentDir = path.join(dir, ".agents", "skills", "agent-skill")
const opencodeSkillDir = path.join(dir, ".opencode", "skill", `opencode-skill-${uniqueId}`)
const opencodeSkillsDir = path.join(dir, ".opencode", "skills", `opencode-skills-${uniqueId}`)
const claudeDir = path.join(dir, ".claude", "skills", `claude-skill-${uniqueId}`)
const agentDir = path.join(dir, ".agents", "skills", `agent-skill-${uniqueId}`)
await Bun.write(
path.join(claudeDir, "SKILL.md"),
`---
name: claude-skill
name: claude-skill-${uniqueId}
description: A skill in the .claude/skills directory.
---

Expand All @@ -348,7 +438,7 @@ description: A skill in the .claude/skills directory.
await Bun.write(
path.join(agentDir, "SKILL.md"),
`---
name: agent-skill
name: agent-skill-${uniqueId}
description: A skill in the .agents/skills directory.
---

Expand All @@ -358,7 +448,7 @@ description: A skill in the .agents/skills directory.
await Bun.write(
path.join(opencodeSkillDir, "SKILL.md"),
`---
name: opencode-skill
name: opencode-skill-${uniqueId}
description: A skill in the .opencode/skill directory.
---

Expand All @@ -368,11 +458,11 @@ description: A skill in the .opencode/skill directory.
await Bun.write(
path.join(opencodeSkillsDir, "SKILL.md"),
`---
name: opencode-skill
name: opencode-skills-${uniqueId}
description: A skill in the .opencode/skills directory.
---

# OpenCode Skill
# OpenCode Skills
`,
)
},
Expand All @@ -382,7 +472,15 @@ description: A skill in the .opencode/skills directory.
directory: tmp.path,
fn: async () => {
const dirs = await Skill.dirs()
expect(dirs.length).toBe(4)
const skillNames = [
`claude-skill-${uniqueId}`,
`agent-skill-${uniqueId}`,
`opencode-skill-${uniqueId}`,
`opencode-skills-${uniqueId}`,
]
for (const name of skillNames) {
expect(dirs.some((d) => d.includes(name))).toBe(true)
}
},
})
})
5 changes: 3 additions & 2 deletions packages/web/src/content/docs/skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ OpenCode searches these locations:
- Project Claude-compatible: `.claude/skills/<name>/SKILL.md`
- Global Claude-compatible: `~/.claude/skills/<name>/SKILL.md`
- Project agent-compatible: `.agents/skills/<name>/SKILL.md`
- Global agent-compatible: `~/.agents/skills/<name>/SKILL.md`
- Global agent-compatible: `~/.config/agents/skills/<name>/SKILL.md` (preferred)
- Global agent-compatible (legacy): `~/.agents/skills/<name>/SKILL.md` (fallback)

---

Expand All @@ -27,7 +28,7 @@ OpenCode searches these locations:
For project-local paths, OpenCode walks up from your current working directory until it reaches the git worktree.
It loads any matching `skills/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` or `.agents/skills/*/SKILL.md` along the way.

Global definitions are also loaded from `~/.config/opencode/skills/*/SKILL.md`, `~/.claude/skills/*/SKILL.md`, and `~/.agents/skills/*/SKILL.md`.
Global definitions are loaded from `~/.config/opencode/skills/*/SKILL.md`, `~/.claude/skills/*/SKILL.md`, `~/.config/agents/skills/*/SKILL.md` (preferred), and `~/.agents/skills/*/SKILL.md` (legacy fallback).

---

Expand Down
Loading