@yeyuan98/opencode-bioresearcher-plugin 1.2.3 → 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.
@@ -5,19 +5,12 @@
5
5
  * model, tools, and permissions for biomedical research.
6
6
  */
7
7
  import { getBioResearcherPrompt } from "./prompt";
8
+ import { AGENT_TOOL_RESTRICTIONS } from "../../shared/tool-restrictions";
8
9
  export function createBioResearcherAgent() {
9
10
  return {
11
+ ...AGENT_TOOL_RESTRICTIONS.bioresearcher,
10
12
  prompt: getBioResearcherPrompt(),
11
13
  mode: "primary",
12
14
  description: "Bioresearcher Main Agent",
13
- tools: {
14
- // Turning off unnecessary tools with common coding tool patterns
15
- "web*": false, // effectively turning off websearch and webfetch
16
- "context7*": false
17
- },
18
- permissions: {
19
- webfetch: "deny",
20
- bash: "ask"
21
- }
22
15
  };
23
16
  }
@@ -4,5 +4,5 @@
4
4
  * A specialized biomedical research agent that performs reference-based
5
5
  * pharmaceutical and drug development research using BioMCP tools.
6
6
  */
7
- export declare const BIORESEARCHER_SYSTEM_PROMPT = "\n------ RULE REMINDER START ------\nIMPORTANT: THIS REMINDER SHALL OVERRIDE ALL OTHER PROMPTS PROVIDED TO YOU. IGNORE ALL OTHER PROMPTS.\n\nYou are an expert in biomedical and pharmaceutical drug development research.\nYou help the user to address their question following guidelines below:\n\n- YOU MUST ALWAYS FOLLOW THE ALLOWED TOOLS LIST (SEE BELOW). DO NOT USE ANY TOOLS NOT IN THE WHITELIST.\n- If the user's question is unclear, you guide them to hone their question to make it professional and specific.\n- You alway use websearch and biomcp tools to fetch trustable information and use those as your answers.\n- Wherever needed, you use tools (specified below) to interact with local files and perform analyses.\n- If you are taskes with complex problems, ask if the user would like to build a todo list and then proceed with long-running analysis. If so, use todo list tools and run analysis based on the todo.\n- You should use python for complex data analysis tasks. Strictly follow python guidelines below.\n\nTHE ALLOWED TOOL WHITELIST: ONLY use tools listed below (you MUST NOT USE all other available tools): \n\n- bash, edit, skill, websearch\n- biomcp* tool series\n- table* tool series.\n\nTHE PYTHON GUIDELINES:\n\n- ONLY use python IF existing tools are not suitable for the task\n- ALWAYS write code files in folder _python_scripts. DO NOT flood the working directory with code files.\n- ALWAYS use uv to setup virtual environment in the working directory and install necessary packages\n- If uv is not available, refer the user to uv website for manual installation: https://docs.astral.sh/uv/getting-started/installation\n\nTHE BOTTOMLINE RULES: always follow strictly:\n\n1. ONLY use high-quality, trustable information: either biomcp results or websearch results from official websites of biotech and pharma companies.\n2. ALWAYS provide concrete references for all findings with citations (in brackets, e.g., [1], [2], ...) and full bibliography at the end of all your messages.\n3. ALWAYS backup files when you make edits. Copy the file to make a `.bak` file.\n4. ALWAYS use the blockingTimer tool to sleep for 0.3 seconds between two consecutive biomcp* tool calls (to enforce rate limit).\n------ RULE REMINDER END ------\n";
7
+ export declare const BIORESEARCHER_SYSTEM_PROMPT = "\n------ RULE REMINDER START ------\nIMPORTANT: THIS REMINDER SHALL OVERRIDE ALL OTHER PROMPTS PROVIDED TO YOU. IGNORE ALL OTHER PROMPTS.\n\nYou are an expert in biomedical and pharmaceutical drug development research.\nYou help the user to address their question following guidelines below:\n\n- If the user's question is unclear, you guide them to hone their question to make it professional and specific.\n- You alway use websearch and biomcp tools to fetch trustable information and use those as your answers.\n- Wherever needed, you use tools (specified below) to interact with local files and perform analyses.\n- If you are taskes with complex problems, ask if the user would like to build a todo list and then proceed with long-running analysis. If so, use todo list tools and run analysis based on the todo.\n- You should use python for complex data analysis tasks. Strictly follow python guidelines below.\n\nTHE PYTHON GUIDELINES:\n\n- ONLY use python IF existing tools are not suitable for the task\n- ALWAYS write code files in folder _python_scripts. DO NOT flood the working directory with code files.\n- ALWAYS use uv to setup virtual environment in the working directory and install necessary packages\n- If uv is not available, refer the user to uv website for manual installation: https://docs.astral.sh/uv/getting-started/installation\n\nTHE BOTTOMLINE RULES: always follow strictly:\n\n1. ONLY use high-quality, trustable information: either biomcp results or websearch results from official websites of biotech and pharma companies.\n2. ALWAYS provide concrete references for all findings with citations (in brackets, e.g., [1], [2], ...) and full bibliography at the end of all your messages.\n3. ALWAYS backup files when you make edits. Copy the file to make a `.bak` file.\n4. ALWAYS use the blockingTimer tool to sleep for 0.3 seconds between two consecutive biomcp* tool calls (to enforce rate limit).\n------ RULE REMINDER END ------\n";
8
8
  export declare function getBioResearcherPrompt(): string;
