diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 52fd004324a6..e2350c907b52 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 0eaa410e0f61..e338559be7e4 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 6e05d013ae5c..7ffc4d8a15fe 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 8f285d5999a0..8fcfb592dee6 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 `` 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:", + "", "", ...accessibleSkills.flatMap((skill) => [ ` `, ` ${skill.name}`, ` ${skill.description}`, + ` ${pathToFileURL(skill.location).href}`, ` `, ]), "", - ].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}`).join("\n")) return { title: `Loaded skill: ${skill.name}`, - output, + output: [ + ``, + `# 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.", + "", + "", + files, + "", + "", + ].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 05b8427394bc..5e91059ffb36 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 72415c1411e0..1d4828580ae8 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,