Skip to content

Commit 3964d18

Browse files
obravmotta8oribarilan
committed
feat(opencode): use native skills and fix agent reset bug (obra#226) (obra#330)
* fix use_skill agent context (obra#290) * fix: respect OPENCODE_CONFIG_DIR for personal skills lookup (obra#297) * fix: respect OPENCODE_CONFIG_DIR for personal skills lookup The plugin was hardcoded to look for personal skills in ~/.config/opencode/skills, ignoring users who set OPENCODE_CONFIG_DIR to a custom path (e.g., for dotfiles management). Now uses OPENCODE_CONFIG_DIR if set, falling back to the default path. * fix: update help text to use dynamic paths Use configDir and personalSkillsDir variables in help text so paths are accurate when OPENCODE_CONFIG_DIR is set. * fix: normalize OPENCODE_CONFIG_DIR before use Handle edge cases where the env var might be: - Empty or whitespace-only - Using ~ for home directory (common in .env files) - A relative path Now trims, expands ~, and resolves to absolute path. * feat(opencode): use native skills and fix agent reset bug (obra#226) - Replace custom use_skill/find_skills tools with OpenCode's native skill tool - Use experimental.chat.system.transform hook instead of session.prompt (fixes obra#226 agent reset on first message) - Symlink skills directory into ~/.config/opencode/skills/superpowers/ - Update installation docs with comprehensive Windows support: - Command Prompt, PowerShell, and Git Bash instructions - Proper symlink vs junction handling - Reinstall safety with cleanup steps - Verification commands for each shell * Add OpenCode native skills changes to release notes Documents: - Breaking change: switch to native skill tool - Fix for agent reset bug (obra#226) - Fix for Windows installation (obra#232) --------- Co-authored-by: Vinicius da Motta <viniciusmotta8@gmail.com> Co-authored-by: oribi <oribarilan@gmail.com>
1 parent a01a135 commit 3964d18

3 files changed

Lines changed: 235 additions & 258 deletions

File tree

‎.opencode/plugin/superpowers.js‎

Lines changed: 40 additions & 179 deletions
Original file line numberDiff line numberDiff line change
@@ -1,233 +1,94 @@
11
/**
22
* Superpowers plugin for OpenCode.ai
33
*
4-
* Provides custom tools for loading and discovering skills,
5-
* with prompt generation for agent configuration.
4+
* Injects superpowers bootstrap context via system prompt transform.
5+
* Skills are discovered via OpenCode's native skill tool from symlinked directory.
66
*/
77

88
import path from 'path';
99
import fs from 'fs';
1010
import os from 'os';
1111
import { fileURLToPath } from 'url';
12-
import { tool } from '@opencode-ai/plugin/tool';
13-
import * as skillsCore from '../../lib/skills-core.js';
1412

1513
const __dirname = path.dirname(fileURLToPath(import.meta.url));
1614

15+
// Simple frontmatter extraction (avoid dependency on skills-core for bootstrap)
16+
const extractAndStripFrontmatter = (content) => {
17+
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
18+
if (!match) return { frontmatter: {}, content };
19+
20+
const frontmatterStr = match[1];
21+
const body = match[2];
22+
const frontmatter = {};
23+
24+
for (const line of frontmatterStr.split('\n')) {
25+
const colonIdx = line.indexOf(':');
26+
if (colonIdx > 0) {
27+
const key = line.slice(0, colonIdx).trim();
28+
const value = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, '');
29+
frontmatter[key] = value;
30+
}
31+
}
32+
33+
return { frontmatter, content: body };
34+
};
35+
1736
// Normalize a path: trim whitespace, expand ~, resolve to absolute
1837
const normalizePath = (p, homeDir) => {
1938
if (!p || typeof p !== 'string') return null;
2039
let normalized = p.trim();
2140
if (!normalized) return null;
22-
// Expand ~ to home directory
2341
if (normalized.startsWith('~/')) {
2442
normalized = path.join(homeDir, normalized.slice(2));
2543
} else if (normalized === '~') {
2644
normalized = homeDir;
2745
}
28-
// Resolve to absolute path
2946
return path.resolve(normalized);
3047
};
3148

3249
export const SuperpowersPlugin = async ({ client, directory }) => {
3350
const homeDir = os.homedir();
34-
const projectSkillsDir = path.join(directory, '.opencode/skills');
35-
// Derive superpowers skills dir from plugin location (works for both symlinked and local installs)
3651
const superpowersSkillsDir = path.resolve(__dirname, '../../skills');
37-
// Respect OPENCODE_CONFIG_DIR if set, otherwise fall back to default
3852
const envConfigDir = normalizePath(process.env.OPENCODE_CONFIG_DIR, homeDir);
3953
const configDir = envConfigDir || path.join(homeDir, '.config/opencode');
40-
const personalSkillsDir = path.join(configDir, 'skills');
4154

4255
// Helper to generate bootstrap content
43-
const getBootstrapContent = (compact = false) => {
44-
const usingSuperpowersPath = skillsCore.resolveSkillPath('using-superpowers', superpowersSkillsDir, personalSkillsDir);
45-
if (!usingSuperpowersPath) return null;
46-
47-
const fullContent = fs.readFileSync(usingSuperpowersPath.skillFile, 'utf8');
48-
const content = skillsCore.stripFrontmatter(fullContent);
56+
const getBootstrapContent = () => {
57+
// Try to load using-superpowers skill
58+
const skillPath = path.join(superpowersSkillsDir, 'using-superpowers', 'SKILL.md');
59+
if (!fs.existsSync(skillPath)) return null;
4960

50-
const toolMapping = compact
51-
? `**Tool Mapping:** TodoWrite->update_plan, Task->@mention, Skill->use_skill
61+
const fullContent = fs.readFileSync(skillPath, 'utf8');
62+
const { content } = extractAndStripFrontmatter(fullContent);
5263

53-
**Skills naming (priority order):** project: > personal > superpowers:`
54-
: `**Tool Mapping for OpenCode:**
64+
const toolMapping = `**Tool Mapping for OpenCode:**
5565
When skills reference tools you don't have, substitute OpenCode equivalents:
5666
- \`TodoWrite\` → \`update_plan\`
5767
- \`Task\` tool with subagents → Use OpenCode's subagent system (@mention)
58-
- \`Skill\` tool → \`use_skill\` custom tool
68+
- \`Skill\` tool → OpenCode's native \`skill\` tool
5969
- \`Read\`, \`Write\`, \`Edit\`, \`Bash\` → Your native tools
6070
61-
**Skills naming (priority order):**
62-
- Project skills: \`project:skill-name\` (in .opencode/skills/)
63-
- Personal skills: \`skill-name\` (in ${configDir}/skills/)
64-
- Superpowers skills: \`superpowers:skill-name\`
65-
- Project skills override personal, which override superpowers when names match`;
71+
**Skills location:**
72+
Superpowers skills are in \`${configDir}/skills/superpowers/\`
73+
Use OpenCode's native \`skill\` tool to list and load skills.`;
6674

6775
return `<EXTREMELY_IMPORTANT>
6876
You have superpowers.
6977
70-
**IMPORTANT: The using-superpowers skill content is included below. It is ALREADY LOADED - you are currently following it. Do NOT use the use_skill tool to load "using-superpowers" - that would be redundant. Use use_skill only for OTHER skills.**
78+
**IMPORTANT: The using-superpowers skill content is included below. It is ALREADY LOADED - you are currently following it. Do NOT use the skill tool to load "using-superpowers" again - that would be redundant.**
7179
7280
${content}
7381
7482
${toolMapping}
7583
</EXTREMELY_IMPORTANT>`;
7684
};
7785

78-
// Helper to inject bootstrap via session.prompt
79-
const injectBootstrap = async (sessionID, compact = false) => {
80-
const bootstrapContent = getBootstrapContent(compact);
81-
if (!bootstrapContent) return false;
82-
83-
try {
84-
await client.session.prompt({
85-
path: { id: sessionID },
86-
body: {
87-
noReply: true,
88-
parts: [{ type: "text", text: bootstrapContent, synthetic: true }]
89-
}
90-
});
91-
return true;
92-
} catch (err) {
93-
return false;
94-
}
95-
};
96-
9786
return {
98-
tool: {
99-
use_skill: tool({
100-
description: 'Load and read a specific skill to guide your work. Skills contain proven workflows, mandatory processes, and expert techniques.',
101-
args: {
102-
skill_name: tool.schema.string().describe('Name of the skill to load (e.g., "superpowers:brainstorming", "my-custom-skill", or "project:my-skill")')
103-
},
104-
execute: async (args, context) => {
105-
const { skill_name } = args;
106-
107-
// Resolve with priority: project > personal > superpowers
108-
// Check for project: prefix first
109-
const forceProject = skill_name.startsWith('project:');
110-
const actualSkillName = forceProject ? skill_name.replace(/^project:/, '') : skill_name;
111-
112-
let resolved = null;
113-
114-
// Try project skills first (if project: prefix or no prefix)
115-
if (forceProject || !skill_name.startsWith('superpowers:')) {
116-
const projectPath = path.join(projectSkillsDir, actualSkillName);
117-
const projectSkillFile = path.join(projectPath, 'SKILL.md');
118-
if (fs.existsSync(projectSkillFile)) {
119-
resolved = {
120-
skillFile: projectSkillFile,
121-
sourceType: 'project',
122-
skillPath: actualSkillName
123-
};
124-
}
125-
}
126-
127-
// Fall back to personal/superpowers resolution
128-
if (!resolved && !forceProject) {
129-
resolved = skillsCore.resolveSkillPath(skill_name, superpowersSkillsDir, personalSkillsDir);
130-
}
131-
132-
if (!resolved) {
133-
return `Error: Skill "${skill_name}" not found.\n\nRun find_skills to see available skills.`;
134-
}
135-
136-
const fullContent = fs.readFileSync(resolved.skillFile, 'utf8');
137-
const { name, description } = skillsCore.extractFrontmatter(resolved.skillFile);
138-
const content = skillsCore.stripFrontmatter(fullContent);
139-
const skillDirectory = path.dirname(resolved.skillFile);
140-
141-
const skillHeader = `# ${name || skill_name}
142-
# ${description || ''}
143-
# Supporting tools and docs are in ${skillDirectory}
144-
# ============================================`;
145-
146-
// Insert as user message with noReply for persistence across compaction
147-
try {
148-
await client.session.prompt({
149-
path: { id: context.sessionID },
150-
body: {
151-
agent: context.agent,
152-
noReply: true,
153-
parts: [
154-
{ type: "text", text: `Loading skill: ${name || skill_name}`, synthetic: true },
155-
{ type: "text", text: `${skillHeader}\n\n${content}`, synthetic: true }
156-
]
157-
}
158-
});
159-
} catch (err) {
160-
// Fallback: return content directly if message insertion fails
161-
return `${skillHeader}\n\n${content}`;
162-
}
163-
164-
return `Launching skill: ${name || skill_name}`;
165-
}
166-
}),
167-
find_skills: tool({
168-
description: 'List all available skills in the project, personal, and superpowers skill libraries.',
169-
args: {},
170-
execute: async (args, context) => {
171-
const projectSkills = skillsCore.findSkillsInDir(projectSkillsDir, 'project', 3);
172-
const personalSkills = skillsCore.findSkillsInDir(personalSkillsDir, 'personal', 3);
173-
const superpowersSkills = skillsCore.findSkillsInDir(superpowersSkillsDir, 'superpowers', 3);
174-
175-
// Priority: project > personal > superpowers
176-
const allSkills = [...projectSkills, ...personalSkills, ...superpowersSkills];
177-
178-
if (allSkills.length === 0) {
179-
return `No skills found. Install superpowers skills to ${superpowersSkillsDir}/ or add personal skills to ${personalSkillsDir}/`;
180-
}
181-
182-
let output = 'Available skills:\n\n';
183-
184-
for (const skill of allSkills) {
185-
let namespace;
186-
switch (skill.sourceType) {
187-
case 'project':
188-
namespace = 'project:';
189-
break;
190-
case 'personal':
191-
namespace = '';
192-
break;
193-
default:
194-
namespace = 'superpowers:';
195-
}
196-
const skillName = skill.name || path.basename(skill.path);
197-
198-
output += `${namespace}${skillName}\n`;
199-
if (skill.description) {
200-
output += ` ${skill.description}\n`;
201-
}
202-
output += ` Directory: ${skill.path}\n\n`;
203-
}
204-
205-
return output;
206-
}
207-
})
208-
},
209-
event: async ({ event }) => {
210-
// Extract sessionID from various event structures
211-
const getSessionID = () => {
212-
return event.properties?.info?.id ||
213-
event.properties?.sessionID ||
214-
event.session?.id;
215-
};
216-
217-
// Inject bootstrap at session creation (before first user message)
218-
if (event.type === 'session.created') {
219-
const sessionID = getSessionID();
220-
if (sessionID) {
221-
await injectBootstrap(sessionID, false);
222-
}
223-
}
224-
225-
// Re-inject bootstrap after context compaction (compact version to save tokens)
226-
if (event.type === 'session.compacted') {
227-
const sessionID = getSessionID();
228-
if (sessionID) {
229-
await injectBootstrap(sessionID, true);
230-
}
87+
// Use system prompt transform to inject bootstrap (fixes #226 agent reset bug)
88+
'experimental.chat.system.transform': async (_input, output) => {
89+
const bootstrap = getBootstrapContent();
90+
if (bootstrap) {
91+
(output.system ||= []).push(bootstrap);
23192
}
23293
}
23394
};

‎RELEASE-NOTES.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,29 @@
11
# Superpowers Release Notes
22

3+
## Unreleased
4+
5+
### Breaking Changes
6+
7+
**OpenCode: Switched to native skills system**
8+
9+
Superpowers for OpenCode now uses OpenCode's native `skill` tool instead of custom `use_skill`/`find_skills` tools. This is a cleaner integration that works with OpenCode's built-in skill discovery.
10+
11+
**Migration required:** Skills must be symlinked to `~/.config/opencode/skills/superpowers/` (see updated installation docs).
12+
13+
### Fixes
14+
15+
**OpenCode: Fixed agent reset on session start (#226)**
16+
17+
The previous bootstrap injection method using `session.prompt({ noReply: true })` caused OpenCode to reset the selected agent to "build" on first message. Now uses `experimental.chat.system.transform` hook which modifies the system prompt directly without side effects.
18+
19+
**OpenCode: Fixed Windows installation (#232)**
20+
21+
- Removed dependency on `skills-core.js` (eliminates broken relative imports when file is copied instead of symlinked)
22+
- Added comprehensive Windows installation docs for cmd.exe, PowerShell, and Git Bash
23+
- Documented proper symlink vs junction usage for each platform
24+
25+
---
26+
327
## v4.0.3 (2025-12-26)
428

529
### Improvements

0 commit comments

Comments
 (0)