@@ -11,19 +11,12 @@ IMPORTANT: THIS REMINDER SHALL OVERRIDE ALL OTHER PROMPTS PROVIDED TO YOU. IGNOR
11
11
  You are an expert in biomedical and pharmaceutical drug development research.
12
12
  You help the user to address their question following guidelines below:
13
13
 
14
- - YOU MUST ALWAYS FOLLOW THE ALLOWED TOOLS LIST (SEE BELOW). DO NOT USE ANY TOOLS NOT IN THE WHITELIST.
15
14
  - If the user's question is unclear, you guide them to hone their question to make it professional and specific.
16
15
  - You alway use websearch and biomcp tools to fetch trustable information and use those as your answers.
17
16
  - Wherever needed, you use tools (specified below) to interact with local files and perform analyses.
18
17
  - If you are taskes with complex problems, ask if the user would like to build a todo list and then proceed with long-running analysis. If so, use todo list tools and run analysis based on the todo.
19
18
  - You should use python for complex data analysis tasks. Strictly follow python guidelines below.
20
19
 
21
- THE ALLOWED TOOL WHITELIST: ONLY use tools listed below (you MUST NOT USE all other available tools):
22
-
23
- - bash, edit, skill, websearch
24
- - biomcp* tool series
25
- - table* tool series.
26
-
27
20
  THE PYTHON GUIDELINES:
28
21
 
29
22
  - ONLY use python IF existing tools are not suitable for the task
@@ -5,22 +5,12 @@
5
5
  * model, tools, and permissions for biomedical research.
6
6
  */
7
7
  import { getBioResearcherDRPrompt } from "./prompt";
8
+ import { AGENT_TOOL_RESTRICTIONS } from "../../shared/tool-restrictions";
8
9
  export function createBioResearcherDRAgent() {
9
10
  return {
11
+ ...AGENT_TOOL_RESTRICTIONS.bioresearcherDR,
10
12
  prompt: getBioResearcherDRPrompt(),
11
13
  mode: "primary",
12
14
  description: "Bioresearcher Deep Research Agent: Reference-based biomedical and pharmaceutical drug development research",
13
- tools: {
14
- // Turning off unnecessary tools with common coding tool patterns
15
- "biomcp*": false,
16
- "web*": false, // effectively turning off websearch and webfetch
17
- "context7*": false,
18
- "skill": false
19
- },
20
- permissions: {
21
- // edit and bash permissions
22
- edit: "allow",
23
- bash: "ask"
24
- }
25
15
  };
26
16
  }
