diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 3240afab326a..05547176f417 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -357,11 +357,18 @@ export function Autocomplete(props: { const results: AutocompleteOption[] = [...command.slashes()] for (const serverCommand of sync.data.command) { - if (serverCommand.source === "skill") continue const label = serverCommand.source === "mcp" ? ":mcp" : "" + const meta = serverCommand as typeof serverCommand & { + skill_scope?: "system" | "user" | "project" + skill_duplicate?: boolean + } + const description = + serverCommand.source !== "skill" || !meta.skill_duplicate || !meta.skill_scope + ? serverCommand.description + : "(" + meta.skill_scope + ") " + (serverCommand.description ?? "").trim() results.push({ display: "/" + serverCommand.name + label, - description: serverCommand.description, + description, onSelect: () => { const newText = "/" + serverCommand.name + " " const cursor = props.input().logicalCursor diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index dce7ac8bbc34..2097bec9d214 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -28,6 +28,8 @@ export namespace Command { agent: z.string().optional(), model: z.string().optional(), source: z.enum(["command", "mcp", "skill"]).optional(), + skill_scope: z.enum(["system", "user", "project"]).optional(), + skill_duplicate: z.boolean().optional(), // workaround for zod not supporting async functions natively so we use getters // https://zod.dev/v4/changelog?id=zfunction template: z.promise(z.string()).or(z.string()), @@ -123,25 +125,31 @@ export namespace Command { } // Add skills as invokable commands + const seen: Record = {} for (const skill of await Skill.all()) { - // Skip if a command with this name already exists - if (result[skill.name]) continue - result[skill.name] = { + const next: Info = { name: skill.name, description: skill.description, source: "skill", + skill_scope: skill.scope, + skill_duplicate: skill.duplicate, get template() { return skill.content }, hints: [], } + const existing = result[skill.name] + if (existing && existing.source !== "skill") continue + const count = (seen[skill.name] ?? 0) + 1 + seen[skill.name] = count + result["skill:" + skill.name + ":" + count] = next } return result }) export async function get(name: string) { - return state().then((x) => x[name]) + return state().then((x) => x[name] ?? Object.values(x).find((item) => item.name === name)) } export async function list() { diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 42795b7ebcc3..89570cf3660d 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -15,11 +15,19 @@ import { Discovery } from "./discovery" export namespace Skill { const log = Log.create({ service: "skill" }) + const SCOPE = { + SYSTEM: "system", + USER: "user", + PROJECT: "project", + } as const + type Scope = (typeof SCOPE)[keyof typeof SCOPE] export const Info = z.object({ name: z.string(), description: z.string(), location: z.string(), content: z.string(), + scope: z.enum([SCOPE.SYSTEM, SCOPE.USER, SCOPE.PROJECT]).optional(), + duplicate: z.boolean().optional(), }) export type Info = z.infer @@ -49,9 +57,25 @@ export namespace Skill { const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md") const SKILL_GLOB = new Bun.Glob("**/SKILL.md") + const key = (input: string) => { + const value = input.replace(/\\/g, "/") + return process.platform === "win32" ? value.toLowerCase() : value + } + + const scope = (input: string): Scope => { + const value = key(input) + const directory = key(Instance.directory) + const worktree = key(Instance.worktree) + if (value === directory || value.startsWith(directory + "/")) return SCOPE.PROJECT + if (value === worktree || value.startsWith(worktree + "/")) return SCOPE.PROJECT + if (/(^|\/)\.system(\/|$)/.test(value)) return SCOPE.SYSTEM + return SCOPE.USER + } + export const state = Instance.state(async () => { - const skills: Record = {} + const list: Info[] = [] const dirs = new Set() + const groups = new Map() const addSkill = async (match: string) => { const md = await ConfigMarkdown.parse(match).catch((err) => { @@ -68,23 +92,30 @@ export namespace Skill { const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) if (!parsed.success) return - // Warn on duplicate skill names - if (skills[parsed.data.name]) { + const group = groups.get(parsed.data.name) ?? [] + const item: Info = { + name: parsed.data.name, + description: parsed.data.description, + location: match, + content: md.content, + scope: scope(match), + duplicate: group.length > 0, + } + + if (group.length > 0) { log.warn("duplicate skill name", { name: parsed.data.name, - existing: skills[parsed.data.name].location, + existing: list[group[0]]?.location ?? "", duplicate: match, }) + for (const i of group) { + list[i].duplicate = true + } } dirs.add(path.dirname(match)) - - skills[parsed.data.name] = { - name: parsed.data.name, - description: parsed.data.description, - location: match, - content: md.content, - } + list.push(item) + groups.set(parsed.data.name, [...group, list.length - 1]) } const scanExternal = async (root: string, scope: "global" | "project") => { @@ -169,17 +200,17 @@ export namespace Skill { } return { - skills, + list, dirs: Array.from(dirs), } }) export async function get(name: string) { - return state().then((x) => x.skills[name]) + return state().then((x) => x.list.find((item) => item.name === name)) } export async function all() { - return state().then((x) => Object.values(x.skills)) + return state().then((x) => x.list) } export async function dirs() { diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index dee7045707ea..4aaa2a133913 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -3,14 +3,38 @@ import os from "os" import path from "path" import fs from "fs/promises" -import fsSync from "fs" import { afterAll } from "bun:test" +import { setTimeout as delay } from "timers/promises" // Set XDG env vars FIRST, before any src/ imports const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid) await fs.mkdir(dir, { recursive: true }) -afterAll(() => { - fsSync.rmSync(dir, { recursive: true, force: true }) + +const clean = async () => { + const retry = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]) + for (let i = 0; i < 20; i++) { + try { + await fs.rm(dir, { recursive: true, force: true }) + return + } catch (error) { + const code = + error && typeof error === "object" && "code" in error && typeof error.code === "string" + ? error.code + : undefined + if (code && retry.has(code) && i < 19) { + await delay(50) + continue + } + if (code && retry.has(code)) { + return + } + throw error + } + } +} + +afterAll(async () => { + await clean() }) process.env["XDG_DATA_HOME"] = path.join(dir, "share") diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index c310256c5e72..1130646083f5 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -1,5 +1,6 @@ import { test, expect } from "bun:test" import { Skill } from "../../src/skill" +import { Command } from "../../src/command" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import path from "path" @@ -386,3 +387,67 @@ description: A skill in the .opencode/skills directory. }, }) }) + + +test("keeps duplicate skills from user and system scopes and exposes both in command list", async () => { + await using project = await tmpdir({ git: true }) + await using home = await tmpdir() + + const systemDir = path.join(home.path, ".claude", "skills", ".system", "skill-creator") + const userDir = path.join(home.path, ".claude", "skills", "skill-creator") + + await fs.mkdir(systemDir, { recursive: true }) + await fs.mkdir(userDir, { recursive: true }) + + await Bun.write( + path.join(systemDir, "SKILL.md"), + `--- +name: skill-creator +description: system skill creator +--- + +# System Skill Creator +`, + ) + + await Bun.write( + path.join(userDir, "SKILL.md"), + `--- +name: skill-creator +description: user skill creator +--- + +# User Skill Creator +`, + ) + + const homeValue = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = home.path + + try { + await Instance.provide({ + directory: project.path, + fn: async () => { + const skills = (await Skill.all()).filter((item) => item.name === "skill-creator") + expect(skills.length).toBe(2) + expect(skills.every((item) => item.duplicate)).toBe(true) + expect(skills.find((item) => item.scope === "system")).toBeDefined() + expect(skills.find((item) => item.scope === "user")).toBeDefined() + + const active = await Skill.get("skill-creator") + expect(active).toBeDefined() + const activeScope = active?.scope + expect(activeScope).toBeDefined() + if (!activeScope) throw new Error("active scope missing") + expect(["user", "system", "project"]).toContain(activeScope) + + const commands = (await Command.list()).filter((item) => item.source === "skill" && item.name === "skill-creator") + expect(commands.length).toBe(2) + expect(commands.find((item) => item.skill_scope === "system")).toBeDefined() + expect(commands.find((item) => item.skill_scope === "user")).toBeDefined() + }, + }) + } finally { + process.env.OPENCODE_TEST_HOME = homeValue + } +})