From 5ca41539945e9beb442309b64d808dc66a00d221 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 7 May 2026 11:11:12 -0700 Subject: [PATCH] feat: add pi superpowers package extension --- README.md | 18 ++- extensions/superpowers.ts | 121 +++++++++++++++++ package.json | 19 ++- .../using-superpowers/references/pi-tools.md | 30 ++++ tests/pi/test-pi-extension.mjs | 128 ++++++++++++++++++ 5 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 extensions/superpowers.ts create mode 100644 skills/using-superpowers/references/pi-tools.md create mode 100644 tests/pi/test-pi-extension.mjs diff --git a/README.md b/README.md index e3314490..66ee2d8e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Superpowers is a complete software development methodology for your coding agent ## Quickstart -Give your agent Superpowers: [Claude Code](#claude-code), [Codex CLI](#codex-cli), [Codex App](#codex-app), [Factory Droid](#factory-droid), [Gemini CLI](#gemini-cli), [OpenCode](#opencode), [Cursor](#cursor), [GitHub Copilot CLI](#github-copilot-cli). +Give your agent Superpowers: [Claude Code](#claude-code), [Codex CLI](#codex-cli), [Codex App](#codex-app), [Factory Droid](#factory-droid), [Gemini CLI](#gemini-cli), [Pi](#pi), [OpenCode](#opencode), [Cursor](#cursor), [GitHub Copilot CLI](#github-copilot-cli). ## How it works @@ -114,6 +114,22 @@ Superpowers is available via the [official Codex plugin marketplace](https://git gemini extensions update superpowers ``` +### Pi + +Install Superpowers as a Pi package from this repository: + +```bash +pi install git:github.com/obra/superpowers +``` + +For local development, run Pi with this checkout loaded as a temporary package: + +```bash +pi -e /path/to/superpowers +``` + +The Pi package loads the Superpowers skills and a small extension that injects the `using-superpowers` bootstrap at session startup and again after compaction. Pi has native skills, so no compatibility `Skill` tool is required. Subagent and task-list tools remain optional Pi companion packages. + ### OpenCode OpenCode uses its own plugin install; install Superpowers separately even if you diff --git a/extensions/superpowers.ts b/extensions/superpowers.ts new file mode 100644 index 00000000..af44623d --- /dev/null +++ b/extensions/superpowers.ts @@ -0,0 +1,121 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const EXTREMELY_IMPORTANT_MARKER = ""; +const BOOTSTRAP_MARKER = "superpowers:using-superpowers bootstrap for pi"; + +const extensionDir = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(extensionDir, ".."); +const skillsDir = resolve(packageRoot, "skills"); +const bootstrapSkillPath = resolve(skillsDir, "using-superpowers", "SKILL.md"); + +let cachedBootstrap: string | null | undefined; + +export default function superpowersPiExtension(pi: ExtensionAPI) { + let injectBootstrap = true; + + pi.on("resources_discover", async () => ({ + skillPaths: [skillsDir], + })); + + pi.on("session_start", async () => { + injectBootstrap = true; + }); + + pi.on("session_compact", async () => { + injectBootstrap = true; + }); + + pi.on("agent_end", async () => { + injectBootstrap = false; + }); + + pi.on("context", async (event) => { + if (!injectBootstrap) return; + if (event.messages.some(messageContainsBootstrap)) return; + + const bootstrap = getBootstrapContent(); + if (!bootstrap) return; + + const bootstrapMessage = { + role: "user" as const, + content: [{ type: "text" as const, text: bootstrap }], + timestamp: Date.now(), + }; + + const insertAt = firstNonCompactionSummaryIndex(event.messages); + return { + messages: [ + ...event.messages.slice(0, insertAt), + bootstrapMessage, + ...event.messages.slice(insertAt), + ], + }; + }); +} + +function getBootstrapContent(): string | null { + if (cachedBootstrap !== undefined) return cachedBootstrap; + + try { + const skillContent = readFileSync(bootstrapSkillPath, "utf8"); + const body = stripFrontmatter(skillContent); + cachedBootstrap = `${EXTREMELY_IMPORTANT_MARKER} +${BOOTSTRAP_MARKER} + +You have superpowers. + +The using-superpowers skill content is included below and is already loaded for this Pi session. Follow it now. Do not try to load using-superpowers again. + +${body} + +${piToolMapping()} +`; + return cachedBootstrap; + } catch { + cachedBootstrap = null; + return null; + } +} + +function stripFrontmatter(content: string): string { + const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/); + return (match ? match[1] : content).trim(); +} + +function piToolMapping(): string { + return `## Pi tool mapping + +Pi has native skills but does not expose Claude Code's \`Skill\` tool. When a Superpowers instruction says to use the \`Skill\` tool, use Pi's native skill system instead: load the relevant \`SKILL.md\` with \`read\` when the skill applies, or let a human invoke \`/skill:name\` explicitly. + +Pi's built-in coding tools are lowercase: \`read\`, \`write\`, \`edit\`, \`bash\`, plus optional \`grep\`, \`find\`, and \`ls\`. Map Claude-style tool names \`Read\`, \`Write\`, \`Edit\`, and \`Bash\` to those Pi tools. + +Pi does not ship a standard \`Task\` subagent tool. If a subagent tool such as \`subagent\` from \`pi-subagents\` is available, use it for Superpowers subagent workflows. If no subagent tool is available, do the work in this session or explain the missing capability instead of inventing tool calls. + +Pi does not ship a standard \`TodoWrite\` task-list tool. If an installed todo/task tool is available, use it. Otherwise track work in plan files or a repo-local \`TODO.md\` when task tracking is needed.`; +} + +function messageContainsBootstrap(message: unknown): boolean { + const content = (message as { content?: unknown }).content; + if (typeof content === "string") return content.includes(BOOTSTRAP_MARKER); + if (!Array.isArray(content)) return false; + return content.some((part) => { + return ( + part && + typeof part === "object" && + (part as { type?: unknown }).type === "text" && + typeof (part as { text?: unknown }).text === "string" && + (part as { text: string }).text.includes(BOOTSTRAP_MARKER) + ); + }); +} + +function firstNonCompactionSummaryIndex(messages: unknown[]): number { + let index = 0; + while ((messages[index] as { role?: unknown } | undefined)?.role === "compactionSummary") { + index += 1; + } + return index; +} diff --git a/package.json b/package.json index 2b814663..00608b49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,23 @@ { "name": "superpowers", "version": "5.1.0", + "description": "Superpowers skills and runtime bootstrap for coding agents", "type": "module", - "main": ".opencode/plugins/superpowers.js" + "main": ".opencode/plugins/superpowers.js", + "keywords": [ + "pi-package", + "skills", + "tdd", + "debugging", + "collaboration", + "workflow" + ], + "pi": { + "extensions": [ + "./extensions/superpowers.ts" + ], + "skills": [ + "./skills" + ] + } } diff --git a/skills/using-superpowers/references/pi-tools.md b/skills/using-superpowers/references/pi-tools.md new file mode 100644 index 00000000..b0b5feda --- /dev/null +++ b/skills/using-superpowers/references/pi-tools.md @@ -0,0 +1,30 @@ +# Pi Tool Mapping + +Pi supports Superpowers skills natively through skill discovery and `/skill:name` commands. It does not expose Claude Code's `Skill` tool. + +When a Superpowers skill mentions Claude Code tool names, use these Pi equivalents: + +| Superpowers / Claude Code name | Pi equivalent | +| --- | --- | +| `Skill` | Pi native skills: load the relevant `SKILL.md` with `read`, or let the human use `/skill:name` | +| `Read` | `read` | +| `Write` | `write` | +| `Edit` | `edit` | +| `Bash` | `bash` | +| `Grep` | `grep` when active; otherwise `bash` with `rg`/`grep` | +| `Glob` | `find` or `bash` with shell globs | +| `LS` / `List` | `ls` when active; otherwise `bash` with `ls` | +| `Task` | Use an installed subagent tool such as `subagent` from `pi-subagents` if available | +| `TodoWrite` | Use an installed todo/task tool if available, otherwise track tasks in the plan or `TODO.md` | + +## Skills + +Pi discovers skills from configured skill directories and installed Pi packages. A Superpowers Pi package should expose `skills/` through its `pi.skills` manifest entry. The agent should still follow the Superpowers rule: when a skill applies, load and follow it before responding. + +## Subagents + +Pi core does not ship a standard subagent tool. The `pi-subagents` package is a strong optional companion and provides a `subagent` tool with single-agent, chain, parallel, async, forked-context, and resume/status workflows. If no subagent tool is available, do not fabricate `Task` calls; execute sequentially in the current session or explain that the optional subagent capability is not installed. + +## Task lists + +Pi core does not ship a standard task-list tool. If a todo/task extension is installed, use its documented tool. Otherwise use Superpowers plan files, checklists in Markdown, or a repo-local `TODO.md` for task tracking. diff --git a/tests/pi/test-pi-extension.mjs b/tests/pi/test-pi-extension.mjs new file mode 100644 index 00000000..772f3ef9 --- /dev/null +++ b/tests/pi/test-pi-extension.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import test from 'node:test'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '../..'); +const packageJsonPath = resolve(repoRoot, 'package.json'); +const extensionPath = resolve(repoRoot, 'extensions/superpowers.ts'); +const piToolsPath = resolve(repoRoot, 'skills/using-superpowers/references/pi-tools.md'); + +async function readPackageJson() { + return JSON.parse(await readFile(packageJsonPath, 'utf8')); +} + +async function loadExtension() { + const handlers = new Map(); + const pi = { + on(event, handler) { + if (!handlers.has(event)) handlers.set(event, []); + handlers.get(event).push(handler); + }, + }; + const mod = await import(pathToFileURL(extensionPath).href + `?cachebust=${Date.now()}-${Math.random()}`); + mod.default(pi); + return { handlers }; +} + +function firstHandler(handlers, event) { + const eventHandlers = handlers.get(event) ?? []; + assert.equal(eventHandlers.length, 1, `expected one ${event} handler`); + return eventHandlers[0]; +} + +function textOf(message) { + if (typeof message.content === 'string') return message.content; + return message.content + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join('\n'); +} + +test('package.json declares a pi package with skills and extension resources', async () => { + const pkg = await readPackageJson(); + + assert.equal(pkg.name, 'superpowers'); + assert.ok(pkg.keywords.includes('pi-package')); + assert.deepEqual(pkg.pi.skills, ['./skills']); + assert.deepEqual(pkg.pi.extensions, ['./extensions/superpowers.ts']); +}); + +test('extension registers lifecycle hooks without pre-compaction injection', async () => { + const { handlers } = await loadExtension(); + + for (const event of ['resources_discover', 'session_start', 'session_compact', 'context', 'agent_end']) { + assert.equal((handlers.get(event) ?? []).length, 1, `missing ${event} handler`); + } + assert.equal((handlers.get('session_before_compact') ?? []).length, 0); +}); + +test('resources_discover contributes the bundled skills directory', async () => { + const { handlers } = await loadExtension(); + const discover = firstHandler(handlers, 'resources_discover'); + + const result = await discover({ type: 'resources_discover', cwd: repoRoot, reason: 'startup' }, {}); + + assert.deepEqual(result.skillPaths, [resolve(repoRoot, 'skills')]); +}); + +test('startup context injects the bootstrap as one user message until agent_end', async () => { + const { handlers } = await loadExtension(); + const sessionStart = firstHandler(handlers, 'session_start'); + const context = firstHandler(handlers, 'context'); + const agentEnd = firstHandler(handlers, 'agent_end'); + + await sessionStart({ type: 'session_start', reason: 'startup' }, {}); + + const originalMessages = [ + { role: 'user', content: [{ type: 'text', text: 'Let us make a react todo list' }], timestamp: 1 }, + ]; + const result = await context({ type: 'context', messages: originalMessages }, {}); + + assert.equal(result.messages.length, 2); + assert.equal(result.messages[0].role, 'user'); + assert.match(textOf(result.messages[0]), /You have superpowers/); + assert.match(textOf(result.messages[0]), /Pi tool mapping/); + assert.equal(result.messages[1], originalMessages[0]); + + const repeatedProviderRequest = await context({ type: 'context', messages: originalMessages }, {}); + assert.equal(repeatedProviderRequest.messages.length, 2); + assert.match(textOf(repeatedProviderRequest.messages[0]), /You have superpowers/); + + const alreadyInjected = await context({ type: 'context', messages: result.messages }, {}); + assert.equal(alreadyInjected, undefined, 'bootstrap should not duplicate when already present'); + + await agentEnd({ type: 'agent_end', messages: [] }, {}); + const afterEnd = await context({ type: 'context', messages: originalMessages }, {}); + assert.equal(afterEnd, undefined, 'startup bootstrap should clear after agent_end'); +}); + +test('session_compact injects bootstrap after compaction summaries, not before compaction', async () => { + const { handlers } = await loadExtension(); + const sessionCompact = firstHandler(handlers, 'session_compact'); + const context = firstHandler(handlers, 'context'); + + await sessionCompact({ type: 'session_compact', compactionEntry: {}, fromExtension: false }, {}); + + const summary = { role: 'compactionSummary', summary: 'Prior work summary', tokensBefore: 123, timestamp: 1 }; + const user = { role: 'user', content: [{ type: 'text', text: 'Continue' }], timestamp: 2 }; + const result = await context({ type: 'context', messages: [summary, user] }, {}); + + assert.equal(result.messages.length, 3); + assert.equal(result.messages[0], summary); + assert.equal(result.messages[1].role, 'user'); + assert.match(textOf(result.messages[1]), /You have superpowers/); + assert.equal(result.messages[2], user); +}); + +test('pi tools reference documents pi-specific mappings', async () => { + assert.equal(existsSync(piToolsPath), true, 'pi-tools.md should exist'); + const text = await readFile(piToolsPath, 'utf8'); + + for (const expected of ['Skill', 'Task', 'TodoWrite', 'read', 'write', 'edit', 'bash']) { + assert.match(text, new RegExp(expected)); + } +});