superpowers.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. /**
  2. * Superpowers plugin for OpenCode.ai
  3. *
  4. * Provides custom tools for loading and discovering skills,
  5. * with prompt generation for agent configuration.
  6. */
  7. import path from 'path';
  8. import fs from 'fs';
  9. import os from 'os';
  10. import { fileURLToPath } from 'url';
  11. import { tool } from '@opencode-ai/plugin/tool';
  12. import * as skillsCore from '../../lib/skills-core.js';
  13. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  14. // Normalize a path: trim whitespace, expand ~, resolve to absolute
  15. const normalizePath = (p, homeDir) => {
  16. if (!p || typeof p !== 'string') return null;
  17. let normalized = p.trim();
  18. if (!normalized) return null;
  19. // Expand ~ to home directory
  20. if (normalized.startsWith('~/')) {
  21. normalized = path.join(homeDir, normalized.slice(2));
  22. } else if (normalized === '~') {
  23. normalized = homeDir;
  24. }
  25. // Resolve to absolute path
  26. return path.resolve(normalized);
  27. };
  28. export const SuperpowersPlugin = async ({ client, directory }) => {
  29. const homeDir = os.homedir();
  30. const projectSkillsDir = path.join(directory, '.opencode/skills');
  31. // Derive superpowers skills dir from plugin location (works for both symlinked and local installs)
  32. const superpowersSkillsDir = path.resolve(__dirname, '../../skills');
  33. // Respect OPENCODE_CONFIG_DIR if set, otherwise fall back to default
  34. const envConfigDir = normalizePath(process.env.OPENCODE_CONFIG_DIR, homeDir);
  35. const configDir = envConfigDir || path.join(homeDir, '.config/opencode');
  36. const personalSkillsDir = path.join(configDir, 'skills');
  37. // Helper to generate bootstrap content
  38. const getBootstrapContent = (compact = false) => {
  39. const usingSuperpowersPath = skillsCore.resolveSkillPath('using-superpowers', superpowersSkillsDir, personalSkillsDir);
  40. if (!usingSuperpowersPath) return null;
  41. const fullContent = fs.readFileSync(usingSuperpowersPath.skillFile, 'utf8');
  42. const content = skillsCore.stripFrontmatter(fullContent);
  43. const toolMapping = compact
  44. ? `**Tool Mapping:** TodoWrite->update_plan, Task->@mention, Skill->use_skill
  45. **Skills naming (priority order):** project: > personal > superpowers:`
  46. : `**Tool Mapping for OpenCode:**
  47. When skills reference tools you don't have, substitute OpenCode equivalents:
  48. - \`TodoWrite\` → \`update_plan\`
  49. - \`Task\` tool with subagents → Use OpenCode's subagent system (@mention)
  50. - \`Skill\` tool → \`use_skill\` custom tool
  51. - \`Read\`, \`Write\`, \`Edit\`, \`Bash\` → Your native tools
  52. **Skills naming (priority order):**
  53. - Project skills: \`project:skill-name\` (in .opencode/skills/)
  54. - Personal skills: \`skill-name\` (in ${configDir}/skills/)
  55. - Superpowers skills: \`superpowers:skill-name\`
  56. - Project skills override personal, which override superpowers when names match`;
  57. return `<EXTREMELY_IMPORTANT>
  58. You have superpowers.
  59. **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.**
  60. ${content}
  61. ${toolMapping}
  62. </EXTREMELY_IMPORTANT>`;
  63. };
  64. // Helper to inject bootstrap via session.prompt
  65. const injectBootstrap = async (sessionID, compact = false) => {
  66. const bootstrapContent = getBootstrapContent(compact);
  67. if (!bootstrapContent) return false;
  68. try {
  69. await client.session.prompt({
  70. path: { id: sessionID },
  71. body: {
  72. noReply: true,
  73. parts: [{ type: "text", text: bootstrapContent, synthetic: true }]
  74. }
  75. });
  76. return true;
  77. } catch (err) {
  78. return false;
  79. }
  80. };
  81. return {
  82. tool: {
  83. use_skill: tool({
  84. description: 'Load and read a specific skill to guide your work. Skills contain proven workflows, mandatory processes, and expert techniques.',
  85. args: {
  86. skill_name: tool.schema.string().describe('Name of the skill to load (e.g., "superpowers:brainstorming", "my-custom-skill", or "project:my-skill")')
  87. },
  88. execute: async (args, context) => {
  89. const { skill_name } = args;
  90. // Resolve with priority: project > personal > superpowers
  91. // Check for project: prefix first
  92. const forceProject = skill_name.startsWith('project:');
  93. const actualSkillName = forceProject ? skill_name.replace(/^project:/, '') : skill_name;
  94. let resolved = null;
  95. // Try project skills first (if project: prefix or no prefix)
  96. if (forceProject || !skill_name.startsWith('superpowers:')) {
  97. const projectPath = path.join(projectSkillsDir, actualSkillName);
  98. const projectSkillFile = path.join(projectPath, 'SKILL.md');
  99. if (fs.existsSync(projectSkillFile)) {
  100. resolved = {
  101. skillFile: projectSkillFile,
  102. sourceType: 'project',
  103. skillPath: actualSkillName
  104. };
  105. }
  106. }
  107. // Fall back to personal/superpowers resolution
  108. if (!resolved && !forceProject) {
  109. resolved = skillsCore.resolveSkillPath(skill_name, superpowersSkillsDir, personalSkillsDir);
  110. }
  111. if (!resolved) {
  112. return `Error: Skill "${skill_name}" not found.\n\nRun find_skills to see available skills.`;
  113. }
  114. const fullContent = fs.readFileSync(resolved.skillFile, 'utf8');
  115. const { name, description } = skillsCore.extractFrontmatter(resolved.skillFile);
  116. const content = skillsCore.stripFrontmatter(fullContent);
  117. const skillDirectory = path.dirname(resolved.skillFile);
  118. const skillHeader = `# ${name || skill_name}
  119. # ${description || ''}
  120. # Supporting tools and docs are in ${skillDirectory}
  121. # ============================================`;
  122. // Insert as user message with noReply for persistence across compaction
  123. try {
  124. await client.session.prompt({
  125. path: { id: context.sessionID },
  126. body: {
  127. agent: context.agent,
  128. noReply: true,
  129. parts: [
  130. { type: "text", text: `Loading skill: ${name || skill_name}`, synthetic: true },
  131. { type: "text", text: `${skillHeader}\n\n${content}`, synthetic: true }
  132. ]
  133. }
  134. });
  135. } catch (err) {
  136. // Fallback: return content directly if message insertion fails
  137. return `${skillHeader}\n\n${content}`;
  138. }
  139. return `Launching skill: ${name || skill_name}`;
  140. }
  141. }),
  142. find_skills: tool({
  143. description: 'List all available skills in the project, personal, and superpowers skill libraries.',
  144. args: {},
  145. execute: async (args, context) => {
  146. const projectSkills = skillsCore.findSkillsInDir(projectSkillsDir, 'project', 3);
  147. const personalSkills = skillsCore.findSkillsInDir(personalSkillsDir, 'personal', 3);
  148. const superpowersSkills = skillsCore.findSkillsInDir(superpowersSkillsDir, 'superpowers', 3);
  149. // Priority: project > personal > superpowers
  150. const allSkills = [...projectSkills, ...personalSkills, ...superpowersSkills];
  151. if (allSkills.length === 0) {
  152. return `No skills found. Install superpowers skills to ${superpowersSkillsDir}/ or add personal skills to ${personalSkillsDir}/`;
  153. }
  154. let output = 'Available skills:\n\n';
  155. for (const skill of allSkills) {
  156. let namespace;
  157. switch (skill.sourceType) {
  158. case 'project':
  159. namespace = 'project:';
  160. break;
  161. case 'personal':
  162. namespace = '';
  163. break;
  164. default:
  165. namespace = 'superpowers:';
  166. }
  167. const skillName = skill.name || path.basename(skill.path);
  168. output += `${namespace}${skillName}\n`;
  169. if (skill.description) {
  170. output += ` ${skill.description}\n`;
  171. }
  172. output += ` Directory: ${skill.path}\n\n`;
  173. }
  174. return output;
  175. }
  176. })
  177. },
  178. event: async ({ event }) => {
  179. // Extract sessionID from various event structures
  180. const getSessionID = () => {
  181. return event.properties?.info?.id ||
  182. event.properties?.sessionID ||
  183. event.session?.id;
  184. };
  185. // Inject bootstrap at session creation (before first user message)
  186. if (event.type === 'session.created') {
  187. const sessionID = getSessionID();
  188. if (sessionID) {
  189. await injectBootstrap(sessionID, false);
  190. }
  191. }
  192. // Re-inject bootstrap after context compaction (compact version to save tokens)
  193. if (event.type === 'session.compacted') {
  194. const sessionID = getSessionID();
  195. if (sessionID) {
  196. await injectBootstrap(sessionID, true);
  197. }
  198. }
  199. }
  200. };
  201. };