Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .opencode/skill/test-skill/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
name: test-skill
description: use this when asked to test skill
---

woah this is a test skill
32 changes: 32 additions & 0 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export namespace Agent {
permission: z.object({
edit: Config.Permission,
bash: z.record(z.string(), Config.Permission),
skill: z.record(z.string(), Config.Permission),
webfetch: Config.Permission.optional(),
doom_loop: Config.Permission.optional(),
external_directory: Config.Permission.optional(),
Expand Down Expand Up @@ -58,6 +59,9 @@ export namespace Agent {
bash: {
"*": "allow",
},
skill: {
"*": "allow",
},
webfetch: "allow",
doom_loop: "ask",
external_directory: "ask",
Expand Down Expand Up @@ -337,6 +341,17 @@ function mergeAgentPermissions(basePermission: any, overridePermission: any): Ag
"*": overridePermission.bash,
}
}

if (typeof basePermission.skill === "string") {
basePermission.skill = {
"*": basePermission.skill,
}
}
if (typeof overridePermission.skill === "string") {
overridePermission.skill = {
"*": overridePermission.skill,
}
}
const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any
let mergedBash
if (merged.bash) {
Expand All @@ -354,10 +369,27 @@ function mergeAgentPermissions(basePermission: any, overridePermission: any): Ag
}
}

let mergedSkill
if (merged.skill) {
if (typeof merged.skill === "string") {
mergedSkill = {
"*": merged.skill,
}
} else if (typeof merged.skill === "object") {
mergedSkill = mergeDeep(
{
"*": "allow",
},
merged.skill,
)
}
}

const result: Agent.Info["permission"] = {
edit: merged.edit ?? "allow",
webfetch: merged.webfetch ?? "allow",
bash: mergedBash ?? { "*": "allow" },
skill: mergedSkill ?? { "*": "allow" },
doom_loop: merged.doom_loop,
external_directory: merged.external_directory,
}
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/cli/cmd/debug/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { FileCommand } from "./file"
import { LSPCommand } from "./lsp"
import { RipgrepCommand } from "./ripgrep"
import { ScrapCommand } from "./scrap"
import { SkillCommand } from "./skill"
import { SnapshotCommand } from "./snapshot"

export const DebugCommand = cmd({
Expand All @@ -17,6 +18,7 @@ export const DebugCommand = cmd({
.command(RipgrepCommand)
.command(FileCommand)
.command(ScrapCommand)
.command(SkillCommand)
.command(SnapshotCommand)
.command(PathsCommand)
.command({
Expand Down
15 changes: 15 additions & 0 deletions packages/opencode/src/cli/cmd/debug/skill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { EOL } from "os"
import { Skill } from "../../../skill"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"

export const SkillCommand = cmd({
command: "skill",
builder: (yargs) => yargs,
async handler() {
await bootstrap(process.cwd(), async () => {
const skills = await Skill.all()
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
})
},
})
2 changes: 2 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@ export namespace Config {
.object({
edit: Permission.optional(),
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
webfetch: Permission.optional(),
doom_loop: Permission.optional(),
external_directory: Permission.optional(),
Expand Down Expand Up @@ -764,6 +765,7 @@ export namespace Config {
.object({
edit: Permission.optional(),
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
webfetch: Permission.optional(),
doom_loop: Permission.optional(),
external_directory: Permission.optional(),
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export namespace SessionCompaction {
export const PRUNE_MINIMUM = 20_000
export const PRUNE_PROTECT = 40_000

const PRUNE_PROTECTED_TOOLS = ["skill"]

// goes backwards through parts until there are 40_000 tokens worth of tool
// calls. then erases output of previous tool calls. idea is to throw away old
// tool calls that are no longer relevant.
Expand All @@ -61,6 +63,8 @@ export namespace SessionCompaction {
const part = msg.parts[partIndex]
if (part.type === "tool")
if (part.state.status === "completed") {
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue

if (part.state.time.compacted) break loop
const estimate = Token.estimate(part.state.output)
total += estimate
Expand Down
6 changes: 1 addition & 5 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,11 +532,7 @@ export namespace SessionPrompt {
agent,
abort,
sessionID,
system: [
...(await SystemPrompt.environment()),
...(await SystemPrompt.skills()),
...(await SystemPrompt.custom()),
],
system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
messages: [
...MessageV2.toModelMessage(sessionMessages),
...(isLastStep
Expand Down
22 changes: 0 additions & 22 deletions packages/opencode/src/session/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import PROMPT_POLARIS from "./prompt/polaris.txt"
import PROMPT_BEAST from "./prompt/beast.txt"
import PROMPT_GEMINI from "./prompt/gemini.txt"
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt"

import PROMPT_CODEX from "./prompt/codex.txt"
import type { Provider } from "@/provider/provider"
Expand Down Expand Up @@ -118,25 +117,4 @@ export namespace SystemPrompt {
)
return Promise.all(found).then((result) => result.filter(Boolean))
}

export async function skills() {
const all = await Skill.all()
if (all.length === 0) return []

const lines = [
"You have access to skills listed in `<available_skills>`. When a task matches a skill's description, read its SKILL.md file to get detailed instructions.",
"",
"<available_skills>",
]
for (const skill of all) {
lines.push(" <skill>")
lines.push(` <name>${skill.name}</name>`)
lines.push(` <description>${skill.description}</description>`)
lines.push(` <location>${skill.location}</location>`)
lines.push(" </skill>")
}
lines.push("</available_skills>")

return [lines.join("\n")]
}
}
135 changes: 26 additions & 109 deletions packages/opencode/src/skill/skill.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,16 @@
import path from "path"
import z from "zod"
import { Config } from "../config/config"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { NamedError } from "@opencode-ai/util/error"
import { ConfigMarkdown } from "../config/markdown"
import { Log } from "../util/log"

export namespace Skill {
const log = Log.create({ service: "skill" })

// Name: 1-64 chars, lowercase alphanumeric and hyphens, no consecutive hyphens, can't start/end with hyphen
const NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/

export const Frontmatter = z.object({
name: z
.string()
.min(1)
.max(64)
.refine((val) => NAME_REGEX.test(val), {
message:
"Name must be lowercase alphanumeric with hyphens, no consecutive hyphens, cannot start or end with hyphen",
}),
description: z.string().min(1).max(1024),
license: z.string().optional(),
compatibility: z.string().max(500).optional(),
metadata: z.record(z.string(), z.string()).optional(),
export const Info = z.object({
name: z.string(),
description: z.string(),
location: z.string(),
})

export type Frontmatter = z.infer<typeof Frontmatter>

export interface Info {
name: string
description: string
location: string
license?: string
compatibility?: string
metadata?: Record<string, string>
}
export type Info = z.infer<typeof Info>

export const InvalidError = NamedError.create(
"SkillInvalidError",
Expand All @@ -57,98 +30,42 @@ export namespace Skill {
}),
)

const SKILL_GLOB = new Bun.Glob("skill/*/SKILL.md")
// const CLAUDE_SKILL_GLOB = new Bun.Glob("*/SKILL.md")
const SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md")

async function discover(): Promise<string[]> {
export const state = Instance.state(async () => {
const directories = await Config.directories()
const skills: Record<string, Info> = {}

const paths: string[] = []

// Scan skill/ subdirectory in config directories (.opencode/, ~/.opencode/, etc.)
for (const dir of directories) {
for await (const match of SKILL_GLOB.scan({
cwd: dir,
absolute: true,
onlyFiles: true,
followSymlinks: true,
})) {
paths.push(match)
const md = await ConfigMarkdown.parse(match)
if (!md) {
continue
}

const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
if (!parsed.success) continue
skills[parsed.data.name] = {
name: parsed.data.name,
description: parsed.data.description,
location: match,
}
}
}

// Also scan .claude/skills/ walking up from cwd to worktree
// for await (const dir of Filesystem.up({
// targets: [".claude/skills"],
// start: Instance.directory,
// stop: Instance.worktree,
// })) {
// for await (const match of CLAUDE_SKILL_GLOB.scan({
// cwd: dir,
// absolute: true,
// onlyFiles: true,
// followSymlinks: true,
// })) {
// paths.push(match)
// }
// }

return paths
}

async function load(skillMdPath: string): Promise<Info> {
const md = await ConfigMarkdown.parse(skillMdPath)
if (!md.data) {
throw new InvalidError({
path: skillMdPath,
message: "SKILL.md must have YAML frontmatter",
})
}

const parsed = Frontmatter.safeParse(md.data)
if (!parsed.success) {
throw new InvalidError({
path: skillMdPath,
issues: parsed.error.issues,
})
}

const frontmatter = parsed.data
const skillDir = path.dirname(skillMdPath)
const dirName = path.basename(skillDir)

if (frontmatter.name !== dirName) {
throw new NameMismatchError({
path: skillMdPath,
expected: dirName,
actual: frontmatter.name,
})
}

return {
name: frontmatter.name,
description: frontmatter.description,
location: skillMdPath,
license: frontmatter.license,
compatibility: frontmatter.compatibility,
metadata: frontmatter.metadata,
}
}

export const state = Instance.state(async () => {
const paths = await discover()
const skills: Info[] = []

for (const skillPath of paths) {
const info = await load(skillPath)
log.info("loaded skill", { name: info.name, location: info.location })
skills.push(info)
}

return skills
})

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

export async function all() {
return state().then((x) => Object.values(x))
}
}
6 changes: 6 additions & 0 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { TodoWriteTool, TodoReadTool } from "./todo"
import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write"
import { InvalidTool } from "./invalid"
import { SkillTool } from "./skill"
import type { Agent } from "../agent/agent"
import { Tool } from "./tool"
import { Instance } from "../project/instance"
Expand Down Expand Up @@ -103,6 +104,7 @@ export namespace ToolRegistry {
TodoReadTool,
WebSearchTool,
CodeSearchTool,
SkillTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
...custom,
Expand Down Expand Up @@ -150,6 +152,10 @@ export namespace ToolRegistry {
result["codesearch"] = false
result["websearch"] = false
}
// Disable skill tool if all skills are denied
if (agent.permission.skill["*"] === "deny" && Object.keys(agent.permission.skill).length === 1) {
result["skill"] = false
}

return result
}
Expand Down
Loading