mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 14:43:37 -04:00
107 lines
3.8 KiB
TypeScript
107 lines
3.8 KiB
TypeScript
export * as SkillTool from "./skill"
|
|
|
|
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
|
import path from "path"
|
|
import { ToolFailure } from "@opencode-ai/ai"
|
|
import { Effect, Schema } from "effect"
|
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
import { Skill } from "../../skill"
|
|
import { Permission } from "../../permission"
|
|
|
|
export const name = "skill"
|
|
const FILE_LIMIT = 10
|
|
|
|
export const Input = Schema.Struct({
|
|
id: Skill.ID.annotate({ description: "The ID of the skill from the available skills list" }),
|
|
})
|
|
|
|
export const Output = Schema.Struct({
|
|
name: Skill.Name,
|
|
directory: Schema.String,
|
|
output: Schema.String,
|
|
})
|
|
export const description = [
|
|
"Load a specialized skill's instructions and resources into the current conversation when the task at hand matches its description.",
|
|
"",
|
|
"The skill ID must match one of the available skills in the instructions.",
|
|
].join("\n")
|
|
|
|
export const toModelOutput = (skill: Skill.Info, files: ReadonlyArray<string>) => {
|
|
const directory = path.dirname(skill.location)
|
|
return [
|
|
`<skill_content name="${skill.name}">`,
|
|
`# Skill: ${skill.name}`,
|
|
"",
|
|
skill.content.trim(),
|
|
"",
|
|
`Base directory for this skill: ${directory}`,
|
|
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
|
"Note: file list is sampled.",
|
|
"",
|
|
"<skill_files>",
|
|
...files.map((file) => `<file>${file}</file>`),
|
|
"</skill_files>",
|
|
"</skill_content>",
|
|
].join("\n")
|
|
}
|
|
|
|
const unableToLoad = (name: string, error?: unknown) =>
|
|
new ToolFailure({ message: `Unable to load skill ${name}`, error })
|
|
|
|
export const Plugin = {
|
|
id: "opencode.tool.skill",
|
|
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: PluginContext) {
|
|
const fs = yield* FSUtil.Service
|
|
const skills = yield* Skill.Service
|
|
const permission = yield* Permission.Service
|
|
yield* ctx.tool
|
|
.transform((draft) =>
|
|
draft.add(
|
|
({
|
|
name,
|
|
options: { codemode: false },
|
|
description,
|
|
input: Input,
|
|
output: Output,
|
|
execute: (input, context) =>
|
|
Effect.gen(function* () {
|
|
const current = yield* skills.list()
|
|
const skill = current.find((skill) => skill.id === input.id)
|
|
if (!skill) return yield* unableToLoad(input.id)
|
|
return yield* Effect.gen(function* () {
|
|
yield* permission.assert({
|
|
action: name,
|
|
resources: [skill.id],
|
|
save: [skill.id],
|
|
sessionID: context.sessionID,
|
|
agent: context.agent,
|
|
source: { type: "tool", messageID: context.messageID, id: context.id },
|
|
})
|
|
const directory = path.dirname(skill.location)
|
|
const files =
|
|
path.basename(skill.location) === "SKILL.md"
|
|
? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
|
|
.filter((file) => path.basename(file) !== "SKILL.md")
|
|
.toSorted()
|
|
.slice(0, FILE_LIMIT)
|
|
: []
|
|
return {
|
|
name: skill.name,
|
|
directory,
|
|
output: toModelOutput(skill, files),
|
|
}
|
|
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
|
|
}).pipe(
|
|
Effect.map((output) => ({
|
|
output,
|
|
content: output.output,
|
|
metadata: { name: output.name, directory: output.directory },
|
|
})),
|
|
),
|
|
}),
|
|
),
|
|
)
|
|
.pipe(Effect.orDie)
|
|
}),
|
|
}
|