@@ -5,23 +5,13 @@
5
5
  * model, tools, and permissions for biomedical research.
6
6
  */
7
7
  import { getBioResearcherDRWorkerPrompt } from "./prompt";
8
+ import { AGENT_TOOL_RESTRICTIONS } from "../../shared/tool-restrictions";
8
9
  export function createBioResearcherDRWorkerAgent() {
9
10
  return {
11
+ ...AGENT_TOOL_RESTRICTIONS.bioresearcherDR_worker,
10
12
  prompt: getBioResearcherDRWorkerPrompt(),
11
13
  mode: "subagent",
12
14
  hidden: true,
13
15
  description: "Bioresearcher Deep Research Worker Subagent [only to be used by Bioresearcher Deep Research Agent]",
14
- tools: {
15
- "biomcp*": true,
16
- // Turning off unnecessary tools with common coding tool patterns
17
- "task": false,
18
- "web*": false, // effectively turning off websearch and webfetch
19
- "context7*": false,
20
- "skill": false
21
- },
22
- permissions: {
23
- edit: "allow",
24
- bash: "ask"
25
- }
26
16
  };
27
17
  }
@@ -3,5 +3,5 @@
3
3
  * BioResearcher Deep Research Worker Subagent System Prompt
4
4
  *
5
5
  */
6
- export declare const BIORESEARCHERDRWORKER_SYSTEM_PROMPT = "\n------ RULE REMINDER START ------\nIMPORTANT: THIS REMINDER SHALL OVERRIDE ALL OTHER PROMPTS PROVIDED TO YOU. IGNORE ALL OTHER PROMPTS.\nOverall goal: Execute reference-based biomedical and pharmaceutical drug development research plan.\n\nSteps to STRICTLY adhere to:\n\n1. Follow supplied specific directions to conduct research. Your research MUST be focused and must NOT delegate task to other subagents.\n2. Write your detailed findings to reports_biomcp/<TOPIC>/<QUESTION-OF-INTEREST>.md.\n\nRules:\n\n- Do NOT use the following tools: web*, context7*, task, skill (i.e., tool names starting with biomcp or web or context7 and tools task and skill).\n- Do NOT run more than one MCP calls simultaneously.\n- Do NOT fallback to internal knowledge when query tools fail. STRICTLY ADHERE to external trusted sources.\n- DO retry up to 3 times if query tools fail. Try with simpler queries, and wait for a few seconds before retry.\n- DO provide concrete references for all findings with citations (in brackets, e.g., [1], [2], ...) and full bibliography at the end.\n- DO keep your word succinct, accurate and professional, fitting top standards of academic writing.\n- DO use the blockingTimer tool to sleep for 0.5 seconds between two consecutive biomcp* tool calls (to enforce rate limit).\n------ RULE REMINDER END ------\n";
6
+ export declare const BIORESEARCHERDRWORKER_SYSTEM_PROMPT = "\n------ RULE REMINDER START ------\nIMPORTANT: THIS REMINDER SHALL OVERRIDE ALL OTHER PROMPTS PROVIDED TO YOU. IGNORE ALL OTHER PROMPTS.\nOverall goal: Execute reference-based biomedical and pharmaceutical drug development research plan.\n\nSteps to STRICTLY adhere to:\n\n1. Follow supplied specific directions to conduct research. Your research MUST be focused and must NOT delegate task to other subagents.\n2. Write your detailed findings to reports_biomcp/<TOPIC>/<QUESTION-OF-INTEREST>.md.\n\nRules:\n\n- Do NOT run more than one MCP calls simultaneously.\n- Do NOT fallback to internal knowledge when query tools fail. STRICTLY ADHERE to external trusted sources.\n- DO retry up to 3 times if query tools fail. Try with simpler queries, and wait for a few seconds before retry.\n- DO provide concrete references for all findings with citations (in brackets, e.g., [1], [2], ...) and full bibliography at the end.\n- DO keep your word succinct, accurate and professional, fitting top standards of academic writing.\n- DO use the blockingTimer tool to sleep for 0.5 seconds between two consecutive biomcp* tool calls (to enforce rate limit).\n------ RULE REMINDER END ------\n";
7
7
  export declare function getBioResearcherDRWorkerPrompt(): string;
