|
@@ -1,17 +1,29 @@
|
|
|
/**
|
|
/**
|
|
|
* Superpowers plugin for OpenCode.ai
|
|
* Superpowers plugin for OpenCode.ai
|
|
|
*
|
|
*
|
|
|
- * Injects superpowers bootstrap context via message transform.
|
|
|
|
|
- * Auto-registers skills directory via config hook (no symlinks needed).
|
|
|
|
|
|
|
+ * Dual-compatible with OpenCode V1 and V2.
|
|
|
|
|
+ *
|
|
|
|
|
+ * V1 (opencode): loaded via named export SuperpowersPlugin — provides config
|
|
|
|
|
+ * hook for skills registration and experimental.chat.messages.transform for
|
|
|
|
|
+ * bootstrap injection.
|
|
|
|
|
+ *
|
|
|
|
|
+ * V2 (opencode2): loaded via default export { id, setup } by PluginSupervisor.
|
|
|
|
|
+ * setup() registers skills natively via ctx.skill.transform(), and injects
|
|
|
|
|
+ * bootstrap context via ctx.session.hook("context").
|
|
|
|
|
+ *
|
|
|
|
|
+ * No external dependencies — pure JavaScript works in both V1 and V2 without
|
|
|
|
|
+ * installing @opencode-ai/plugin or effect.
|
|
|
*/
|
|
*/
|
|
|
|
|
|
|
|
import path from 'path';
|
|
import path from 'path';
|
|
|
import fs from 'fs';
|
|
import fs from 'fs';
|
|
|
-import os from 'os';
|
|
|
|
|
import { fileURLToPath } from 'url';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
|
|
|
|
|
|
+// Skills directory shared by V1 (config hook) and V2 (setup/ctx.skill.transform)
|
|
|
|
|
+const superpowersSkillsDir = path.resolve(__dirname, '../../skills');
|
|
|
|
|
+
|
|
|
// Simple frontmatter extraction (avoid dependency on skills-core for bootstrap)
|
|
// Simple frontmatter extraction (avoid dependency on skills-core for bootstrap)
|
|
|
const extractAndStripFrontmatter = (content) => {
|
|
const extractAndStripFrontmatter = (content) => {
|
|
|
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
@@ -33,47 +45,28 @@ const extractAndStripFrontmatter = (content) => {
|
|
|
return { frontmatter, content: body };
|
|
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;
|
|
|
|
|
- if (normalized.startsWith('~/')) {
|
|
|
|
|
- normalized = path.join(homeDir, normalized.slice(2));
|
|
|
|
|
- } else if (normalized === '~') {
|
|
|
|
|
- normalized = homeDir;
|
|
|
|
|
- }
|
|
|
|
|
- return path.resolve(normalized);
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
// Module-level cache for bootstrap content.
|
|
// Module-level cache for bootstrap content.
|
|
|
// The SKILL.md file does not change during a session, so reading + parsing it
|
|
// The SKILL.md file does not change during a session, so reading + parsing it
|
|
|
// once eliminates redundant fs.existsSync + fs.readFileSync + regex work on
|
|
// once eliminates redundant fs.existsSync + fs.readFileSync + regex work on
|
|
|
// every agent step. See #1202 for the full analysis.
|
|
// every agent step. See #1202 for the full analysis.
|
|
|
let _bootstrapCache = undefined; // undefined = not yet loaded, null = file missing
|
|
let _bootstrapCache = undefined; // undefined = not yet loaded, null = file missing
|
|
|
|
|
|
|
|
-export const SuperpowersPlugin = async ({ client, directory }) => {
|
|
|
|
|
- const homeDir = os.homedir();
|
|
|
|
|
- const superpowersSkillsDir = path.resolve(__dirname, '../../skills');
|
|
|
|
|
- const envConfigDir = normalizePath(process.env.OPENCODE_CONFIG_DIR, homeDir);
|
|
|
|
|
- const configDir = envConfigDir || path.join(homeDir, '.config/opencode');
|
|
|
|
|
-
|
|
|
|
|
- // Helper to generate bootstrap content (cached after first call)
|
|
|
|
|
- const getBootstrapContent = () => {
|
|
|
|
|
- // Return cached result on subsequent calls
|
|
|
|
|
- if (_bootstrapCache !== undefined) return _bootstrapCache;
|
|
|
|
|
-
|
|
|
|
|
- // Try to load using-superpowers skill
|
|
|
|
|
- const skillPath = path.join(superpowersSkillsDir, 'using-superpowers', 'SKILL.md');
|
|
|
|
|
- if (!fs.existsSync(skillPath)) {
|
|
|
|
|
- _bootstrapCache = null;
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+// Helper to generate bootstrap content (cached after first call)
|
|
|
|
|
+const getBootstrapContent = () => {
|
|
|
|
|
+ // Return cached result on subsequent calls
|
|
|
|
|
+ if (_bootstrapCache !== undefined) return _bootstrapCache;
|
|
|
|
|
+
|
|
|
|
|
+ // Try to load using-superpowers skill
|
|
|
|
|
+ const skillPath = path.join(superpowersSkillsDir, 'using-superpowers', 'SKILL.md');
|
|
|
|
|
+ if (!fs.existsSync(skillPath)) {
|
|
|
|
|
+ _bootstrapCache = null;
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- const fullContent = fs.readFileSync(skillPath, 'utf8');
|
|
|
|
|
- const { content } = extractAndStripFrontmatter(fullContent);
|
|
|
|
|
|
|
+ const fullContent = fs.readFileSync(skillPath, 'utf8');
|
|
|
|
|
+ const { content } = extractAndStripFrontmatter(fullContent);
|
|
|
|
|
|
|
|
- const toolMapping = `**Tool Mapping for OpenCode:**
|
|
|
|
|
|
|
+ const toolMapping = `**Tool Mapping for OpenCode:**
|
|
|
When skills request actions, substitute OpenCode equivalents:
|
|
When skills request actions, substitute OpenCode equivalents:
|
|
|
- Create or update todos → \`todowrite\`
|
|
- Create or update todos → \`todowrite\`
|
|
|
- \`Subagent (general-purpose):\` → \`task\` with \`subagent_type: "general"\`
|
|
- \`Subagent (general-purpose):\` → \`task\` with \`subagent_type: "general"\`
|
|
@@ -86,7 +79,7 @@ When skills request actions, substitute OpenCode equivalents:
|
|
|
|
|
|
|
|
Use OpenCode's native \`skill\` tool to list and load skills.`;
|
|
Use OpenCode's native \`skill\` tool to list and load skills.`;
|
|
|
|
|
|
|
|
- _bootstrapCache = `<EXTREMELY_IMPORTANT>
|
|
|
|
|
|
|
+ _bootstrapCache = `<EXTREMELY_IMPORTANT>
|
|
|
You have superpowers.
|
|
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 skill tool to load "using-superpowers" again - that would be redundant.**
|
|
**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.**
|
|
@@ -96,15 +89,25 @@ ${content}
|
|
|
${toolMapping}
|
|
${toolMapping}
|
|
|
</EXTREMELY_IMPORTANT>`;
|
|
</EXTREMELY_IMPORTANT>`;
|
|
|
|
|
|
|
|
- return _bootstrapCache;
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ return _bootstrapCache;
|
|
|
|
|
+};
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * V1 Plugin Function (named export + default.server)
|
|
|
|
|
+ *
|
|
|
|
|
+ * Used by V1 (OpenCode 1.x): discovered via named export scanning.
|
|
|
|
|
+ * Provides: config hook (V1 skills registration) + bootstrap injection
|
|
|
|
|
+ * (experimental.chat.messages.transform).
|
|
|
|
|
+ */
|
|
|
|
|
+export const SuperpowersPlugin = async ({ client, directory }) => {
|
|
|
return {
|
|
return {
|
|
|
// Inject skills path into live config so OpenCode discovers superpowers skills
|
|
// Inject skills path into live config so OpenCode discovers superpowers skills
|
|
|
// without requiring manual symlinks or config file edits.
|
|
// without requiring manual symlinks or config file edits.
|
|
|
- // This works because Config.get() returns a cached singleton — modifications
|
|
|
|
|
- // here are visible when skills are lazily discovered later.
|
|
|
|
|
config: async (config) => {
|
|
config: async (config) => {
|
|
|
|
|
+ // V2: skills is a flat array — skip, setup() handles V2 skill registration
|
|
|
|
|
+ if (Array.isArray(config.skills)) return;
|
|
|
|
|
+
|
|
|
|
|
+ // V1: skills is { paths: [...] }
|
|
|
config.skills = config.skills || {};
|
|
config.skills = config.skills || {};
|
|
|
config.skills.paths = config.skills.paths || [];
|
|
config.skills.paths = config.skills.paths || [];
|
|
|
if (!config.skills.paths.includes(superpowersSkillsDir)) {
|
|
if (!config.skills.paths.includes(superpowersSkillsDir)) {
|
|
@@ -128,8 +131,6 @@ ${toolMapping}
|
|
|
if (!firstUser || !firstUser.parts.length) return;
|
|
if (!firstUser || !firstUser.parts.length) return;
|
|
|
|
|
|
|
|
// Guard: skip if first user message already contains bootstrap.
|
|
// Guard: skip if first user message already contains bootstrap.
|
|
|
- // This prevents double injection when OpenCode passes an already
|
|
|
|
|
- // transformed in-memory message array through the hook again.
|
|
|
|
|
if (firstUser.parts.some(p => p.type === 'text' && p.text.includes('EXTREMELY_IMPORTANT'))) return;
|
|
if (firstUser.parts.some(p => p.type === 'text' && p.text.includes('EXTREMELY_IMPORTANT'))) return;
|
|
|
|
|
|
|
|
const ref = firstUser.parts[0];
|
|
const ref = firstUser.parts[0];
|
|
@@ -137,3 +138,46 @@ ${toolMapping}
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
};
|
|
};
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * V2 Setup Function (default.setup)
|
|
|
|
|
+ *
|
|
|
|
|
+ * Called by V2 PluginSupervisor (packages/core/src/plugin/).
|
|
|
|
|
+ * Performs two things:
|
|
|
|
|
+ *
|
|
|
|
|
+ * 1. Registers the skills directory natively via ctx.skill.transform().
|
|
|
|
|
+ * 2. Injects bootstrap context via ctx.session.hook("context"), the V2
|
|
|
|
|
+ * equivalent of V1's experimental.chat.messages.transform.
|
|
|
|
|
+ */
|
|
|
|
|
+async function setup(ctx) {
|
|
|
|
|
+ // 1. Register skills
|
|
|
|
|
+ await ctx.skill.transform((draft) => {
|
|
|
|
|
+ draft.source({
|
|
|
|
|
+ type: 'directory',
|
|
|
|
|
+ path: superpowersSkillsDir,
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 2. Inject bootstrap into first user message via V2 session context hook
|
|
|
|
|
+ await ctx.session.hook('context', (event) => {
|
|
|
|
|
+ const bootstrap = getBootstrapContent();
|
|
|
|
|
+ if (!bootstrap || !event.messages || !event.messages.length) return;
|
|
|
|
|
+ const firstUser = event.messages.find(m => m.role === 'user');
|
|
|
|
|
+ if (!firstUser || !firstUser.content || !firstUser.content.length) return;
|
|
|
|
|
+ if (firstUser.content.some(p => p.type === 'text' && p.text && p.text.includes('EXTREMELY_IMPORTANT'))) return;
|
|
|
|
|
+ firstUser.content.unshift({ type: 'text', text: bootstrap });
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Default Export: { id, server, setup }
|
|
|
|
|
+ *
|
|
|
|
|
+ * V2 PluginSupervisor reads { id, setup }.
|
|
|
|
|
+ * V1 reads named export SuperpowersPlugin.
|
|
|
|
|
+ * server() is exported for V1 compatibility.
|
|
|
|
|
+ */
|
|
|
|
|
+export default {
|
|
|
|
|
+ id: 'superpowers',
|
|
|
|
|
+ server: SuperpowersPlugin,
|
|
|
|
|
+ setup,
|
|
|
|
|
+};
|