Skip to content
1 change: 0 additions & 1 deletion .opencode/opencode.jsonc
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
// "plugin": ["opencode-openai-codex-auth"],
// "enterprise": {
// "url": "https://enterprise.dev.opencode.ai",
// },
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global"
import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"

export namespace Agent {
export const Info = z
Expand Down Expand Up @@ -50,12 +51,14 @@ export namespace Agent {
const state = Instance.state(async () => {
const cfg = await Config.get()

const skillDirs = await Skill.dirs()
const defaults = PermissionNext.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
[Truncate.GLOB]: "allow",
...Object.fromEntries(skillDirs.map((dir) => [path.join(dir, "*"), "allow"])),
},
question: "deny",
plan_enter: "deny",
Expand Down
15 changes: 12 additions & 3 deletions packages/opencode/src/skill/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,14 +145,23 @@ export namespace Skill {
}
}

return skills
const dirs = Array.from(new Set(Object.values(skills).map((item) => path.dirname(item.location))))

return {
skills,
dirs,
}
})

export async function get(name: string) {
return state().then((x) => x[name])
return state().then((x) => x.skills[name])
}

export async function all() {
return state().then((x) => Object.values(x))
return state().then((x) => Object.values(x.skills))
}

export async function dirs() {
return state().then((x) => x.dirs)
}
}
65 changes: 54 additions & 11 deletions packages/opencode/src/tool/skill.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
import { Tool } from "./tool"
import { Skill } from "../skill"
import { PermissionNext } from "../permission/next"
import { Ripgrep } from "../file/ripgrep"
import { iife } from "@/util/iife"

export const SkillTool = Tool.define("skill", async (ctx) => {
const skills = await Skill.all()
Expand All @@ -18,21 +21,29 @@ export const SkillTool = Tool.define("skill", async (ctx) => {

const description =
accessibleSkills.length === 0
? "Load a skill to get detailed instructions for a specific task. No skills are currently available."
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
: [
"Load a skill to get detailed instructions for a specific task.",
"Skills provide specialized knowledge and step-by-step guidance.",
"Use this when a task matches an available skill's description.",
"Only the skills listed here are available:",
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
"<available_skills>",
...accessibleSkills.flatMap((skill) => [
` <skill>`,
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
` </skill>`,
]),
"</available_skills>",
].join(" ")
].join("\n")

const examples = accessibleSkills
.map((skill) => `'${skill.name}'`)
Expand All @@ -41,7 +52,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : ""

const parameters = z.object({
name: z.string().describe(`The skill identifier from available_skills${hint}`),
name: z.string().describe(`The name of the skill from available_skills${hint}`),
})

return {
Expand All @@ -61,15 +72,47 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
always: [params.name],
metadata: {},
})
const content = skill.content

const dir = path.dirname(skill.location)
const base = pathToFileURL(dir).href

// Format output similar to plugin pattern
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", content.trim()].join("\n")
const limit = 10
const files = await iife(async () => {
const arr = []
for await (const file of Ripgrep.files({
cwd: dir,
follow: false,
hidden: true,
signal: ctx.abort,
})) {
if (file.includes("SKILL.md")) {
continue
}
arr.push(path.resolve(dir, file))
if (arr.length >= limit) {
break
}
}
return arr
}).then((f) => f.map((file) => `<file>${file}</file>`).join("\n"))

return {
title: `Loaded skill: ${skill.name}`,
output,
output: [
`<skill_content name="${skill.name}">`,
`# Skill: ${skill.name}`,
"",
skill.content.trim(),
"",
`Base directory for this skill: ${base}`,
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"",
"<skill_files>",
files,
"</skill_files>",
"</skill_content>",
].join("\n"),
metadata: {
name: skill.name,
dir,
Expand Down
37 changes: 37 additions & 0 deletions packages/opencode/test/agent/agent.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect } from "bun:test"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent"
Expand Down Expand Up @@ -513,6 +514,42 @@ test("explicit Truncate.GLOB deny is respected", async () => {
})
})

test("skill directories are allowed for external_directory", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".opencode", "skill", "perm-skill")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: perm-skill
description: Permission skill.
---

# Permission Skill
`,
)
},
})

const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path

try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill")
const target = path.join(skillDir, "reference", "notes.md")
expect(PermissionNext.evaluate("external_directory", target, build!.permission).action).toBe("allow")
},
})
} finally {
process.env.OPENCODE_TEST_HOME = home
}
})

test("defaultAgent returns build when no default_agent config", async () => {
await using tmp = await tmpdir()
await Instance.provide({
Expand Down
36 changes: 36 additions & 0 deletions packages/opencode/test/skill/skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,42 @@ Instructions here.
})
})

test("returns skill directories from Skill.dirs", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".opencode", "skill", "dir-skill")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: dir-skill
description: Skill for dirs test.
---

# Dir Skill
`,
)
},
})

const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path

try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const dirs = await Skill.dirs()
const skillDir = path.join(tmp.path, ".opencode", "skill", "dir-skill")
expect(dirs).toContain(skillDir)
expect(dirs.length).toBe(1)
},
})
} finally {
process.env.OPENCODE_TEST_HOME = home
}
})

test("discovers multiple skills from .opencode/skill/ directory", async () => {
await using tmp = await tmpdir({
git: true,
Expand Down
Loading