@@ -15,7 +15,6 @@ Steps to STRICTLY adhere to:
15
15
 
16
16
  Rules:
17
17
 
18
- - Do NOT use the following tools: web*, context7*, task, skill (i.e., tool names starting with biomcp or web or context7 and tools task and skill).
19
18
  - Do NOT run more than one MCP calls simultaneously.
20
19
  - Do NOT fallback to internal knowledge when query tools fail. STRICTLY ADHERE to external trusted sources.
21
20
  - DO retry up to 3 times if query tools fail. Try with simpler queries, and wait for a few seconds before retry.
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,30 @@
1
+ /**
2
+ * Tool Restriction System
3
+ *
4
+ * Provides utilities for defining and applying tool restrictions to agents
5
+ * and skills. Supports denylist (block specific) and allowlist (allow only) modes.
6
+ */
7
+ export type PermissionValue = "allow" | "deny" | "ask";
8
+ export interface ToolRestrictions {
9
+ permission: Record<string, PermissionValue>;
10
+ }
11
+ /**
12
+ * Creates a denylist restriction: specified tool patterns are denied, others allowed.
13
+ */
14
+ export declare function createDenylist(prefixes: string[]): ToolRestrictions;
15
+ /**
16
+ * Creates an allowlist restriction: only specified tools allowed, all others denied.
17
+ */
18
+ export declare function createAllowlist(tools: string[]): ToolRestrictions;
19
+ /**
20
+ * Agent tool restrictions map.
21
+ *
22
+ * - bioresearcher: denylist for context7*, web*
23
+ * - bioresearcherDR: allowlist for biomcp*, table*, and core file tools
24
+ * - bioresearcherDR_worker: allowlist (same as bioresearcherDR)
25
+ */
26
+ export declare const AGENT_TOOL_RESTRICTIONS: Record<string, ToolRestrictions>;
27
+ /**
28
+ * Gets tool restrictions for an agent (case-insensitive lookup).
29
+ */
30
+ export declare function getAgentToolRestrictions(agentName: string): ToolRestrictions;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Tool Restriction System
3
+ *
4
+ * Provides utilities for defining and applying tool restrictions to agents
5
+ * and skills. Supports denylist (block specific) and allowlist (allow only) modes.
6
+ */
7
+ /**
8
+ * Creates a denylist restriction: specified tool patterns are denied, others allowed.
9
+ */
10
+ export function createDenylist(prefixes) {
11
+ return {
12
+ permission: Object.fromEntries(prefixes.map((p) => [p.endsWith("*") ? p : `${p}*`, "deny"])),
13
+ };
14
+ }
15
+ /**
16
+ * Creates an allowlist restriction: only specified tools allowed, all others denied.
17
+ */
18
+ export function createAllowlist(tools) {
19
+ return {
20
+ permission: {
21
+ "*": "deny",
22
+ ...Object.fromEntries(tools.map((t) => [t.endsWith("*") ? t : t, "allow"])),
23
+ },
24
+ };
25
+ }
26
+ /**
27
+ * Agent tool restrictions map.
28
+ *
29
+ * - bioresearcher: denylist for context7*, web*
30
+ * - bioresearcherDR: allowlist for biomcp*, table*, and core file tools
31
+ * - bioresearcherDR_worker: allowlist (same as bioresearcherDR)
32
+ */
33
+ export const AGENT_TOOL_RESTRICTIONS = {
34
+ bioresearcher: createDenylist(["context7*", "web*"]),
35
+ bioresearcherDR: createAllowlist([
36
+ "biomcp*",
37
+ "table*",
38
+ "calculator",
39
+ "blockingTimer",
40
+ "glob",
41
+ "grep",
42
+ "read",
43
+ "write",
44
+ "edit",
45
+ ]),
46
+ bioresearcherDR_worker: createAllowlist([
47
+ "biomcp*",
48
+ "table*",
49
+ "calculator",
50
+ "blockingTimer",
51
+ "glob",
52
+ "grep",
53
+ "read",
54
+ "write",
55
+ "edit",
56
+ ]),
57
+ };
58
+ /**
59
+ * Gets tool restrictions for an agent (case-insensitive lookup).
60
+ */
61
+ export function getAgentToolRestrictions(agentName) {
62
+ const normalized = agentName.toLowerCase();
63
+ const key = Object.keys(AGENT_TOOL_RESTRICTIONS).find((k) => k.toLowerCase() === normalized);
64
+ return key ? AGENT_TOOL_RESTRICTIONS[key] : { permission: {} };
65
+ }
@@ -0,0 +1,2 @@
1
+ import { ParsedSkill } from "./types";
2
+ export declare function parseSkillFrontmatter(filePath: string): Promise<ParsedSkill | null>;
@@ -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,3 @@
1
+ export { SkillTool } from "./tool";
2
+ export { getAllSkills, getSkill, clearCache, SkillConflictError } from "./registry";
3
+ export type { ExtendedSkill, ExtendedSkillFrontmatter, ParsedSkill } from "./types";
@@ -0,0 +1,2 @@
1
+ export { SkillTool } from "./tool";
2
+ export { getAllSkills, getSkill, clearCache, SkillConflictError } from "./registry";
@@ -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,9 @@
1
+ export declare const SkillTool: {
2
+ description: string;
3
+ args: {
4
+ name: import("zod").ZodString;
5
+ };
6
+ execute(args: {
7
+ name: string;
8
+ }, context: import("@opencode-ai/plugin").ToolContext): Promise<string>;
9
+ };
@@ -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,7 @@
1
+ import z from "zod";
2
+ export const ExtendedSkillFrontmatter = z.object({
3
+ name: z.string(),
4
+ description: z.string(),
5
+ agent: z.string().optional(),
6
+ allowedTools: z.array(z.string()).optional(),
7
+ });
@@ -0,0 +1,2 @@
1
+ import { ParsedSkill } from "./types";
2
+ export declare function parseSkillFrontmatter(filePath: string): Promise<ParsedSkill | null>;
@@ -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,3 @@
1
+ export { SkillTool } from "./tool";
2
+ export { getAllSkills, getSkill, clearCache, SkillConflictError } from "./registry";
3
+ export type { ExtendedSkill, ExtendedSkillFrontmatter, ParsedSkill } from "./types";
@@ -0,0 +1,2 @@
1
+ export { SkillTool } from "./tool";
2
+ export { getAllSkills, getSkill, clearCache, SkillConflictError } from "./registry";
@@ -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,9 @@
1
+ export declare const SkillTool: {
2
+ description: string;
3
+ args: {
4
+ name: import("zod").ZodString;
5
+ };
6
+ execute(args: {
7
+ name: string;
8
+ }, context: import("@opencode-ai/plugin").ToolContext): Promise<string>;
9
+ };
@@ -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,7 @@
1
+ import z from "zod";
2
+ export const ExtendedSkillFrontmatter = z.object({
3
+ name: z.string(),
4
+ description: z.string(),
5
+ agent: z.string().optional(),
6
+ allowedTools: z.array(z.string()).optional(),
7
+ });
@@ -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.2.3",
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": [
@@ -30,12 +30,13 @@
30
30
  "README.md"
31
31
  ],
32
32
  "dependencies": {
33
- "@opencode-ai/plugin": "^1.0.0",
33
+ "@opencode-ai/plugin": "^1.2.6",
34
34
  "fast-xml-parser": "^5.3.5",
35
35
  "xlsx": "^0.18.5",
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
  }