@yeyuan98/opencode-bioresearcher-plugin 1.2.4 → 1.3.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2 -0
- package/dist/skill/frontmatter.d.ts +2 -0
- package/dist/skill/frontmatter.js +65 -0
- package/dist/skill/index.d.ts +3 -0
- package/dist/skill/index.js +2 -0
- package/dist/skill/registry.d.ts +11 -0
- package/dist/skill/registry.js +64 -0
- package/dist/skill/tool.d.ts +9 -0
- package/dist/skill/tool.js +115 -0
- package/dist/skill/types.d.ts +22 -0
- package/dist/skill/types.js +7 -0
- package/dist/skill-tools/frontmatter.d.ts +2 -0
- package/dist/skill-tools/frontmatter.js +65 -0
- package/dist/skill-tools/index.d.ts +3 -0
- package/dist/skill-tools/index.js +2 -0
- package/dist/skill-tools/registry.d.ts +11 -0
- package/dist/skill-tools/registry.js +64 -0
- package/dist/skill-tools/tool.d.ts +9 -0
- package/dist/skill-tools/tool.js +115 -0
- package/dist/skill-tools/types.d.ts +22 -0
- package/dist/skill-tools/types.js +7 -0
- package/dist/skills/demo-skill/SKILL.md +41 -0
- package/dist/skills/demo-skill/demo_script.py +23 -0
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createBioResearcherDRWorkerAgent } from "./agents/bioresearcherDR_worke
|
|
|
4
4
|
import { tableTools } from "./table-tools/index";
|
|
5
5
|
import { blockingTimer, calculator } from "./misc-tools/index";
|
|
6
6
|
import { parse_pubmed_articleSet } from "./parser-tools/pubmed";
|
|
7
|
+
import { SkillTool } from "./skill-tools";
|
|
7
8
|
export const BioResearcherPlugin = async () => {
|
|
8
9
|
return {
|
|
9
10
|
config: async (config) => {
|
|
@@ -13,6 +14,7 @@ export const BioResearcherPlugin = async () => {
|
|
|
13
14
|
config.agent.bioresearcherDR_worker = createBioResearcherDRWorkerAgent();
|
|
14
15
|
},
|
|
15
16
|
tool: {
|
|
17
|
+
skill: SkillTool,
|
|
16
18
|
...tableTools,
|
|
17
19
|
blockingTimer,
|
|
18
20
|
calculator,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ExtendedSkillFrontmatter } from "./types";
|
|
2
|
+
const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
|
|
3
|
+
export async function parseSkillFrontmatter(filePath) {
|
|
4
|
+
const file = Bun.file(filePath);
|
|
5
|
+
const content = await file.text().catch(() => null);
|
|
6
|
+
if (!content)
|
|
7
|
+
return null;
|
|
8
|
+
const match = content.match(FRONTMATTER_REGEX);
|
|
9
|
+
if (!match)
|
|
10
|
+
return null;
|
|
11
|
+
const [, frontmatterYaml, body] = match;
|
|
12
|
+
let data;
|
|
13
|
+
try {
|
|
14
|
+
data = parseSimpleYaml(frontmatterYaml);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const parsed = ExtendedSkillFrontmatter.safeParse(data);
|
|
20
|
+
if (!parsed.success)
|
|
21
|
+
return null;
|
|
22
|
+
return {
|
|
23
|
+
frontmatter: parsed.data,
|
|
24
|
+
content: body.trim(),
|
|
25
|
+
location: filePath,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function parseSimpleYaml(yaml) {
|
|
29
|
+
const result = {};
|
|
30
|
+
let currentKey = "";
|
|
31
|
+
let currentArray = null;
|
|
32
|
+
for (const line of yaml.split("\n")) {
|
|
33
|
+
const trimmed = line.trimEnd();
|
|
34
|
+
if (!trimmed)
|
|
35
|
+
continue;
|
|
36
|
+
if (trimmed.startsWith(" - ")) {
|
|
37
|
+
if (currentArray !== null) {
|
|
38
|
+
currentArray.push(trimmed.slice(4));
|
|
39
|
+
}
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (currentArray !== null && currentKey) {
|
|
43
|
+
result[currentKey] = currentArray;
|
|
44
|
+
currentArray = null;
|
|
45
|
+
}
|
|
46
|
+
const colonIndex = trimmed.indexOf(":");
|
|
47
|
+
if (colonIndex === -1)
|
|
48
|
+
continue;
|
|
49
|
+
const key = trimmed.slice(0, colonIndex).trim();
|
|
50
|
+
const value = trimmed.slice(colonIndex + 1).trim();
|
|
51
|
+
if (value === "") {
|
|
52
|
+
currentKey = key;
|
|
53
|
+
currentArray = [];
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
result[key] = value;
|
|
57
|
+
currentKey = "";
|
|
58
|
+
currentArray = null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (currentArray !== null && currentKey) {
|
|
62
|
+
result[currentKey] = currentArray;
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ExtendedSkill } from "./types";
|
|
2
|
+
export declare class SkillConflictError extends Error {
|
|
3
|
+
skillName: string;
|
|
4
|
+
pluginLocation: string;
|
|
5
|
+
userLocation: string;
|
|
6
|
+
constructor(skillName: string, pluginLocation: string, userLocation: string);
|
|
7
|
+
}
|
|
8
|
+
export declare function loadPluginSkills(): Promise<ExtendedSkill[]>;
|
|
9
|
+
export declare function getAllSkills(): Promise<ExtendedSkill[]>;
|
|
10
|
+
export declare function getSkill(name: string): Promise<ExtendedSkill | undefined>;
|
|
11
|
+
export declare function clearCache(): void;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { parseSkillFrontmatter } from "./frontmatter";
|
|
4
|
+
const SKILL_GLOB = new Bun.Glob("**/SKILL.md");
|
|
5
|
+
export class SkillConflictError extends Error {
|
|
6
|
+
skillName;
|
|
7
|
+
pluginLocation;
|
|
8
|
+
userLocation;
|
|
9
|
+
constructor(skillName, pluginLocation, userLocation) {
|
|
10
|
+
super(`Skill name conflict: "${skillName}" is reserved by the plugin. ` +
|
|
11
|
+
`Please rename your skill at ${userLocation}.`);
|
|
12
|
+
this.skillName = skillName;
|
|
13
|
+
this.pluginLocation = pluginLocation;
|
|
14
|
+
this.userLocation = userLocation;
|
|
15
|
+
this.name = "SkillConflictError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function getPluginSkillsDir() {
|
|
19
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
return path.join(currentDir, "..", "skills");
|
|
21
|
+
}
|
|
22
|
+
export async function loadPluginSkills() {
|
|
23
|
+
const skillsDir = getPluginSkillsDir();
|
|
24
|
+
const skills = [];
|
|
25
|
+
try {
|
|
26
|
+
for await (const match of SKILL_GLOB.scan({
|
|
27
|
+
cwd: skillsDir,
|
|
28
|
+
absolute: true,
|
|
29
|
+
onlyFiles: true,
|
|
30
|
+
})) {
|
|
31
|
+
const parsed = await parseSkillFrontmatter(match);
|
|
32
|
+
if (!parsed)
|
|
33
|
+
continue;
|
|
34
|
+
skills.push({
|
|
35
|
+
name: parsed.frontmatter.name,
|
|
36
|
+
description: parsed.frontmatter.description,
|
|
37
|
+
location: parsed.location,
|
|
38
|
+
content: parsed.content,
|
|
39
|
+
agent: parsed.frontmatter.agent,
|
|
40
|
+
allowedTools: parsed.frontmatter.allowedTools,
|
|
41
|
+
source: "plugin",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Plugin skills directory may not exist
|
|
47
|
+
}
|
|
48
|
+
return skills;
|
|
49
|
+
}
|
|
50
|
+
let cachedSkills = null;
|
|
51
|
+
export async function getAllSkills() {
|
|
52
|
+
if (cachedSkills)
|
|
53
|
+
return cachedSkills;
|
|
54
|
+
const pluginSkills = await loadPluginSkills();
|
|
55
|
+
cachedSkills = pluginSkills;
|
|
56
|
+
return pluginSkills;
|
|
57
|
+
}
|
|
58
|
+
export async function getSkill(name) {
|
|
59
|
+
const skills = await getAllSkills();
|
|
60
|
+
return skills.find((s) => s.name === name);
|
|
61
|
+
}
|
|
62
|
+
export function clearCache() {
|
|
63
|
+
cachedSkills = null;
|
|
64
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { pathToFileURL } from "url";
|
|
3
|
+
import { tool } from "@opencode-ai/plugin";
|
|
4
|
+
import { getAllSkills, getSkill } from "./registry";
|
|
5
|
+
function formatDescription(skills) {
|
|
6
|
+
if (skills.length === 0) {
|
|
7
|
+
return "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available.";
|
|
8
|
+
}
|
|
9
|
+
return [
|
|
10
|
+
"Load a specialized skill that provides domain-specific instructions and workflows.",
|
|
11
|
+
"",
|
|
12
|
+
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
|
|
13
|
+
"",
|
|
14
|
+
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
|
|
15
|
+
"",
|
|
16
|
+
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
|
|
17
|
+
"",
|
|
18
|
+
"The following skills provide specialized sets of instructions for particular tasks:",
|
|
19
|
+
"",
|
|
20
|
+
"<available_skills>",
|
|
21
|
+
...skills.flatMap((skill) => [
|
|
22
|
+
` <skill>`,
|
|
23
|
+
` <name>${skill.name}</name>`,
|
|
24
|
+
` <description>${skill.description}</description>`,
|
|
25
|
+
skill.agent ? ` <restricted_to_agent>${skill.agent}</restricted_to_agent>` : null,
|
|
26
|
+
` <location>${pathToFileURL(skill.location).href}</location>`,
|
|
27
|
+
` </skill>`,
|
|
28
|
+
].filter(Boolean)),
|
|
29
|
+
"</available_skills>",
|
|
30
|
+
].join("\n");
|
|
31
|
+
}
|
|
32
|
+
async function listSkillFiles(dir, signal) {
|
|
33
|
+
const limit = 10;
|
|
34
|
+
const files = [];
|
|
35
|
+
const glob = new Bun.Glob("*");
|
|
36
|
+
try {
|
|
37
|
+
for await (const file of glob.scan({ cwd: dir, onlyFiles: true })) {
|
|
38
|
+
if (file === "SKILL.md")
|
|
39
|
+
continue;
|
|
40
|
+
files.push(path.resolve(dir, file));
|
|
41
|
+
if (files.length >= limit)
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Directory may not exist
|
|
47
|
+
}
|
|
48
|
+
return files.map((f) => `<file>${f}</file>`).join("\n");
|
|
49
|
+
}
|
|
50
|
+
export const SkillTool = tool({
|
|
51
|
+
description: "", // Set dynamically below
|
|
52
|
+
args: {
|
|
53
|
+
name: tool.schema.string().describe("The name of the skill from available_skills"),
|
|
54
|
+
},
|
|
55
|
+
async execute(params, ctx) {
|
|
56
|
+
const skills = await getAllSkills();
|
|
57
|
+
const skill = await getSkill(params.name);
|
|
58
|
+
if (!skill) {
|
|
59
|
+
const available = skills.map((s) => s.name).join(", ");
|
|
60
|
+
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`);
|
|
61
|
+
}
|
|
62
|
+
// Agent routing enforcement
|
|
63
|
+
if (skill.agent && ctx.agent !== skill.agent) {
|
|
64
|
+
throw new Error(`Skill "${skill.name}" is restricted to agent "${skill.agent}". Current agent: "${ctx.agent || "unknown"}".`);
|
|
65
|
+
}
|
|
66
|
+
await ctx.ask({
|
|
67
|
+
permission: "skill",
|
|
68
|
+
patterns: [params.name],
|
|
69
|
+
always: [params.name],
|
|
70
|
+
metadata: {},
|
|
71
|
+
});
|
|
72
|
+
const dir = path.dirname(skill.location);
|
|
73
|
+
const base = pathToFileURL(dir).href;
|
|
74
|
+
const files = await listSkillFiles(dir, ctx.abort);
|
|
75
|
+
const allowedToolsSection = skill.allowedTools
|
|
76
|
+
? [
|
|
77
|
+
"",
|
|
78
|
+
"<allowed_tools>",
|
|
79
|
+
"This skill is designed to work with the following tools:",
|
|
80
|
+
...skill.allowedTools.map((t) => ` - ${t}`),
|
|
81
|
+
"</allowed_tools>",
|
|
82
|
+
].join("\n")
|
|
83
|
+
: "";
|
|
84
|
+
ctx.metadata({
|
|
85
|
+
title: `Loaded skill: ${skill.name}`,
|
|
86
|
+
metadata: {
|
|
87
|
+
name: skill.name,
|
|
88
|
+
dir,
|
|
89
|
+
allowedTools: skill.allowedTools,
|
|
90
|
+
agent: skill.agent,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
return [
|
|
94
|
+
`<skill_content name="${skill.name}">`,
|
|
95
|
+
`# Skill: ${skill.name}`,
|
|
96
|
+
"",
|
|
97
|
+
skill.content.trim(),
|
|
98
|
+
"",
|
|
99
|
+
`Base directory for this skill: ${base}`,
|
|
100
|
+
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
|
101
|
+
"Note: file list is sampled.",
|
|
102
|
+
"",
|
|
103
|
+
"<skill_files>",
|
|
104
|
+
files,
|
|
105
|
+
"</skill_files>",
|
|
106
|
+
allowedToolsSection,
|
|
107
|
+
"</skill_content>",
|
|
108
|
+
].join("\n");
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
// Set description dynamically based on available skills
|
|
112
|
+
getAllSkills().then((skills) => {
|
|
113
|
+
;
|
|
114
|
+
SkillTool.description = formatDescription(skills);
|
|
115
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import z from "zod";
|
|
2
|
+
export declare const ExtendedSkillFrontmatter: z.ZodObject<{
|
|
3
|
+
name: z.ZodString;
|
|
4
|
+
description: z.ZodString;
|
|
5
|
+
agent: z.ZodOptional<z.ZodString>;
|
|
6
|
+
allowedTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
7
|
+
}, z.core.$strip>;
|
|
8
|
+
export type ExtendedSkillFrontmatter = z.infer<typeof ExtendedSkillFrontmatter>;
|
|
9
|
+
export interface ExtendedSkill {
|
|
10
|
+
name: string;
|
|
11
|
+
description: string;
|
|
12
|
+
location: string;
|
|
13
|
+
content: string;
|
|
14
|
+
agent?: string;
|
|
15
|
+
allowedTools?: string[];
|
|
16
|
+
source: "plugin";
|
|
17
|
+
}
|
|
18
|
+
export interface ParsedSkill {
|
|
19
|
+
frontmatter: ExtendedSkillFrontmatter;
|
|
20
|
+
content: string;
|
|
21
|
+
location: string;
|
|
22
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ExtendedSkillFrontmatter } from "./types";
|
|
2
|
+
const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
|
|
3
|
+
export async function parseSkillFrontmatter(filePath) {
|
|
4
|
+
const file = Bun.file(filePath);
|
|
5
|
+
const content = await file.text().catch(() => null);
|
|
6
|
+
if (!content)
|
|
7
|
+
return null;
|
|
8
|
+
const match = content.match(FRONTMATTER_REGEX);
|
|
9
|
+
if (!match)
|
|
10
|
+
return null;
|
|
11
|
+
const [, frontmatterYaml, body] = match;
|
|
12
|
+
let data;
|
|
13
|
+
try {
|
|
14
|
+
data = parseSimpleYaml(frontmatterYaml);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const parsed = ExtendedSkillFrontmatter.safeParse(data);
|
|
20
|
+
if (!parsed.success)
|
|
21
|
+
return null;
|
|
22
|
+
return {
|
|
23
|
+
frontmatter: parsed.data,
|
|
24
|
+
content: body.trim(),
|
|
25
|
+
location: filePath,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function parseSimpleYaml(yaml) {
|
|
29
|
+
const result = {};
|
|
30
|
+
let currentKey = "";
|
|
31
|
+
let currentArray = null;
|
|
32
|
+
for (const line of yaml.split("\n")) {
|
|
33
|
+
const trimmed = line.trimEnd();
|
|
34
|
+
if (!trimmed)
|
|
35
|
+
continue;
|
|
36
|
+
if (trimmed.startsWith(" - ")) {
|
|
37
|
+
if (currentArray !== null) {
|
|
38
|
+
currentArray.push(trimmed.slice(4));
|
|
39
|
+
}
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (currentArray !== null && currentKey) {
|
|
43
|
+
result[currentKey] = currentArray;
|
|
44
|
+
currentArray = null;
|
|
45
|
+
}
|
|
46
|
+
const colonIndex = trimmed.indexOf(":");
|
|
47
|
+
if (colonIndex === -1)
|
|
48
|
+
continue;
|
|
49
|
+
const key = trimmed.slice(0, colonIndex).trim();
|
|
50
|
+
const value = trimmed.slice(colonIndex + 1).trim();
|
|
51
|
+
if (value === "") {
|
|
52
|
+
currentKey = key;
|
|
53
|
+
currentArray = [];
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
result[key] = value;
|
|
57
|
+
currentKey = "";
|
|
58
|
+
currentArray = null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (currentArray !== null && currentKey) {
|
|
62
|
+
result[currentKey] = currentArray;
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ExtendedSkill } from "./types";
|
|
2
|
+
export declare class SkillConflictError extends Error {
|
|
3
|
+
skillName: string;
|
|
4
|
+
pluginLocation: string;
|
|
5
|
+
userLocation: string;
|
|
6
|
+
constructor(skillName: string, pluginLocation: string, userLocation: string);
|
|
7
|
+
}
|
|
8
|
+
export declare function loadPluginSkills(): Promise<ExtendedSkill[]>;
|
|
9
|
+
export declare function getAllSkills(): Promise<ExtendedSkill[]>;
|
|
10
|
+
export declare function getSkill(name: string): Promise<ExtendedSkill | undefined>;
|
|
11
|
+
export declare function clearCache(): void;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { parseSkillFrontmatter } from "./frontmatter";
|
|
4
|
+
const SKILL_GLOB = new Bun.Glob("**/SKILL.md");
|
|
5
|
+
export class SkillConflictError extends Error {
|
|
6
|
+
skillName;
|
|
7
|
+
pluginLocation;
|
|
8
|
+
userLocation;
|
|
9
|
+
constructor(skillName, pluginLocation, userLocation) {
|
|
10
|
+
super(`Skill name conflict: "${skillName}" is reserved by the plugin. ` +
|
|
11
|
+
`Please rename your skill at ${userLocation}.`);
|
|
12
|
+
this.skillName = skillName;
|
|
13
|
+
this.pluginLocation = pluginLocation;
|
|
14
|
+
this.userLocation = userLocation;
|
|
15
|
+
this.name = "SkillConflictError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function getPluginSkillsDir() {
|
|
19
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
return path.join(currentDir, "..", "skills");
|
|
21
|
+
}
|
|
22
|
+
export async function loadPluginSkills() {
|
|
23
|
+
const skillsDir = getPluginSkillsDir();
|
|
24
|
+
const skills = [];
|
|
25
|
+
try {
|
|
26
|
+
for await (const match of SKILL_GLOB.scan({
|
|
27
|
+
cwd: skillsDir,
|
|
28
|
+
absolute: true,
|
|
29
|
+
onlyFiles: true,
|
|
30
|
+
})) {
|
|
31
|
+
const parsed = await parseSkillFrontmatter(match);
|
|
32
|
+
if (!parsed)
|
|
33
|
+
continue;
|
|
34
|
+
skills.push({
|
|
35
|
+
name: parsed.frontmatter.name,
|
|
36
|
+
description: parsed.frontmatter.description,
|
|
37
|
+
location: parsed.location,
|
|
38
|
+
content: parsed.content,
|
|
39
|
+
agent: parsed.frontmatter.agent,
|
|
40
|
+
allowedTools: parsed.frontmatter.allowedTools,
|
|
41
|
+
source: "plugin",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Plugin skills directory may not exist
|
|
47
|
+
}
|
|
48
|
+
return skills;
|
|
49
|
+
}
|
|
50
|
+
let cachedSkills = null;
|
|
51
|
+
export async function getAllSkills() {
|
|
52
|
+
if (cachedSkills)
|
|
53
|
+
return cachedSkills;
|
|
54
|
+
const pluginSkills = await loadPluginSkills();
|
|
55
|
+
cachedSkills = pluginSkills;
|
|
56
|
+
return pluginSkills;
|
|
57
|
+
}
|
|
58
|
+
export async function getSkill(name) {
|
|
59
|
+
const skills = await getAllSkills();
|
|
60
|
+
return skills.find((s) => s.name === name);
|
|
61
|
+
}
|
|
62
|
+
export function clearCache() {
|
|
63
|
+
cachedSkills = null;
|
|
64
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { pathToFileURL } from "url";
|
|
3
|
+
import { tool } from "@opencode-ai/plugin";
|
|
4
|
+
import { getAllSkills, getSkill } from "./registry";
|
|
5
|
+
function formatDescription(skills) {
|
|
6
|
+
if (skills.length === 0) {
|
|
7
|
+
return "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available.";
|
|
8
|
+
}
|
|
9
|
+
return [
|
|
10
|
+
"Load a specialized skill that provides domain-specific instructions and workflows.",
|
|
11
|
+
"",
|
|
12
|
+
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
|
|
13
|
+
"",
|
|
14
|
+
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
|
|
15
|
+
"",
|
|
16
|
+
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
|
|
17
|
+
"",
|
|
18
|
+
"The following skills provide specialized sets of instructions for particular tasks:",
|
|
19
|
+
"",
|
|
20
|
+
"<available_skills>",
|
|
21
|
+
...skills.flatMap((skill) => [
|
|
22
|
+
` <skill>`,
|
|
23
|
+
` <name>${skill.name}</name>`,
|
|
24
|
+
` <description>${skill.description}</description>`,
|
|
25
|
+
skill.agent ? ` <restricted_to_agent>${skill.agent}</restricted_to_agent>` : null,
|
|
26
|
+
` <location>${pathToFileURL(skill.location).href}</location>`,
|
|
27
|
+
` </skill>`,
|
|
28
|
+
].filter(Boolean)),
|
|
29
|
+
"</available_skills>",
|
|
30
|
+
].join("\n");
|
|
31
|
+
}
|
|
32
|
+
async function listSkillFiles(dir, signal) {
|
|
33
|
+
const limit = 10;
|
|
34
|
+
const files = [];
|
|
35
|
+
const glob = new Bun.Glob("*");
|
|
36
|
+
try {
|
|
37
|
+
for await (const file of glob.scan({ cwd: dir, onlyFiles: true })) {
|
|
38
|
+
if (file === "SKILL.md")
|
|
39
|
+
continue;
|
|
40
|
+
files.push(path.resolve(dir, file));
|
|
41
|
+
if (files.length >= limit)
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Directory may not exist
|
|
47
|
+
}
|
|
48
|
+
return files.map((f) => `<file>${f}</file>`).join("\n");
|
|
49
|
+
}
|
|
50
|
+
export const SkillTool = tool({
|
|
51
|
+
description: "", // Set dynamically below
|
|
52
|
+
args: {
|
|
53
|
+
name: tool.schema.string().describe("The name of the skill from available_skills"),
|
|
54
|
+
},
|
|
55
|
+
async execute(params, ctx) {
|
|
56
|
+
const skills = await getAllSkills();
|
|
57
|
+
const skill = await getSkill(params.name);
|
|
58
|
+
if (!skill) {
|
|
59
|
+
const available = skills.map((s) => s.name).join(", ");
|
|
60
|
+
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`);
|
|
61
|
+
}
|
|
62
|
+
// Agent routing enforcement
|
|
63
|
+
if (skill.agent && ctx.agent !== skill.agent) {
|
|
64
|
+
throw new Error(`Skill "${skill.name}" is restricted to agent "${skill.agent}". Current agent: "${ctx.agent || "unknown"}".`);
|
|
65
|
+
}
|
|
66
|
+
await ctx.ask({
|
|
67
|
+
permission: "skill",
|
|
68
|
+
patterns: [params.name],
|
|
69
|
+
always: [params.name],
|
|
70
|
+
metadata: {},
|
|
71
|
+
});
|
|
72
|
+
const dir = path.dirname(skill.location);
|
|
73
|
+
const base = pathToFileURL(dir).href;
|
|
74
|
+
const files = await listSkillFiles(dir, ctx.abort);
|
|
75
|
+
const allowedToolsSection = skill.allowedTools
|
|
76
|
+
? [
|
|
77
|
+
"",
|
|
78
|
+
"<allowed_tools>",
|
|
79
|
+
"This skill is designed to work with the following tools:",
|
|
80
|
+
...skill.allowedTools.map((t) => ` - ${t}`),
|
|
81
|
+
"</allowed_tools>",
|
|
82
|
+
].join("\n")
|
|
83
|
+
: "";
|
|
84
|
+
ctx.metadata({
|
|
85
|
+
title: `Loaded skill: ${skill.name}`,
|
|
86
|
+
metadata: {
|
|
87
|
+
name: skill.name,
|
|
88
|
+
dir,
|
|
89
|
+
allowedTools: skill.allowedTools,
|
|
90
|
+
agent: skill.agent,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
return [
|
|
94
|
+
`<skill_content name="${skill.name}">`,
|
|
95
|
+
`# Skill: ${skill.name}`,
|
|
96
|
+
"",
|
|
97
|
+
skill.content.trim(),
|
|
98
|
+
"",
|
|
99
|
+
`Base directory for this skill: ${base}`,
|
|
100
|
+
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
|
101
|
+
"Note: file list is sampled.",
|
|
102
|
+
"",
|
|
103
|
+
"<skill_files>",
|
|
104
|
+
files,
|
|
105
|
+
"</skill_files>",
|
|
106
|
+
allowedToolsSection,
|
|
107
|
+
"</skill_content>",
|
|
108
|
+
].join("\n");
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
// Set description dynamically based on available skills
|
|
112
|
+
getAllSkills().then((skills) => {
|
|
113
|
+
;
|
|
114
|
+
SkillTool.description = formatDescription(skills);
|
|
115
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import z from "zod";
|
|
2
|
+
export declare const ExtendedSkillFrontmatter: z.ZodObject<{
|
|
3
|
+
name: z.ZodString;
|
|
4
|
+
description: z.ZodString;
|
|
5
|
+
agent: z.ZodOptional<z.ZodString>;
|
|
6
|
+
allowedTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
7
|
+
}, z.core.$strip>;
|
|
8
|
+
export type ExtendedSkillFrontmatter = z.infer<typeof ExtendedSkillFrontmatter>;
|
|
9
|
+
export interface ExtendedSkill {
|
|
10
|
+
name: string;
|
|
11
|
+
description: string;
|
|
12
|
+
location: string;
|
|
13
|
+
content: string;
|
|
14
|
+
agent?: string;
|
|
15
|
+
allowedTools?: string[];
|
|
16
|
+
source: "plugin";
|
|
17
|
+
}
|
|
18
|
+
export interface ParsedSkill {
|
|
19
|
+
frontmatter: ExtendedSkillFrontmatter;
|
|
20
|
+
content: string;
|
|
21
|
+
location: string;
|
|
22
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: demo-skill
|
|
3
|
+
description: Demo skill showcasing the plugin skill integration system
|
|
4
|
+
allowedTools:
|
|
5
|
+
- Bash
|
|
6
|
+
- Read
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Demo Skill
|
|
10
|
+
|
|
11
|
+
This skill demonstrates the plugin skill integration system.
|
|
12
|
+
|
|
13
|
+
## Features Demonstrated
|
|
14
|
+
|
|
15
|
+
1. **Skill Discovery** - This skill is discovered from `plugin/skills/`
|
|
16
|
+
2. **allowedTools** - Listed above (documentation only)
|
|
17
|
+
3. **Bundled Resources** - Files in this directory are accessible
|
|
18
|
+
|
|
19
|
+
## Test Resource Resolution
|
|
20
|
+
|
|
21
|
+
Run the bundled script to verify resources are properly resolved:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
python3 demo_script.py
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Expected output:
|
|
28
|
+
```
|
|
29
|
+
Demo Skill - Resource Resolution Test
|
|
30
|
+
=====================================
|
|
31
|
+
Skill directory: <path to skill>
|
|
32
|
+
Status: Resources resolved correctly!
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## How It Works
|
|
36
|
+
|
|
37
|
+
1. Plugin builds with `npm run build`
|
|
38
|
+
2. `skills/` directory copied to `dist/skills/`
|
|
39
|
+
3. Skill tool discovers all `SKILL.md` files
|
|
40
|
+
4. Agent calls `skill` tool with skill name
|
|
41
|
+
5. Skill content + file list returned to agent
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Demo script to verify skill resource resolution."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
print("Demo Skill - Resource Resolution Test")
|
|
10
|
+
print("=" * 40)
|
|
11
|
+
print(f"Script location: {os.path.abspath(__file__)}")
|
|
12
|
+
print(f"Python version: {sys.version.split()[0]}")
|
|
13
|
+
print()
|
|
14
|
+
print("Status: Resources resolved correctly!")
|
|
15
|
+
print()
|
|
16
|
+
print("This confirms:")
|
|
17
|
+
print(" - Skill files bundled at build time")
|
|
18
|
+
print(" - Resources discoverable via skill tool")
|
|
19
|
+
print(" - Scripts executable from skill directory")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yeyuan98/opencode-bioresearcher-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0-alpha.0",
|
|
4
4
|
"description": "OpenCode plugin that adds a bioresearcher agent",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"scripts": {
|
|
15
|
-
"build": "tsc",
|
|
15
|
+
"build": "tsc && node -e \"const fs=require('fs');fs.rmSync('dist/skills',{recursive:true,force:true});fs.cpSync('skills','dist/skills',{recursive:true})\"",
|
|
16
16
|
"typecheck": "tsc --noEmit"
|
|
17
17
|
},
|
|
18
18
|
"keywords": [
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"zod": "^4.1.8"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
+
"@types/bun": "^1.2.0",
|
|
39
40
|
"@types/node": "^20.0.0",
|
|
40
41
|
"typescript": "^5.9.3"
|
|
41
42
|
}
|