opencode-froggy 0.10.2 → 0.12.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/README.md CHANGED
@@ -78,6 +78,7 @@ Alternatively, clone or copy the plugin files to one of these directories:
78
78
  | `/commit-push` | Stage, commit, and push changes with user confirmation | `build` |
79
79
  | `/diff-summary [source] [target]` | Show working tree changes or diff between branches | - |
80
80
  | `/doc-changes` | Update documentation based on uncommitted changes (new features only) | `doc-writer` |
81
+ | `/linear-stale-check` | Review open Linear issues for the configured team and report whether they are likely active, uncertain, or obsolete | `plan` |
81
82
  | `/review-changes` | Review uncommitted changes (staged, unstaged, untracked) | `code-reviewer` |
82
83
  | `/review-pr <source> <target>` | Review diff from source branch into target branch | `code-reviewer` |
83
84
  | `/send-to [agent] <message>` | Send a message to a child session (subagent) to continue the conversation | - |
@@ -104,6 +105,24 @@ Shows staged changes, unstaged changes, and untracked file contents.
104
105
  ```
105
106
  Shows stats overview, commits, files changed, and full diff between branches.
106
107
 
108
+ ### /linear-stale-check
109
+
110
+ Analyzes open Linear issues for the team configured in `AGENTS.md` with:
111
+
112
+ ```text
113
+ Linear team: <name>
114
+ ```
115
+
116
+ If no team is configured, the command lists available Linear teams and asks which one to use for the current run.
117
+
118
+ The command is read-only. It fetches open issues, investigates related code, skips code investigation for issues already `In Progress`, and outputs a single report grouped by verdict:
119
+
120
+ - 🔴 Likely obsolete
121
+ - 🟡 Uncertain
122
+ - 🟢 Likely active
123
+
124
+ It does not modify Linear issues, add comments, or update project files.
125
+
107
126
  ---
108
127
 
109
128
  ## Agents
@@ -0,0 +1,199 @@
1
+ ---
2
+ description: Review open Linear issues to check if they are still relevant
3
+ agent: plan
4
+ ---
5
+
6
+ ## Your task
7
+
8
+ Analyze the team's open Linear issues, diagnose each one by combining Linear
9
+ metadata with codebase investigation, and present a single report.
10
+
11
+ This command is read-only:
12
+ - Do not ask the user questions except when `AGENTS.md` does not define a
13
+ Linear team.
14
+ - Do not modify any Linear issue.
15
+ - Do not add comments to Linear issues.
16
+ - Do not modify `AGENTS.md` or any other project file.
17
+ - Do not start an iterative review loop.
18
+
19
+ The user will read the report and decide what to do next.
20
+
21
+ ## 1. Resolve the Linear team
22
+
23
+ Read `AGENTS.md` from the current project and look for a plain-text line:
24
+
25
+ ```text
26
+ Linear team: <name>
27
+ ```
28
+
29
+ If the line exists, use `<name>` as the Linear team.
30
+
31
+ If the line does not exist:
32
+ - Use `linear_list_teams` to list available teams.
33
+ - Ask the user to choose one of the available teams with the `question` tool.
34
+ - Use the selected team for this run.
35
+ - Continue the stale-check report normally.
36
+ - Do not write to `AGENTS.md` yourself.
37
+
38
+ ## 2. Fetch open issues
39
+
40
+ Use `linear_list_issues` for the resolved team:
41
+ - `team`: resolved Linear team name
42
+ - omit `state` entirely; do not pass `uncompleted`, `backlog`, `unstarted`,
43
+ `started`, an empty string, or any other status-type alias
44
+ - omit `priority` entirely; do not pass `priority: 0` because that filters to
45
+ issues with no priority
46
+ - `orderBy`: `updatedAt`, oldest first
47
+ - fetch all matching issues by paginating until there are no more pages
48
+
49
+ Only pass filters that are intentionally active. For unused filters, omit the
50
+ field entirely instead of passing an empty, zero, or placeholder value. In
51
+ particular, `priority: 0` is an active Linear filter for "No priority" and
52
+ will exclude Medium, High, and Urgent issues.
53
+
54
+ After fetching all team issues, filter locally:
55
+ - keep issues whose `statusType` is not `completed`
56
+ - keep issues whose `statusType` is not `canceled`
57
+
58
+ Do not rely on Linear state-type aliases for this command. They can return
59
+ incomplete results. Fetch broadly for the team, then filter locally by
60
+ `statusType`.
61
+
62
+ For each retained issue, load detailed data with `linear_get_issue` and include relations:
63
+ - title
64
+ - description
65
+ - URL
66
+ - state
67
+ - priority
68
+ - assignee
69
+ - labels
70
+ - project
71
+ - createdAt
72
+ - updatedAt
73
+ - blocking and blocked-by relations
74
+
75
+ ## 3. Diagnose each issue
76
+
77
+ Diagnose every fetched issue before producing the final report.
78
+
79
+ ### Linear signals
80
+
81
+ Compute these signals for every issue:
82
+ - Days since last update.
83
+ - No assignee.
84
+ - No project.
85
+ - No useful description.
86
+ - `In Progress` but inactive for more than 30 days.
87
+ - Blockers are already closed or canceled.
88
+
89
+ ### Code investigation
90
+
91
+ Run code investigation for every issue except issues whose state is exactly
92
+ `In Progress`.
93
+
94
+ For `In Progress` issues, set code signals to:
95
+
96
+ ```text
97
+ skipped (in progress)
98
+ ```
99
+
100
+ For all other issues, investigate the codebase directly with read-only search
101
+ tools and git history commands. Run searches in parallel batches when possible.
102
+
103
+ For each issue, use:
104
+ - the Linear identifier
105
+ - the title
106
+ - a concise description excerpt
107
+ - candidate keywords from the title and description
108
+
109
+ Candidate keywords should include:
110
+ - mentioned files or modules
111
+ - function, class, type, command, hook, or config names
112
+ - the Linear identifier itself
113
+ - meaningful product or domain terms
114
+
115
+ Search for relevance signals and keep the result to 3-5 concise lines:
116
+ - Does the mentioned code or feature still exist?
117
+ - Is the Linear identifier referenced in code, comments, TODOs, branches, or recent commits?
118
+ - Is there recent git activity on related files or symbols?
119
+ - Are there signs the feature was removed, renamed, or replaced?
120
+
121
+ Summarize the returned investigation as code signals:
122
+ - code or feature found
123
+ - code or feature not found
124
+ - identifier referenced
125
+ - recent git activity found
126
+ - no recent git activity found
127
+ - no code correlation found
128
+
129
+ ## 4. Assign a verdict
130
+
131
+ Assign exactly one verdict per issue:
132
+
133
+ - 🔴 Likely obsolete
134
+ - 🟡 Uncertain
135
+ - 🟢 Likely active
136
+
137
+ Use these rules as guidance, but apply judgment based on the full evidence.
138
+
139
+ For `In Progress` issues, base the verdict on update age only:
140
+ - updated less than 30 days ago: 🟢 Likely active
141
+ - updated 30-90 days ago: 🟡 Uncertain
142
+ - updated more than 90 days ago: 🔴 Likely obsolete
143
+
144
+ For other issues:
145
+ - Use 🟢 Likely active when the issue is referenced in code, has recent related git activity, or clearly describes current code.
146
+ - Use 🟡 Uncertain when signals are mixed or weak.
147
+ - Use 🔴 Likely obsolete when mentioned code is missing, the feature appears removed or replaced, or the issue has weak Linear signals and no code correlation.
148
+
149
+ ## 5. Output a single report
150
+
151
+ Output one report only. Sort by verdict in this order:
152
+ 1. 🔴 Likely obsolete
153
+ 2. 🟡 Uncertain
154
+ 3. 🟢 Likely active
155
+
156
+ Use this structure:
157
+
158
+ ```markdown
159
+ # Linear Stale-Check Report
160
+
161
+ Team: <team>
162
+ Issues analyzed: <count>
163
+
164
+ ## 🔴 Likely Obsolete (<count>)
165
+
166
+ ### LIN-123 — Issue title
167
+
168
+ URL: <Linear URL>
169
+ State: <state> | Priority: <priority> | Assignee: <assignee or none> | Updated: <N>d ago
170
+ Project: <project or none> | Labels: <labels or none>
171
+
172
+ Summary: <1-2 line summary of the issue>
173
+
174
+ Linear signals:
175
+ - <signal>
176
+ - <signal>
177
+
178
+ Code signals:
179
+ - <signal>
180
+ - <signal>
181
+
182
+ Verdict: 🔴 Likely obsolete — <short reason>
183
+
184
+ ## 🟡 Uncertain (<count>)
185
+
186
+ ...
187
+
188
+ ## 🟢 Likely Active (<count>)
189
+
190
+ ...
191
+ ```
192
+
193
+ Keep each issue concise. Prefer useful evidence over speculation.
194
+
195
+ End the report with:
196
+
197
+ ```text
198
+ End of report. No issues were modified.
199
+ ```
package/dist/index.js CHANGED
@@ -5,8 +5,7 @@ import { getGlobalHookDir, getProjectHookDir } from "./config-paths";
5
5
  import { hasCodeExtension } from "./code-files";
6
6
  import { log } from "./logger";
7
7
  import { executeBashAction, DEFAULT_BASH_TIMEOUT, } from "./bash-executor";
8
- import { gitingestTool, pdfToMarkdownTool, createPromptSessionTool, createListChildSessionsTool, createAgentPromoteTool, createSkillTool, formatPluginSkillsAsXmlItems, getPromotedAgents, ethTransactionTool, ethAddressTxsTool, ethAddressBalanceTool, ethTokenTransfersTool, } from "./tools";
9
- import { injectPluginSkillsIntoSystem } from "./skill-injection";
8
+ import { gitingestTool, pdfToMarkdownTool, createPromptSessionTool, createListChildSessionsTool, createAgentPromoteTool, getPromotedAgents, ethTransactionTool, ethAddressTxsTool, ethAddressBalanceTool, ethTokenTransfersTool, } from "./tools";
10
9
  export { parseFrontmatter, loadAgents, loadCommands } from "./loaders";
11
10
  export { buildSkillActivationBlock } from "./skill-activation";
12
11
  import { buildSkillActivationBlock } from "./skill-activation";
@@ -35,15 +34,6 @@ const SmartfrogPlugin = async (ctx) => {
35
34
  const skillActivationBlock = skillsWithTriggers.length > 0
36
35
  ? buildSkillActivationBlock(skillsWithTriggers)
37
36
  : null;
38
- const skillTool = createSkillTool({
39
- pluginSkills: skills,
40
- pluginDir: PLUGIN_ROOT,
41
- cwd: ctx.directory,
42
- client: ctx.client,
43
- });
44
- const pluginSkillsXmlItems = skills.length > 0
45
- ? formatPluginSkillsAsXmlItems(skills, PLUGIN_ROOT)
46
- : null;
47
37
  log("[init] Plugin loaded", {
48
38
  agents: Object.keys(agents),
49
39
  commands: Object.keys(commands),
@@ -53,7 +43,6 @@ const SmartfrogPlugin = async (ctx) => {
53
43
  tools: [
54
44
  "gitingest",
55
45
  "pdf-to-markdown",
56
- "skill",
57
46
  "agent-promote",
58
47
  "eth-transaction",
59
48
  "eth-address-txs",
@@ -208,11 +197,20 @@ const SmartfrogPlugin = async (ctx) => {
208
197
  if (Object.keys(commands).length > 0) {
209
198
  config.command = { ...(config.command ?? {}), ...commands };
210
199
  }
200
+ if (skills.length > 0) {
201
+ const existingSkills = config.skills ?? {};
202
+ const existingPaths = Array.isArray(existingSkills.paths) ? existingSkills.paths : [];
203
+ config.skills = {
204
+ ...existingSkills,
205
+ paths: existingPaths.includes(SKILL_DIR)
206
+ ? existingPaths
207
+ : [...existingPaths, SKILL_DIR],
208
+ };
209
+ }
211
210
  },
212
211
  tool: {
213
212
  gitingest: gitingestTool,
214
213
  "pdf-to-markdown": pdfToMarkdownTool,
215
- skill: skillTool,
216
214
  "prompt-session": createPromptSessionTool(ctx.client),
217
215
  "list-child-sessions": createListChildSessionsTool(ctx.client),
218
216
  "agent-promote": createAgentPromoteTool(ctx.client, Object.keys(agents)),
@@ -289,7 +287,8 @@ const SmartfrogPlugin = async (ctx) => {
289
287
  }
290
288
  },
291
289
  "experimental.chat.system.transform": async (_input, output) => {
292
- injectPluginSkillsIntoSystem(output.system, pluginSkillsXmlItems);
290
+ // The activation block relies on OpenCode's native `skill` tool after we
291
+ // expose the plugin's bundled skills through `config.skills.paths`.
293
292
  if (skillActivationBlock) {
294
293
  output.system.push(skillActivationBlock);
295
294
  }
@@ -4,5 +4,4 @@ export { pdfToMarkdownTool } from "./pdf-to-markdown";
4
4
  export { createPromptSessionTool, type PromptSessionArgs } from "./prompt-session";
5
5
  export { createListChildSessionsTool } from "./list-child-sessions";
6
6
  export { createAgentPromoteTool, getPromotedAgents, type AgentPromoteArgs } from "./agent-promote";
7
- export { createSkillTool, discoverAllSkills, formatPluginSkillsAsXmlItems, type CreateSkillToolOptions, type DiscoverAllSkillsOptions, type SkillInfo, type SkillScope, } from "./skill";
8
7
  export { ethTransactionTool, ethAddressTxsTool, ethAddressBalanceTool, ethTokenTransfersTool, EtherscanClient, EtherscanClientError, weiToEth, formatTimestamp, shortenAddress, type EthTransactionArgs, type EthAddressTxsArgs, type EthAddressBalanceArgs, type EthTokenTransfersArgs, } from "./blockchain";
@@ -4,5 +4,4 @@ export { pdfToMarkdownTool } from "./pdf-to-markdown";
4
4
  export { createPromptSessionTool } from "./prompt-session";
5
5
  export { createListChildSessionsTool } from "./list-child-sessions";
6
6
  export { createAgentPromoteTool, getPromotedAgents } from "./agent-promote";
7
- export { createSkillTool, discoverAllSkills, formatPluginSkillsAsXmlItems, } from "./skill";
8
7
  export { ethTransactionTool, ethAddressTxsTool, ethAddressBalanceTool, ethTokenTransfersTool, EtherscanClient, EtherscanClientError, weiToEth, formatTimestamp, shortenAddress, } from "./blockchain";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-froggy",
3
- "version": "0.10.2",
3
+ "version": "0.12.0",
4
4
  "description": "OpenCode plugin with a hook layer (tool.before.*, session.idle...), agents (code-reviewer, doc-writer), and commands (/review-pr, /commit)",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1 +0,0 @@
1
- export declare function injectPluginSkillsIntoSystem(system: string[], pluginSkillsXmlItems: string | null): void;
@@ -1,13 +0,0 @@
1
- const AVAILABLE_SKILLS_BLOCK_REGEX = /(<available_skills>[\s\S]*?)(<\/available_skills>)/;
2
- export function injectPluginSkillsIntoSystem(system, pluginSkillsXmlItems) {
3
- if (!pluginSkillsXmlItems)
4
- return;
5
- for (let i = 0; i < system.length; i++) {
6
- const segment = system[i];
7
- if (AVAILABLE_SKILLS_BLOCK_REGEX.test(segment)) {
8
- system[i] = segment.replace(AVAILABLE_SKILLS_BLOCK_REGEX, `$1${pluginSkillsXmlItems}\n$2`);
9
- return;
10
- }
11
- }
12
- system.push(`<available_skills>\n${pluginSkillsXmlItems}\n</available_skills>`);
13
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,60 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { injectPluginSkillsIntoSystem } from "./skill-injection";
3
- const SAMPLE_ITEMS = ` <skill>\n <name>tdd</name>\n <description>Apply TDD</description>\n </skill>`;
4
- describe("injectPluginSkillsIntoSystem", () => {
5
- it("should do nothing when pluginSkillsXmlItems is null", () => {
6
- const system = ["some prompt", "<available_skills>\n <skill><name>x</name></skill>\n</available_skills>"];
7
- const before = [...system];
8
- injectPluginSkillsIntoSystem(system, null);
9
- expect(system).toEqual(before);
10
- });
11
- it("should inject items inside existing <available_skills> block", () => {
12
- const system = [
13
- "header text",
14
- "<available_skills>\n <skill>\n <name>x</name>\n <description>d</description>\n </skill>\n</available_skills>",
15
- "footer text",
16
- ];
17
- injectPluginSkillsIntoSystem(system, SAMPLE_ITEMS);
18
- expect(system[0]).toBe("header text");
19
- expect(system[2]).toBe("footer text");
20
- expect(system[1]).toContain("<name>x</name>");
21
- expect(system[1]).toContain("<name>tdd</name>");
22
- const openCount = (system[1].match(/<available_skills>/g) ?? []).length;
23
- const closeCount = (system[1].match(/<\/available_skills>/g) ?? []).length;
24
- expect(openCount).toBe(1);
25
- expect(closeCount).toBe(1);
26
- });
27
- it("should inject items before </available_skills>", () => {
28
- const system = [
29
- "<available_skills>\n <skill>\n <name>native</name>\n <description>n</description>\n </skill>\n</available_skills>",
30
- ];
31
- injectPluginSkillsIntoSystem(system, SAMPLE_ITEMS);
32
- const nativeIndex = system[0].indexOf("<name>native</name>");
33
- const tddIndex = system[0].indexOf("<name>tdd</name>");
34
- const closeIndex = system[0].indexOf("</available_skills>");
35
- expect(nativeIndex).toBeGreaterThan(-1);
36
- expect(tddIndex).toBeGreaterThan(nativeIndex);
37
- expect(closeIndex).toBeGreaterThan(tddIndex);
38
- });
39
- it("should push a standalone block when no <available_skills> exists", () => {
40
- const system = ["plain prompt", "another segment"];
41
- injectPluginSkillsIntoSystem(system, SAMPLE_ITEMS);
42
- expect(system).toHaveLength(3);
43
- expect(system[2]).toBe(`<available_skills>\n${SAMPLE_ITEMS}\n</available_skills>`);
44
- });
45
- it("should only inject into the first matching segment", () => {
46
- const system = [
47
- "<available_skills>\n <skill>\n <name>first</name>\n <description>d</description>\n </skill>\n</available_skills>",
48
- "<available_skills>\n <skill>\n <name>second</name>\n <description>d</description>\n </skill>\n</available_skills>",
49
- ];
50
- injectPluginSkillsIntoSystem(system, SAMPLE_ITEMS);
51
- expect(system[0]).toContain("<name>tdd</name>");
52
- expect(system[1]).not.toContain("<name>tdd</name>");
53
- });
54
- it("should do nothing when pluginSkillsXmlItems is empty string", () => {
55
- const system = ["<available_skills>\n</available_skills>"];
56
- const before = [...system];
57
- injectPluginSkillsIntoSystem(system, "");
58
- expect(system).toEqual(before);
59
- });
60
- });
@@ -1,34 +0,0 @@
1
- import { type ToolContext } from "@opencode-ai/plugin";
2
- import type { createOpencodeClient } from "@opencode-ai/sdk";
3
- import { type LoadedSkill } from "../loaders";
4
- type Client = ReturnType<typeof createOpencodeClient>;
5
- export type SkillScope = "plugin" | "opencode" | "opencode-project" | "claude" | "claude-project";
6
- export interface SkillInfo {
7
- name: string;
8
- description: string;
9
- location: string;
10
- scope: SkillScope;
11
- }
12
- export interface DiscoverAllSkillsOptions {
13
- pluginSkills: LoadedSkill[];
14
- pluginDir: string;
15
- cwd: string;
16
- }
17
- export declare function discoverAllSkills(options: DiscoverAllSkillsOptions): SkillInfo[];
18
- export declare function formatPluginSkillsAsXmlItems(skills: LoadedSkill[], pluginDir: string): string;
19
- export interface CreateSkillToolOptions {
20
- pluginSkills: LoadedSkill[];
21
- pluginDir: string;
22
- cwd: string;
23
- client: Client;
24
- }
25
- export declare function createSkillTool(options: CreateSkillToolOptions): {
26
- description: string;
27
- args: {
28
- name: import("zod").ZodString;
29
- };
30
- execute(args: {
31
- name: string;
32
- }, context: ToolContext): Promise<string>;
33
- };
34
- export {};
@@ -1,137 +0,0 @@
1
- import { tool } from "@opencode-ai/plugin";
2
- import { existsSync, readdirSync, readFileSync } from "node:fs";
3
- import { join, dirname } from "node:path";
4
- import { homedir } from "node:os";
5
- import { parseFrontmatter } from "../loaders";
6
- import { log } from "../logger";
7
- const TOOL_DESCRIPTION_PREFIX = `Load a skill to get detailed instructions for a specific task.`;
8
- const TOOL_DESCRIPTION_NO_SKILLS = `${TOOL_DESCRIPTION_PREFIX} No skills are currently available.`;
9
- function discoverSkillsFromDir(skillsDir, scope) {
10
- if (!existsSync(skillsDir))
11
- return [];
12
- const skills = [];
13
- try {
14
- const entries = readdirSync(skillsDir, { withFileTypes: true });
15
- for (const entry of entries) {
16
- if (entry.name.startsWith("."))
17
- continue;
18
- if (!entry.isDirectory())
19
- continue;
20
- const skillMdPath = join(skillsDir, entry.name, "SKILL.md");
21
- if (!existsSync(skillMdPath))
22
- continue;
23
- try {
24
- const content = readFileSync(skillMdPath, "utf-8");
25
- const { data } = parseFrontmatter(content);
26
- if (!data.name || !data.description)
27
- continue;
28
- skills.push({
29
- name: data.name,
30
- description: data.description,
31
- location: skillMdPath,
32
- scope,
33
- });
34
- }
35
- catch {
36
- // Skip invalid skill files
37
- }
38
- }
39
- }
40
- catch {
41
- // Directory not accessible
42
- }
43
- return skills;
44
- }
45
- function pluginSkillsToInfo(skills, pluginDir) {
46
- return skills.map(s => ({
47
- name: s.name,
48
- description: s.description,
49
- location: s.path || join(pluginDir, "skill", s.name, "SKILL.md"),
50
- scope: "plugin",
51
- }));
52
- }
53
- function formatSkillItems(skills) {
54
- return skills
55
- .map(skill => [
56
- " <skill>",
57
- ` <name>${skill.name}</name>`,
58
- ` <description>${skill.description}</description>`,
59
- " </skill>",
60
- ].join("\n"))
61
- .join("\n");
62
- }
63
- function formatSkillsXml(skills) {
64
- if (skills.length === 0)
65
- return "";
66
- return `<available_skills>\n${formatSkillItems(skills)}\n</available_skills>`;
67
- }
68
- export function discoverAllSkills(options) {
69
- const { pluginSkills, pluginDir, cwd } = options;
70
- // Merge order: plugin < claude global < opencode global < claude project < opencode project
71
- // Later entries override earlier on name collision (project > global > plugin)
72
- const allSkills = [
73
- ...pluginSkillsToInfo(pluginSkills, pluginDir),
74
- ...discoverSkillsFromDir(join(homedir(), ".claude", "skills"), "claude"),
75
- ...discoverSkillsFromDir(join(homedir(), ".config", "opencode", "skills"), "opencode"),
76
- ...discoverSkillsFromDir(join(cwd, ".claude", "skills"), "claude-project"),
77
- ...discoverSkillsFromDir(join(cwd, ".opencode", "skills"), "opencode-project"),
78
- ];
79
- const skillMap = new Map();
80
- for (const skill of allSkills) {
81
- skillMap.set(skill.name, skill);
82
- }
83
- return Array.from(skillMap.values());
84
- }
85
- export function formatPluginSkillsAsXmlItems(skills, pluginDir) {
86
- if (skills.length === 0)
87
- return "";
88
- return formatSkillItems(pluginSkillsToInfo(skills, pluginDir));
89
- }
90
- function loadSkillContent(location) {
91
- const content = readFileSync(location, "utf-8");
92
- const { body } = parseFrontmatter(content);
93
- return body.trim();
94
- }
95
- export function createSkillTool(options) {
96
- const { client, pluginDir, pluginSkills, cwd } = options;
97
- const skills = discoverAllSkills({ pluginSkills, pluginDir, cwd });
98
- const description = skills.length === 0
99
- ? TOOL_DESCRIPTION_NO_SKILLS
100
- : `${TOOL_DESCRIPTION_PREFIX}\n\n${formatSkillsXml(skills)}`;
101
- return tool({
102
- description,
103
- args: {
104
- name: tool.schema
105
- .string()
106
- .describe("The skill identifier from available_skills (e.g., 'tdd', 'openspec-propose')"),
107
- },
108
- async execute(args, _context) {
109
- const skill = skills.find(s => s.name === args.name);
110
- if (!skill) {
111
- const available = skills.map(s => s.name).join(", ");
112
- throw new Error(`Skill "${args.name}" not found. Available skills: ${available || "none"}`);
113
- }
114
- const body = loadSkillContent(skill.location);
115
- const dir = dirname(skill.location);
116
- try {
117
- await client.tui.showToast({
118
- body: {
119
- message: `Skill "${skill.name}" loaded`,
120
- variant: "info",
121
- duration: 3000,
122
- },
123
- });
124
- }
125
- catch (error) {
126
- log("[skill] Failed to show toast", { error: String(error) });
127
- }
128
- return [
129
- `## Skill: ${skill.name}`,
130
- "",
131
- `**Base directory**: ${dir}`,
132
- "",
133
- body,
134
- ].join("\n");
135
- },
136
- });
137
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,218 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
- import { mkdirSync, writeFileSync, rmSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { tmpdir } from "node:os";
5
- import { discoverAllSkills, formatPluginSkillsAsXmlItems, } from "./skill";
6
- function createSkillFile(dir, name, content) {
7
- const skillDir = join(dir, name);
8
- mkdirSync(skillDir, { recursive: true });
9
- const skillPath = join(skillDir, "SKILL.md");
10
- writeFileSync(skillPath, content);
11
- return skillPath;
12
- }
13
- describe("formatPluginSkillsAsXmlItems", () => {
14
- it("should return empty string for no skills", () => {
15
- expect(formatPluginSkillsAsXmlItems([], "/plugin")).toBe("");
16
- });
17
- it("should format skills without wrapping <available_skills> tags", () => {
18
- const skills = [
19
- { name: "tdd", description: "Apply TDD", path: "/p/SKILL.md", body: "" },
20
- ];
21
- const result = formatPluginSkillsAsXmlItems(skills, "/plugin");
22
- expect(result).toContain("<skill>");
23
- expect(result).toContain("<name>tdd</name>");
24
- expect(result).toContain("<description>Apply TDD</description>");
25
- expect(result).not.toContain("<available_skills>");
26
- });
27
- it("should format multiple skills", () => {
28
- const skills = [
29
- { name: "a", description: "A", path: "/a", body: "" },
30
- { name: "b", description: "B", path: "/b", body: "" },
31
- ];
32
- const result = formatPluginSkillsAsXmlItems(skills, "/plugin");
33
- expect(result).toContain("<name>a</name>");
34
- expect(result).toContain("<name>b</name>");
35
- });
36
- });
37
- describe("discoverAllSkills", () => {
38
- let testDir;
39
- beforeEach(() => {
40
- testDir = join(tmpdir(), `skill-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
41
- mkdirSync(testDir, { recursive: true });
42
- });
43
- afterEach(() => {
44
- rmSync(testDir, { recursive: true, force: true });
45
- });
46
- it("should return empty array when no skills and no plugin skills", () => {
47
- const result = discoverAllSkills({
48
- pluginSkills: [],
49
- pluginDir: "/plugin",
50
- cwd: testDir,
51
- });
52
- expect(result).toEqual([]);
53
- });
54
- it("should include plugin skills with scope=plugin", () => {
55
- const pluginSkills = [
56
- { name: "tdd", description: "TDD", path: "/plugin/skill/tdd/SKILL.md", body: "" },
57
- ];
58
- const result = discoverAllSkills({
59
- pluginSkills,
60
- pluginDir: "/plugin",
61
- cwd: testDir,
62
- });
63
- expect(result).toHaveLength(1);
64
- expect(result[0].name).toBe("tdd");
65
- expect(result[0].scope).toBe("plugin");
66
- });
67
- it("should discover .opencode/skills/ in cwd", () => {
68
- const opencodeDir = join(testDir, ".opencode", "skills");
69
- mkdirSync(opencodeDir, { recursive: true });
70
- createSkillFile(opencodeDir, "project-skill", `---
71
- name: project-skill
72
- description: From project
73
- ---
74
- Body`);
75
- const result = discoverAllSkills({
76
- pluginSkills: [],
77
- pluginDir: "/plugin",
78
- cwd: testDir,
79
- });
80
- expect(result).toHaveLength(1);
81
- expect(result[0].name).toBe("project-skill");
82
- expect(result[0].scope).toBe("opencode-project");
83
- });
84
- it("should discover .claude/skills/ in cwd", () => {
85
- const claudeDir = join(testDir, ".claude", "skills");
86
- mkdirSync(claudeDir, { recursive: true });
87
- createSkillFile(claudeDir, "claude-skill", `---
88
- name: claude-skill
89
- description: From claude
90
- ---
91
- Body`);
92
- const result = discoverAllSkills({
93
- pluginSkills: [],
94
- pluginDir: "/plugin",
95
- cwd: testDir,
96
- });
97
- expect(result).toHaveLength(1);
98
- expect(result[0].scope).toBe("claude-project");
99
- });
100
- it("should let project skills override plugin skills with same name", () => {
101
- const pluginSkills = [
102
- { name: "shared", description: "Plugin version", path: "/p", body: "" },
103
- ];
104
- const opencodeDir = join(testDir, ".opencode", "skills");
105
- mkdirSync(opencodeDir, { recursive: true });
106
- createSkillFile(opencodeDir, "shared", `---
107
- name: shared
108
- description: Project version
109
- ---
110
- Body`);
111
- const result = discoverAllSkills({
112
- pluginSkills,
113
- pluginDir: "/plugin",
114
- cwd: testDir,
115
- });
116
- expect(result).toHaveLength(1);
117
- expect(result[0].description).toBe("Project version");
118
- expect(result[0].scope).toBe("opencode-project");
119
- });
120
- it("should ignore directories without SKILL.md", () => {
121
- const opencodeDir = join(testDir, ".opencode", "skills");
122
- const incompleteDir = join(opencodeDir, "no-skill");
123
- mkdirSync(incompleteDir, { recursive: true });
124
- writeFileSync(join(incompleteDir, "README.md"), "not a skill");
125
- const result = discoverAllSkills({
126
- pluginSkills: [],
127
- pluginDir: "/plugin",
128
- cwd: testDir,
129
- });
130
- expect(result).toHaveLength(0);
131
- });
132
- it("should ignore skills missing name or description", () => {
133
- const opencodeDir = join(testDir, ".opencode", "skills");
134
- mkdirSync(opencodeDir, { recursive: true });
135
- createSkillFile(opencodeDir, "no-name", `---
136
- description: no name
137
- ---
138
- Body`);
139
- createSkillFile(opencodeDir, "no-desc", `---
140
- name: no-desc
141
- ---
142
- Body`);
143
- const result = discoverAllSkills({
144
- pluginSkills: [],
145
- pluginDir: "/plugin",
146
- cwd: testDir,
147
- });
148
- expect(result).toHaveLength(0);
149
- });
150
- it("should ignore hidden directories", () => {
151
- const opencodeDir = join(testDir, ".opencode", "skills");
152
- mkdirSync(opencodeDir, { recursive: true });
153
- createSkillFile(opencodeDir, ".hidden", `---
154
- name: hidden
155
- description: hidden skill
156
- ---
157
- Body`);
158
- const result = discoverAllSkills({
159
- pluginSkills: [],
160
- pluginDir: "/plugin",
161
- cwd: testDir,
162
- });
163
- expect(result).toHaveLength(0);
164
- });
165
- it("should let opencode-project override claude-project with same name", () => {
166
- const claudeDir = join(testDir, ".claude", "skills");
167
- mkdirSync(claudeDir, { recursive: true });
168
- createSkillFile(claudeDir, "shared", `---
169
- name: shared
170
- description: Claude version
171
- ---
172
- Body`);
173
- const opencodeDir = join(testDir, ".opencode", "skills");
174
- mkdirSync(opencodeDir, { recursive: true });
175
- createSkillFile(opencodeDir, "shared", `---
176
- name: shared
177
- description: Opencode version
178
- ---
179
- Body`);
180
- const result = discoverAllSkills({
181
- pluginSkills: [],
182
- pluginDir: "/plugin",
183
- cwd: testDir,
184
- });
185
- expect(result).toHaveLength(1);
186
- expect(result[0].description).toBe("Opencode version");
187
- expect(result[0].scope).toBe("opencode-project");
188
- });
189
- it("should aggregate multiple sources", () => {
190
- const pluginSkills = [
191
- { name: "plugin-only", description: "P", path: "/p", body: "" },
192
- ];
193
- const opencodeDir = join(testDir, ".opencode", "skills");
194
- mkdirSync(opencodeDir, { recursive: true });
195
- createSkillFile(opencodeDir, "opencode-only", `---
196
- name: opencode-only
197
- description: O
198
- ---
199
- Body`);
200
- const claudeDir = join(testDir, ".claude", "skills");
201
- mkdirSync(claudeDir, { recursive: true });
202
- createSkillFile(claudeDir, "claude-only", `---
203
- name: claude-only
204
- description: C
205
- ---
206
- Body`);
207
- const result = discoverAllSkills({
208
- pluginSkills,
209
- pluginDir: "/plugin",
210
- cwd: testDir,
211
- });
212
- expect(result).toHaveLength(3);
213
- const byName = new Map(result.map((s) => [s.name, s]));
214
- expect(byName.get("plugin-only")?.scope).toBe("plugin");
215
- expect(byName.get("opencode-only")?.scope).toBe("opencode-project");
216
- expect(byName.get("claude-only")?.scope).toBe("claude-project");
217
- });
218
- });