Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Ensure shell scripts always have LF line endings
*.sh text eol=lf

# Ensure the polyglot wrapper keeps LF (it's parsed by both cmd and bash)
*.cmd text eol=lf

# Common text files
*.md text eol=lf
*.json text eol=lf
*.js text eol=lf
*.mjs text eol=lf
*.ts text eol=lf

# Explicitly mark binary files
*.png binary
*.jpg binary
*.gif binary
219 changes: 40 additions & 179 deletions .opencode/plugin/superpowers.js
Original file line number Diff line number Diff line change
@@ -1,233 +1,94 @@
/**
* Superpowers plugin for OpenCode.ai
*
* Provides custom tools for loading and discovering skills,
* with prompt generation for agent configuration.
* Injects superpowers bootstrap context via system prompt transform.
* Skills are discovered via OpenCode's native skill tool from symlinked directory.
*/

import path from 'path';
import fs from 'fs';
import os from 'os';
import { fileURLToPath } from 'url';
import { tool } from '@opencode-ai/plugin/tool';
import * as skillsCore from '../../lib/skills-core.js';

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

// Simple frontmatter extraction (avoid dependency on skills-core for bootstrap)
const extractAndStripFrontmatter = (content) => {
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) return { frontmatter: {}, content };

const frontmatterStr = match[1];
const body = match[2];
const frontmatter = {};

for (const line of frontmatterStr.split('\n')) {
const colonIdx = line.indexOf(':');
if (colonIdx > 0) {
const key = line.slice(0, colonIdx).trim();
const value = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, '');
frontmatter[key] = value;
}
}

return { frontmatter, content: body };
};

// Normalize a path: trim whitespace, expand ~, resolve to absolute
const normalizePath = (p, homeDir) => {
if (!p || typeof p !== 'string') return null;
let normalized = p.trim();
if (!normalized) return null;
// Expand ~ to home directory
if (normalized.startsWith('~/')) {
normalized = path.join(homeDir, normalized.slice(2));
} else if (normalized === '~') {
normalized = homeDir;
}
// Resolve to absolute path
return path.resolve(normalized);
};

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

