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 +19 -0
- package/command/linear-stale-check.md +199 -0
- package/dist/index.js +13 -14
- package/dist/tools/index.d.ts +0 -1
- package/dist/tools/index.js +0 -1
- package/package.json +1 -1
- package/dist/skill-injection.d.ts +0 -1
- package/dist/skill-injection.js +0 -13
- package/dist/skill-injection.test.d.ts +0 -1
- package/dist/skill-injection.test.js +0 -60
- package/dist/tools/skill.d.ts +0 -34
- package/dist/tools/skill.js +0 -137
- package/dist/tools/skill.test.d.ts +0 -1
- package/dist/tools/skill.test.js +0 -218
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,
|
|
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
|
-
|
|
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
|
}
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/tools/index.js
CHANGED
|
@@ -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.
|
|
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;
|
package/dist/skill-injection.js
DELETED
|
@@ -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
|
-
});
|
package/dist/tools/skill.d.ts
DELETED
|
@@ -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 {};
|
package/dist/tools/skill.js
DELETED
|
@@ -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 {};
|
package/dist/tools/skill.test.js
DELETED
|
@@ -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
|
-
});
|