diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 10b6125a6a93..845d081c5eaa 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -5,10 +5,18 @@ 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 = { @@ -16,12 +24,27 @@ export namespace Global { 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") + }, } } diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 42795b7ebcc3..99b2255bab94 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -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") @@ -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, diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index c310256c5e72..6e2a67f63cc3 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -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" @@ -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. --- @@ -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. --- @@ -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. --- @@ -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. --- @@ -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. --- @@ -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 `, ) }, @@ -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) + } }, }) }) diff --git a/packages/web/src/content/docs/skills.mdx b/packages/web/src/content/docs/skills.mdx index 2ce88ea5682f..c176ca4ec6ad 100644 --- a/packages/web/src/content/docs/skills.mdx +++ b/packages/web/src/content/docs/skills.mdx @@ -18,7 +18,8 @@ OpenCode searches these locations: - Project Claude-compatible: `.claude/skills//SKILL.md` - Global Claude-compatible: `~/.claude/skills//SKILL.md` - Project agent-compatible: `.agents/skills//SKILL.md` -- Global agent-compatible: `~/.agents/skills//SKILL.md` +- Global agent-compatible: `~/.config/agents/skills//SKILL.md` (preferred) +- Global agent-compatible (legacy): `~/.agents/skills//SKILL.md` (fallback) --- @@ -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). ---