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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down Expand Up @@ -123,25 +125,31 @@ export namespace Command {
}

// Add skills as invokable commands
const seen: Record<string, number> = {}
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() {
Expand Down
59 changes: 45 additions & 14 deletions packages/opencode/src/skill/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Info>

Expand Down Expand Up @@ -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<string, Info> = {}
const list: Info[] = []
const dirs = new Set<string>()
const groups = new Map<string, number[]>()

const addSkill = async (match: string) => {
const md = await ConfigMarkdown.parse(match).catch((err) => {
Expand All @@ -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") => {
Expand Down Expand Up @@ -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() {
Expand Down
30 changes: 27 additions & 3 deletions packages/opencode/test/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")
Expand Down
65 changes: 65 additions & 0 deletions packages/opencode/test/skill/skill.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
}
})
Loading