// Helper to generate bootstrap content
const getBootstrapContent = (compact = false) => {
const usingSuperpowersPath = skillsCore.resolveSkillPath('using-superpowers', superpowersSkillsDir, personalSkillsDir);
if (!usingSuperpowersPath) return null;

const fullContent = fs.readFileSync(usingSuperpowersPath.skillFile, 'utf8');
const content = skillsCore.stripFrontmatter(fullContent);
const getBootstrapContent = () => {
// Try to load using-superpowers skill
const skillPath = path.join(superpowersSkillsDir, 'using-superpowers', 'SKILL.md');
if (!fs.existsSync(skillPath)) return null;

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

**Skills naming (priority order):** project: > personal > superpowers:`
: `**Tool Mapping for OpenCode:**
const toolMapping = `**Tool Mapping for OpenCode:**
When skills reference tools you don't have, substitute OpenCode equivalents:
- \`TodoWrite\` → \`update_plan\`
- \`Task\` tool with subagents → Use OpenCode's subagent system (@mention)
- \`Skill\` tool → \`use_skill\` custom tool
- \`Skill\` tool → OpenCode's native \`skill\` tool
- \`Read\`, \`Write\`, \`Edit\`, \`Bash\` → Your native tools

**Skills naming (priority order):**
- Project skills: \`project:skill-name\` (in .opencode/skills/)
- Personal skills: \`skill-name\` (in ${configDir}/skills/)
- Superpowers skills: \`superpowers:skill-name\`
- Project skills override personal, which override superpowers when names match`;
**Skills location:**
Superpowers skills are in \`${configDir}/skills/superpowers/\`
Use OpenCode's native \`skill\` tool to list and load skills.`;

return `<EXTREMELY_IMPORTANT>
You have superpowers.

**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.**
**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.**

${content}

${toolMapping}
</EXTREMELY_IMPORTANT>`;
};

// Helper to inject bootstrap via session.prompt
const injectBootstrap = async (sessionID, compact = false) => {
const bootstrapContent = getBootstrapContent(compact);
if (!bootstrapContent) return false;

try {
await client.session.prompt({
path: { id: sessionID },
body: {
noReply: true,
parts: [{ type: "text", text: bootstrapContent, synthetic: true }]
}
});
return true;
} catch (err) {
return false;
}
};

return {
tool: {
use_skill: tool({
description: 'Load and read a specific skill to guide your work. Skills contain proven workflows, mandatory processes, and expert techniques.',
args: {
skill_name: tool.schema.string().describe('Name of the skill to load (e.g., "superpowers:brainstorming", "my-custom-skill", or "project:my-skill")')
},
execute: async (args, context) => {
const { skill_name } = args;

// Resolve with priority: project > personal > superpowers
// Check for project: prefix first
const forceProject = skill_name.startsWith('project:');
const actualSkillName = forceProject ? skill_name.replace(/^project:/, '') : skill_name;

let resolved = null;

// Try project skills first (if project: prefix or no prefix)
if (forceProject || !skill_name.startsWith('superpowers:')) {
const projectPath = path.join(projectSkillsDir, actualSkillName);
const projectSkillFile = path.join(projectPath, 'SKILL.md');
if (fs.existsSync(projectSkillFile)) {
resolved = {
skillFile: projectSkillFile,
sourceType: 'project',
skillPath: actualSkillName
};
}
}

// Fall back to personal/superpowers resolution
if (!resolved && !forceProject) {
resolved = skillsCore.resolveSkillPath(skill_name, superpowersSkillsDir, personalSkillsDir);
}

if (!resolved) {
return `Error: Skill "${skill_name}" not found.\n\nRun find_skills to see available skills.`;
}

const fullContent = fs.readFileSync(resolved.skillFile, 'utf8');
const { name, description } = skillsCore.extractFrontmatter(resolved.skillFile);
const content = skillsCore.stripFrontmatter(fullContent);
const skillDirectory = path.dirname(resolved.skillFile);

const skillHeader = `# ${name || skill_name}
# ${description || ''}
# Supporting tools and docs are in ${skillDirectory}
# ============================================`;

// Insert as user message with noReply for persistence across compaction
try {
await client.session.prompt({
path: { id: context.sessionID },
body: {
agent: context.agent,
noReply: true,
parts: [
{ type: "text", text: `Loading skill: ${name || skill_name}`, synthetic: true },
{ type: "text", text: `${skillHeader}\n\n${content}`, synthetic: true }
]
}
});
} catch (err) {
// Fallback: return content directly if message insertion fails
return `${skillHeader}\n\n${content}`;
}

return `Launching skill: ${name || skill_name}`;
}
}),
find_skills: tool({
description: 'List all available skills in the project, personal, and superpowers skill libraries.',
args: {},
execute: async (args, context) => {
const projectSkills = skillsCore.findSkillsInDir(projectSkillsDir, 'project', 3);
const personalSkills = skillsCore.findSkillsInDir(personalSkillsDir, 'personal', 3);
const superpowersSkills = skillsCore.findSkillsInDir(superpowersSkillsDir, 'superpowers', 3);

// Priority: project > personal > superpowers
const allSkills = [...projectSkills, ...personalSkills, ...superpowersSkills];

if (allSkills.length === 0) {
return `No skills found. Install superpowers skills to ${superpowersSkillsDir}/ or add personal skills to ${personalSkillsDir}/`;
}

let output = 'Available skills:\n\n';

for (const skill of allSkills) {
let namespace;
switch (skill.sourceType) {
case 'project':
namespace = 'project:';
break;
case 'personal':
namespace = '';
break;
default:
namespace = 'superpowers:';
}
const skillName = skill.name || path.basename(skill.path);

output += `${namespace}${skillName}\n`;
if (skill.description) {
output += ` ${skill.description}\n`;
}
output += ` Directory: ${skill.path}\n\n`;
}

return output;
}
})
},
event: async ({ event }) => {
// Extract sessionID from various event structures
const getSessionID = () => {
return event.properties?.info?.id ||
event.properties?.sessionID ||
event.session?.id;
};

// Inject bootstrap at session creation (before first user message)
if (event.type === 'session.created') {
const sessionID = getSessionID();
if (sessionID) {
await injectBootstrap(sessionID, false);
}
}

// Re-inject bootstrap after context compaction (compact version to save tokens)
if (event.type === 'session.compacted') {
const sessionID = getSessionID();
if (sessionID) {
await injectBootstrap(sessionID, true);
}
// Use system prompt transform to inject bootstrap (fixes #226 agent reset bug)
'experimental.chat.system.transform': async (_input, output) => {
const bootstrap = getBootstrapContent();
if (bootstrap) {
(output.system ||= []).push(bootstrap);
}
}
};
Expand Down
30 changes: 30 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
# Superpowers Release Notes

## Unreleased

### Breaking Changes

**OpenCode: Switched to native skills system**

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.

**Migration required:** Skills must be symlinked to `~/.config/opencode/skills/superpowers/` (see updated installation docs).

### Fixes

**OpenCode: Fixed agent reset on session start (#226)**

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.

**OpenCode: Fixed Windows installation (#232)**

- Removed dependency on `skills-core.js` (eliminates broken relative imports when file is copied instead of symlinked)
- Added comprehensive Windows installation docs for cmd.exe, PowerShell, and Git Bash
- Documented proper symlink vs junction usage for each platform

**Claude Code: Fixed Windows hook execution for Claude Code 2.1.x**

Claude Code 2.1.x changed how hooks execute on Windows: it now auto-detects `.sh` files in commands and prepends `bash `. This broke the polyglot wrapper pattern because `bash "run-hook.cmd" session-start.sh` tries to execute the .cmd file as a bash script.

Fix: hooks.json now calls session-start.sh directly. Claude Code 2.1.x handles the bash invocation automatically. Also added .gitattributes to enforce LF line endings for shell scripts (fixes CRLF issues on Windows checkout).

---

## v4.0.3 (2025-12-26)

### Improvements
Expand Down
Loading