summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAiden Cline <[email protected]>2026-02-03 09:58:31 -0600
committerGitHub <[email protected]>2026-02-03 09:58:31 -0600
commit39753296292e594da0568a230bafa368888d4614 (patch)
tree39ee581cd1e792a9360eeda1b37dc9a2974c1cce
parent54e14c1a176d3e985216e249d2f48266d5a55d55 (diff)
downloadopencode-39753296292e594da0568a230bafa368888d4614.tar.gz
opencode-39753296292e594da0568a230bafa368888d4614.zip
feat: improve skills, better prompting, fix permission asks after invoking skills, ensure agent knows where scripts/resources are (#11737)
-rw-r--r--.opencode/opencode.jsonc1
-rw-r--r--packages/opencode/src/agent/agent.ts3
-rw-r--r--packages/opencode/src/skill/skill.ts15
-rw-r--r--packages/opencode/src/tool/skill.ts65
-rw-r--r--packages/opencode/test/agent/agent.test.ts37
-rw-r--r--packages/opencode/test/skill/skill.test.ts36
6 files changed, 142 insertions, 15 deletions
diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc
index 52fd00432..e2350c907 100644
--- a/.opencode/opencode.jsonc
+++ b/.opencode/opencode.jsonc
@@ -1,6 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
- // "plugin": ["opencode-openai-codex-auth"],
// "enterprise": {
// "url": "https://enterprise.dev.opencode.ai",
// },
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index 0eaa410e0..e338559be 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -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
@@ -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",
diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts
index 6e05d013a..7ffc4d8a1 100644
--- a/packages/opencode/src/skill/skill.ts
+++ b/packages/opencode/src/skill/skill.ts
@@ -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)
}
}
diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts
index 8f285d599..8fcfb592d 100644
--- a/packages/opencode/src/tool/skill.ts
+++ b/packages/opencode/src/tool/skill.ts
@@ -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()
@@ -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}'`)
@@ -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 {
@@ -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,
diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts
index 05b842739..5e91059ff 100644
--- a/packages/opencode/test/agent/agent.test.ts
+++ b/packages/opencode/test/agent/agent.test.ts
@@ -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"
@@ -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({
diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts
index 72415c141..1d4828580 100644
--- a/packages/opencode/test/skill/skill.test.ts
+++ b/packages/opencode/test/skill/skill.test.ts
@@ -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,